test AI camera in e2e (#2188)

* Add a cameraZoomRange default
* Small detox version bump
* Add a mocked camera with a take photo button
* An e2e mock for the computer vision plugin predicting a frame
* Mock taking a photo by loading the first photo from the CameraRoll

Closes #1981
This commit is contained in:
Johannes Klein
2024-11-01 18:38:37 +01:00
committed by GitHub
parent 49bfb78d1b
commit 5c8e3be12c
37 changed files with 672 additions and 322 deletions

View File

@@ -2,15 +2,30 @@ import mockFaker from "tests/helpers/faker";
export const nativeInterface = jest.fn( );
export const CameraRoll = {
getPhotos: jest.fn( ( ) => ( {
page_info: {
end_cursor: jest.fn( ),
has_next_page: false
},
edges: [
// This expexcts something like
// { node: photo }
]
getPhotos: jest.fn( ( ) => new Promise( resolve => {
resolve( {
page_info: {
end_cursor: jest.fn( ),
has_next_page: false
},
edges: [
{
node: {
image: {
filename: "IMG_20210901_123456.jpg",
filepath: "/path/to/IMG_20210901_123456.jpg",
extension: "jpg",
uri: "file:///path/to/IMG_20210901_123456.jpg",
height: 1920,
width: 1080,
fileSize: 123456,
playableDuration: NaN,
orientation: 1
}
}
}
]
} );
} ) ),
getAlbums: jest.fn( ( ) => ( {
// Expecting album titles as keys and photo counts as values

View File

@@ -7,6 +7,7 @@ module.exports = {
exists: jest.fn( async ( ) => true ),
moveFile: async ( ) => "testdata",
copyFile: async ( ) => "testdata",
copyAssetsFileIOS: async ( ) => "testdata",
stat: jest.fn( ( ) => ( {
mtime: new Date()
} ) ),

80
e2e/aiCamera.e2e.js Normal file
View File

@@ -0,0 +1,80 @@
import {
by, device, element, waitFor
} from "detox";
import { iNatE2eBeforeAll, iNatE2eBeforeEach } from "./helpers";
import deleteObservation from "./sharedFlows/deleteObservation";
import signIn from "./sharedFlows/signIn";
import switchPowerMode from "./sharedFlows/switchPowerMode";
import uploadObservation from "./sharedFlows/uploadObservation";
describe( "AICamera", () => {
beforeAll( async () => iNatE2eBeforeAll( device ) );
beforeEach( async () => iNatE2eBeforeEach( device ) );
it(
"should open the ai camera, take photo, select a suggestion, upload and delete observation",
async () => {
/*
/ 1. Sign in
*/
const username = await signIn();
/*
/ 2. Switch UI to power user mode
*/
await switchPowerMode();
/*
/ 3. Take photo with AI Camera, select a suggestion, upload and delete observation
*/
// Tap to open AICamera
const addObsButton = element( by.id( "add-obs-button" ) );
await waitFor( addObsButton ).toBeVisible().withTimeout( 10000 );
await addObsButton.tap();
const aiCameraButton = element( by.id( "aicamera-button" ) );
await waitFor( aiCameraButton ).toBeVisible().withTimeout( 10000 );
await aiCameraButton.tap();
// Check that the camera screen is visible
const cameraContainer = element( by.id( "CameraWithDevice" ) );
await waitFor( cameraContainer ).toBeVisible().withTimeout( 10000 );
// Check that the mocked cv suggestion is visible
const taxonResult = element( by.id( "AICamera.taxa.51779" ) );
await waitFor( taxonResult ).toBeVisible().withTimeout( 10000 );
// Tap the take photo button
const takePhotoButton = element( by.id( "take-photo-button" ) );
await waitFor( takePhotoButton ).toBeVisible().withTimeout( 10000 );
await takePhotoButton.tap();
// On suggestions find the first element in the suggestions list
const firstSuggestion = element( by.id( /SuggestionsList\.taxa\..*/ ) ).atIndex(
0
);
await waitFor( firstSuggestion ).toBeVisible().withTimeout( 10000 );
const suggestionAttributes = await firstSuggestion.getAttributes();
const taxonID = suggestionAttributes.elements
? suggestionAttributes.elements[0].identifier.split( "." ).pop()
: suggestionAttributes.identifier.split( "." ).pop();
await firstSuggestion.tap();
// On Taxon Detail
const selectTaxonButon = element( by.id( "TaxonDetails.SelectButton" ) );
await waitFor( selectTaxonButon ).toBeVisible().withTimeout( 10000 );
await selectTaxonButon.tap();
await uploadObservation( { upload: true } );
// Check that the display taxon name is visible
const displayTaxonName = element( by.id( `display-taxon-name.${taxonID}` ) ).atIndex(
0
);
await waitFor( displayTaxonName ).toBeVisible().withTimeout( 10000 );
await displayTaxonName.tap();
// Delete the observation
await deleteObservation( { uploaded: true } );
// Make sure we're back on MyObservations
await waitFor( username ).toBeVisible().withTimeout( 10000 );
}
);
} );

View File

@@ -4,7 +4,12 @@ export async function iNatE2eBeforeAll( device ) {
if ( device.getPlatform() === "android" ) {
await device.launchApp( {
newInstance: true,
permissions: { location: "always" }
permissions: {
location: "always",
camera: "YES",
medialibrary: "YES",
photos: "YES"
}
} );
}
}
@@ -17,7 +22,12 @@ export async function iNatE2eBeforeEach( device ) {
} else {
await device.launchApp( {
newInstance: true,
permissions: { location: "always" }
permissions: {
location: "always",
camera: "YES",
medialibrary: "YES",
photos: "YES"
}
} );
// disable password autofill
execSync(

View File

@@ -0,0 +1,30 @@
import {
by, element, expect, waitFor
} from "detox";
// Start this on ObsEdit or ObsDetails via uploaded = false / true
export default async function deleteObservation( options = { uploaded: false } ) {
if ( options.uploaded ) {
const editButton = element( by.id( "ObsDetail.editButton" ) );
await waitFor( editButton ).toBeVisible().withTimeout( 10000 );
// Navigate to the edit screen
await editButton.tap();
}
// Check that the edit screen is visible
await waitFor( element( by.text( "EVIDENCE" ) ) )
.toBeVisible()
.withTimeout( 10000 );
// Press header kebab menu
const headerKebabMenu = element( by.id( "KebabMenu.Button" ) );
await expect( headerKebabMenu ).toBeVisible();
await headerKebabMenu.tap();
// Press delete observation
const deleteObservationMenuItem = element( by.id( "Header.delete-observation" ) );
await waitFor( deleteObservationMenuItem ).toBeVisible().withTimeout( 10000 );
await deleteObservationMenuItem.tap();
// Check that the delete button is visible
const deleteObservationButton = element( by.text( "DELETE" ) );
await waitFor( deleteObservationButton ).toBeVisible().withTimeout( 10000 );
// Press delete observation
await deleteObservationButton.tap();
}

29
e2e/sharedFlows/signIn.js Normal file
View File

@@ -0,0 +1,29 @@
import {
by, element, expect, waitFor
} from "detox";
import Config from "react-native-config-node";
export default async function signIn() {
const loginText = element( by.id( "log-in-to-iNaturalist-button.text" ) );
// 10000 timeout is for github actions, which was failing with a
// shorter timeout period
await waitFor( loginText ).toBeVisible().withTimeout( 10000 );
await expect( loginText ).toBeVisible();
await element( by.id( "log-in-to-iNaturalist-button.text" ) ).tap();
const usernameInput = element( by.id( "Login.email" ) );
await waitFor( usernameInput ).toBeVisible().withTimeout( 10000 );
await expect( usernameInput ).toBeVisible();
await element( by.id( "Login.email" ) ).tap();
await element( by.id( "Login.email" ) ).typeText( Config.E2E_TEST_USERNAME );
const passwordInput = element( by.id( "Login.password" ) );
await expect( passwordInput ).toBeVisible();
await element( by.id( "Login.password" ) ).tap();
await element( by.id( "Login.password" ) ).typeText( Config.E2E_TEST_PASSWORD );
const loginButton = element( by.id( "Login.loginButton" ) );
await expect( loginButton ).toBeVisible();
await element( by.id( "Login.loginButton" ) ).tap();
const username = element( by.text( `@${Config.E2E_TEST_USERNAME}` ) ).atIndex( 1 );
await waitFor( username ).toBeVisible().withTimeout( 10000 );
await expect( username ).toBeVisible();
return username;
}

View File

@@ -0,0 +1,17 @@
import {
by, element, waitFor
} from "detox";
export default async function switchPowerMode() {
const drawerButton = element( by.id( "OPEN_DRAWER" ) );
await waitFor( drawerButton ).toBeVisible().withTimeout( 10000 );
await drawerButton.tap();
// Tap the settings drawer menu item
const settingsDrawerMenuItem = element( by.id( "settings" ) );
await waitFor( settingsDrawerMenuItem ).toBeVisible().withTimeout( 10000 );
await settingsDrawerMenuItem.tap();
// Tap the settings radio button for power user mode
const powerUserRadioButton = element( by.id( "all-observation-option" ) );
await waitFor( powerUserRadioButton ).toBeVisible().withTimeout( 10000 );
await powerUserRadioButton.tap();
}

View File

@@ -0,0 +1,30 @@
import {
by, element, expect, waitFor
} from "detox";
// This needs to be a relative path for the e2e-mock version to be used
import { CHUCKS_PAD } from "../../src/appConstants/e2e";
// Upload or save an observation
export default async function uploadObservation( options = { upload: false } ) {
// Start this on ObsEdit
// Check that the new observation screen is visible
const newObservationText = element( by.id( "new-observation-text" ) );
await waitFor( newObservationText ).toBeVisible().withTimeout( 10000 );
// Ensure the location from the e2e-mock is being used so we don't end up
// with tests flaking out due to time zone issues
const pattern = new RegExp( `.*${CHUCKS_PAD.latitude.toFixed( 4 )}.*` );
const locationText = element( by.text( pattern ) );
await waitFor( locationText ).toBeVisible().withTimeout( 10000 );
if ( options.upload ) {
// Press Upload now button
const uploadNowButton = element( by.id( "ObsEdit.uploadButton" ) );
await expect( uploadNowButton ).toBeVisible();
await uploadNowButton.tap();
} else {
// Press Save button
const saveButton = element( by.id( "ObsEdit.saveButton" ) );
await expect( saveButton ).toBeVisible();
await saveButton.tap();
}
}

13
e2e/signIn.e2e.js Normal file
View File

@@ -0,0 +1,13 @@
import { device } from "detox";
import { iNatE2eBeforeAll, iNatE2eBeforeEach } from "./helpers";
import signIn from "./sharedFlows/signIn";
describe( "Shared flow", () => {
beforeAll( async () => iNatE2eBeforeAll( device ) );
beforeEach( async () => iNatE2eBeforeEach( device ) );
it( "should sign in the test user", async () => {
await signIn( );
} );
} );

View File

@@ -1,11 +1,12 @@
import {
by, device, element, expect, waitFor
} from "detox";
import Config from "react-native-config-node";
// This needs to be a relative path for the e2e-mock version to be used
import { CHUCKS_PAD } from "../src/appConstants/e2e";
import { iNatE2eBeforeAll, iNatE2eBeforeEach } from "./helpers";
import deleteObservation from "./sharedFlows/deleteObservation";
import signIn from "./sharedFlows/signIn";
import switchPowerMode from "./sharedFlows/switchPowerMode";
import uploadObservation from "./sharedFlows/uploadObservation";
describe( "Signed in user", () => {
beforeAll( async ( ) => iNatE2eBeforeAll( device ) );
@@ -22,27 +23,8 @@ describe( "Signed in user", () => {
);
await expect( obsWithoutEvidenceButton ).toBeVisible();
await obsWithoutEvidenceButton.tap();
// Check that the new observation screen is visible
await waitFor( element( by.id( "new-observation-text" ) ) )
.toBeVisible()
.withTimeout( 10000 );
// Ensure the location from the e2e-mock is being used so we don't end up
// with tests flaking out due to time zone issues
const pattern = new RegExp( `.*${CHUCKS_PAD.latitude.toFixed( 4 )}.*` );
await waitFor( element( by.text( pattern ) ) )
.toBeVisible()
.withTimeout( 10000 );
if ( options.upload ) {
// Press Upload now button
const uploadNowButton = element( by.id( "ObsEdit.uploadButton" ) );
await expect( uploadNowButton ).toBeVisible();
await uploadNowButton.tap();
} else {
// Press Save button
const saveButton = element( by.id( "ObsEdit.saveButton" ) );
await expect( saveButton ).toBeVisible();
await saveButton.tap();
}
await uploadObservation( options );
// Check that the comments count component for the obs we just created is
// visible. Since it just saved and there's an animation the runs before
@@ -69,29 +51,7 @@ describe( "Signed in user", () => {
async function deleteObservationByUUID( uuid, username, options = { uploaded: false } ) {
const obsListItem = element( by.id( `MyObservations.obsListItem.${uuid}` ) );
await obsListItem.tap();
if ( options.uploaded ) {
const editButton = element( by.id( "ObsDetail.editButton" ) );
await waitFor( editButton ).toBeVisible().withTimeout( 10000 );
// Navigate to the edit screen
await editButton.tap();
}
// Check that the edit screen is visible
await waitFor( element( by.text( "EVIDENCE" ) ) )
.toBeVisible()
.withTimeout( 10000 );
// Press header kebab menu
const headerKebabMenu = element( by.id( "KebabMenu.Button" ) );
await expect( headerKebabMenu ).toBeVisible();
await headerKebabMenu.tap();
// Press delete observation
const deleteObservation = element( by.id( "Header.delete-observation" ) );
await waitFor( deleteObservation ).toBeVisible().withTimeout( 10000 );
await deleteObservation.tap();
// Check that the delete button is visible
const deleteObservationButton = element( by.text( "DELETE" ) );
await waitFor( deleteObservationButton ).toBeVisible().withTimeout( 10000 );
// Press delete observation
await deleteObservationButton.tap();
await deleteObservation( options );
// Make sure we're back on MyObservations
await waitFor( username ).toBeVisible().withTimeout( 10000 );
}
@@ -100,47 +60,15 @@ describe( "Signed in user", () => {
/*
/ 1. Sign in
*/
const loginText = element( by.id( "log-in-to-iNaturalist-button.text" ) );
// 10000 timeout is for github actions, which was failing with a
// shorter timeout period
await waitFor( loginText ).toBeVisible().withTimeout( 10000 );
await expect( loginText ).toBeVisible();
await element( by.id( "log-in-to-iNaturalist-button.text" ) ).tap();
const usernameInput = element( by.id( "Login.email" ) );
await waitFor( usernameInput ).toBeVisible().withTimeout( 10000 );
await expect( usernameInput ).toBeVisible();
await element( by.id( "Login.email" ) ).tap();
await element( by.id( "Login.email" ) ).typeText( Config.E2E_TEST_USERNAME );
const passwordInput = element( by.id( "Login.password" ) );
await expect( passwordInput ).toBeVisible();
await element( by.id( "Login.password" ) ).tap();
await element( by.id( "Login.password" ) ).typeText( Config.E2E_TEST_PASSWORD );
const loginButton = element( by.id( "Login.loginButton" ) );
await expect( loginButton ).toBeVisible();
await element( by.id( "Login.loginButton" ) ).tap();
const username = element( by.text( `@${Config.E2E_TEST_USERNAME}` ) ).atIndex(
1
);
await waitFor( username ).toBeVisible().withTimeout( 10000 );
await expect( username ).toBeVisible();
const username = await signIn( );
/*
/ 2. Switch UI to power user mode
*/
const drawerButton = element( by.id( "OPEN_DRAWER" ) );
await waitFor( drawerButton ).toBeVisible().withTimeout( 10000 );
await drawerButton.tap();
// Tap the settings drawer menu item
const settingsDrawerMenuItem = element( by.id( "settings" ) );
await waitFor( settingsDrawerMenuItem ).toBeVisible().withTimeout( 10000 );
await settingsDrawerMenuItem.tap();
// Tap the settings radio button for power user mode
const powerUserRadioButton = element( by.id( "all-observation-option" ) );
await waitFor( powerUserRadioButton ).toBeVisible().withTimeout( 10000 );
await powerUserRadioButton.tap();
await switchPowerMode( );
/*
/ 3. Create two observations
/ 3. Create two observations without evidence
*/
const uuid = await createAndUploadObservation( { upload: true } );
// Create a second b/c later we want to test that the deleted status text
@@ -176,7 +104,7 @@ describe( "Signed in user", () => {
await waitFor( username ).toBeVisible( ).withTimeout( 10000 );
/*
/ 5. Delete the observations
/ 5. Delete the two observations without evidence
*/
await deleteObservationByUUID( uuid, username, { uploaded: true } );
// It would be nice to test for the "1 observation deleted" status text in

View File

@@ -0,0 +1,13 @@
import { device } from "detox";
import { iNatE2eBeforeAll, iNatE2eBeforeEach } from "./helpers";
import switchPowerMode from "./sharedFlows/switchPowerMode";
describe( "Shared flow", () => {
beforeAll( async () => iNatE2eBeforeAll( device ) );
beforeEach( async () => iNatE2eBeforeEach( device ) );
it( "should switch to power mode", async () => {
await switchPowerMode( );
} );
} );

234
package-lock.json generated
View File

@@ -129,7 +129,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"chalk": "^5.3.0",
"decompress": "^4.2.1",
"detox": "^20.19.4",
"detox": "^20.27.2",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
@@ -2807,9 +2807,9 @@
}
},
"node_modules/@flatten-js/interval-tree": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.2.tgz",
"integrity": "sha512-OwLoV9E/XM6b7bes2rSFnGNjyRy7vcoIHFTnmBR2WAaZTf0Fe4EX4GdA65vU1KgFAasti7iRSg2dZfYd1Zt00Q==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.3.tgz",
"integrity": "sha512-xhFWUBoHJFF77cJO1D6REjdgJEMRf2Y2Z+eKEPav8evGKcLSnj1ud5pLXQSbGuxF3VSvT1rWhMfVpXEKJLTL+A==",
"dev": true
},
"node_modules/@fluent/bundle": {
@@ -7645,6 +7645,33 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/bunyamin": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/bunyamin/-/bunyamin-1.6.3.tgz",
"integrity": "sha512-m1hAijFhu8pFiidsVc0XEDic46uxPK+mKNLqkb5mluNx0nTolNzx/DjwMqHChQWCgfOLMjKYJJ2uPTQLE6t4Ng==",
"dev": true,
"dependencies": {
"@flatten-js/interval-tree": "^1.1.2",
"multi-sort-stream": "^1.0.4",
"stream-json": "^1.7.5",
"trace-event-lib": "^1.3.1"
},
"engines": {
"node": ">=14.18.2"
},
"peerDependencies": {
"@types/bunyan": "^1.8.8",
"bunyan": "^1.8.15 || ^2.0.0"
},
"peerDependenciesMeta": {
"@types/bunyan": {
"optional": true
},
"bunyan": {
"optional": true
}
}
},
"node_modules/bunyan": {
"version": "1.8.15",
"resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz",
@@ -9118,9 +9145,9 @@
}
},
"node_modules/detox": {
"version": "20.19.4",
"resolved": "https://registry.npmjs.org/detox/-/detox-20.19.4.tgz",
"integrity": "sha512-frlWR+6oSrVhyr2xXqnSUbOwxBl1Yl7cdayIErVcHYIxfWIMYOH9o4csuIso3HRCUpJUwZkQrg0IsZUzWe08IA==",
"version": "20.27.2",
"resolved": "https://registry.npmjs.org/detox/-/detox-20.27.2.tgz",
"integrity": "sha512-cC6S40v7ix+uA5jYzG8eazSs7YtOWgc2aCwWLZIIzfE5Kvo0gfHgtqeRhrYWCMZaj/irKKs39h2B070oNQOIrA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -9130,13 +9157,14 @@
"caf": "^15.0.1",
"chalk": "^4.0.0",
"child-process-promise": "^2.2.0",
"detox-copilot": "^0.0.9",
"execa": "^5.1.1",
"find-up": "^5.0.0",
"fs-extra": "^11.0.0",
"funpermaproxy": "^1.1.0",
"glob": "^8.0.3",
"ini": "^1.3.4",
"jest-environment-emit": "^1.0.5",
"jest-environment-emit": "^1.0.8",
"json-cycle": "^1.3.0",
"lodash": "^4.17.11",
"multi-sort-stream": "^1.0.3",
@@ -9175,6 +9203,12 @@
}
}
},
"node_modules/detox-copilot": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/detox-copilot/-/detox-copilot-0.0.9.tgz",
"integrity": "sha512-Wk2fuisD8EH+349b0ysNWvZ7UEsThAChbYFlLqOR1jWkDaonEvgf6IOUlmxjvyTl9ENtl8ckd1U7k94yCBYwqw==",
"dev": true
},
"node_modules/detox/node_modules/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz",
@@ -9307,97 +9341,6 @@
"node": ">=8"
}
},
"node_modules/detox/node_modules/jest-environment-emit": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/jest-environment-emit/-/jest-environment-emit-1.0.7.tgz",
"integrity": "sha512-/0AYqbL3zrfRTtGyzTZwgRxQZiDXEM8ZUfY7Uscla/XGs9vszx4f0XTSZqAk3CQaiwYAoKvFZkB2vSKm1Q08fQ==",
"dev": true,
"dependencies": {
"bunyamin": "^1.5.2",
"bunyan": "^2.0.5",
"bunyan-debug-stream": "^3.1.0",
"funpermaproxy": "^1.1.0",
"lodash.merge": "^4.6.2",
"node-ipc": "9.2.1",
"strip-ansi": "^6.0.0",
"tslib": "^2.5.3"
},
"engines": {
"node": ">=16.14.0"
},
"peerDependencies": {
"@jest/environment": ">=27.2.5",
"@jest/types": ">=27.2.5",
"jest": ">=27.2.5",
"jest-environment-jsdom": ">=27.2.5",
"jest-environment-node": ">=27.2.5"
},
"peerDependenciesMeta": {
"@jest/environment": {
"optional": true
},
"@jest/types": {
"optional": true
},
"jest": {
"optional": true
},
"jest-environment-jsdom": {
"optional": true
},
"jest-environment-node": {
"optional": true
}
}
},
"node_modules/detox/node_modules/jest-environment-emit/node_modules/bunyamin": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/bunyamin/-/bunyamin-1.5.2.tgz",
"integrity": "sha512-Xp2nfqk33zt3nX90OSTkLVOc5N+1zdR3MWvfLHoIrm3cGRkdxPTPYB9CCgrDV8oum5rbghJjAbmXFXOrRXvMtg==",
"dev": true,
"dependencies": {
"@flatten-js/interval-tree": "^1.1.2",
"multi-sort-stream": "^1.0.4",
"stream-json": "^1.7.5",
"trace-event-lib": "^1.3.1"
},
"engines": {
"node": ">=14.18.2"
},
"peerDependencies": {
"@types/bunyan": "^1.8.8",
"bunyan": "^1.8.15 || ^2.0.0"
},
"peerDependenciesMeta": {
"@types/bunyan": {
"optional": true
},
"bunyan": {
"optional": true
}
}
},
"node_modules/detox/node_modules/jest-environment-emit/node_modules/bunyan": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/bunyan/-/bunyan-2.0.5.tgz",
"integrity": "sha512-Jvl74TdxCN6rSP9W1I6+UOUtwslTDqsSFkDqZlFb/ilaSvQ+bZAnXT/GT97IZ5L+Vph0joPZPhxUyn6FLNmFAA==",
"dev": true,
"engines": [
"node >=0.10.0"
],
"dependencies": {
"exeunt": "1.1.0"
},
"bin": {
"bunyan": "bin/bunyan"
},
"optionalDependencies": {
"dtrace-provider": "~0.8",
"moment": "^2.19.3",
"mv": "~2",
"safe-json-stringify": "~1"
}
},
"node_modules/detox/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -13282,6 +13225,70 @@
"node": ">=8"
}
},
"node_modules/jest-environment-emit": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/jest-environment-emit/-/jest-environment-emit-1.0.8.tgz",
"integrity": "sha512-WNqvxBLH0yNojHJQ99Y21963aT7UTavxV3PgiBQFi8zwrlnKU6HvkB6LOvQrbk5I8mI8JEKvcoOrQOvBVMLIXQ==",
"dev": true,
"dependencies": {
"bunyamin": "^1.5.2",
"bunyan": "^2.0.5",
"bunyan-debug-stream": "^3.1.0",
"funpermaproxy": "^1.1.0",
"lodash.merge": "^4.6.2",
"node-ipc": "9.2.1",
"strip-ansi": "^6.0.0",
"tslib": "^2.5.3"
},
"engines": {
"node": ">=16.14.0"
},
"peerDependencies": {
"@jest/environment": ">=27.2.5",
"@jest/types": ">=27.2.5",
"jest": ">=27.2.5",
"jest-environment-jsdom": ">=27.2.5",
"jest-environment-node": ">=27.2.5"
},
"peerDependenciesMeta": {
"@jest/environment": {
"optional": true
},
"@jest/types": {
"optional": true
},
"jest": {
"optional": true
},
"jest-environment-jsdom": {
"optional": true
},
"jest-environment-node": {
"optional": true
}
}
},
"node_modules/jest-environment-emit/node_modules/bunyan": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/bunyan/-/bunyan-2.0.5.tgz",
"integrity": "sha512-Jvl74TdxCN6rSP9W1I6+UOUtwslTDqsSFkDqZlFb/ilaSvQ+bZAnXT/GT97IZ5L+Vph0joPZPhxUyn6FLNmFAA==",
"dev": true,
"engines": [
"node >=0.10.0"
],
"dependencies": {
"exeunt": "1.1.0"
},
"bin": {
"bunyan": "bin/bunyan"
},
"optionalDependencies": {
"dtrace-provider": "~0.8",
"moment": "^2.19.3",
"mv": "~2",
"safe-json-stringify": "~1"
}
},
"node_modules/jest-environment-node": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
@@ -15597,9 +15604,9 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"dev": true,
"optional": true,
"engines": {
@@ -15646,6 +15653,7 @@
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
"integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"optional": true,
"dependencies": {
@@ -15663,6 +15671,7 @@
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
"integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"optional": true,
"dependencies": {
@@ -15683,9 +15692,9 @@
}
},
"node_modules/nan": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz",
"integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==",
"dev": true,
"optional": true
},
@@ -19194,9 +19203,9 @@
"dev": true
},
"node_modules/stream-json": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.7.5.tgz",
"integrity": "sha512-NSkoVduGakxZ8a+pTPUlcGEeAGQpWL9rKJhOFCV+J/QtdQUEU5vtBgVg6eJXn8JB8RZvpbJWZGvXkhz70MLWoA==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.8.0.tgz",
"integrity": "sha512-HZfXngYHUAr1exT4fxlbc1IOce1RYxp2ldeaf97LYCOPSoOqY/1Psp7iGvpb+6JIOgkra9zDYnPX01hGAHzEPw==",
"dev": true,
"dependencies": {
"stream-chain": "^2.2.5"
@@ -19967,13 +19976,12 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/trace-event-lib": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/trace-event-lib/-/trace-event-lib-1.3.1.tgz",
"integrity": "sha512-RO/TD5E9RNqU6MhOfi/njFWKYhrzOJCpRXlEQHgXwM+6boLSrQnOZ9xbHwOXzC+Luyixc7LNNSiTsqTVeF7I1g==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/trace-event-lib/-/trace-event-lib-1.4.1.tgz",
"integrity": "sha512-TOgFolKG8JFY+9d5EohGWMvwvteRafcyfPWWNIqcuD1W/FUvxWcy2MSCZ/beYHM63oYPHYHCd3tkbgCctHVP7w==",
"dev": true,
"dependencies": {
"browser-process-hrtime": "^1.0.0",
"lodash": "^4.17.21"
"browser-process-hrtime": "^1.0.0"
},
"engines": {
"node": ">=12.0.0"

View File

@@ -158,7 +158,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"chalk": "^5.3.0",
"decompress": "^4.2.1",
"detox": "^20.19.4",
"detox": "^20.27.2",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",

View File

@@ -45,11 +45,11 @@ const AddObsModal = ( { closeModal, navAndCloseModal }: Props ) => {
const prepareObsEdit = useStore( state => state.prepareObsEdit );
const obsCreateItems = useMemo( ( ) => ( {
arCamera: {
aiCamera: {
text: t( "Use-iNaturalists-AI-Camera" ),
icon: "arcamera",
onPress: ( ) => navAndCloseModal( "Camera", { camera: "AI" } ),
testID: "arcamera-button",
testID: "aicamera-button",
className: classnames( GREEN_CIRCLE_CLASS, "absolute bottom-[26px]" ),
accessibilityLabel: t( "AI-Camera" ),
accessibilityHint: t( "Navigates-to-AI-camera" )
@@ -138,7 +138,7 @@ const AddObsModal = ( { closeModal, navAndCloseModal }: Props ) => {
} )}
>
{renderAddObsIcon( obsCreateItems.standardCamera )}
{AI_CAMERA_SUPPORTED && renderAddObsIcon( obsCreateItems.arCamera )}
{AI_CAMERA_SUPPORTED && renderAddObsIcon( obsCreateItems.aiCamera )}
{renderAddObsIcon( obsCreateItems.photoLibrary )}
</View>
<View className={classnames( ROW_CLASS, "items-center" )}>

View File

@@ -9,7 +9,7 @@ import { View } from "components/styledComponents";
import React from "react";
import { GestureResponderEvent, ViewStyle } from "react-native";
import DeviceInfo from "react-native-device-info";
import { TakePhotoOptions } from "react-native-vision-camera";
import type { TakePhotoOptions } from "react-native-vision-camera";
import AIDebugButton from "./AIDebugButton";

View File

@@ -1,15 +1,16 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import CameraView from "components/Camera/CameraView.tsx";
import {
useFrameProcessor
} from "components/Camera/helpers/visionCameraWrapper";
import InatVision from "components/Camera/helpers/visionPluginWrapper";
import type { Node } from "react";
import React, {
useEffect,
useState
} from "react";
import { Platform } from "react-native";
import {
useFrameProcessor
} from "react-native-vision-camera";
import { Worklets } from "react-native-worklets-core";
import { modelPath, modelVersion, taxonomyPath } from "sharedHelpers/cvModel.ts";
import {
@@ -17,7 +18,6 @@ import {
usePatchedRunAsync
} from "sharedHelpers/visionCameraPatches";
import { useDeviceOrientation } from "sharedHooks";
import * as InatVision from "vision-camera-plugin-inatvision";
type Props = {
// $FlowIgnore
@@ -68,7 +68,7 @@ const FrameProcessorCamera = ( {
inactive
}: Props ): Node => {
const { deviceOrientation } = useDeviceOrientation();
const [lastTimestamp, setLastTimestamp] = useState( Date.now() );
const [lastTimestamp, setLastTimestamp] = useState( undefined );
const navigation = useNavigation();
@@ -128,9 +128,12 @@ const FrameProcessorCamera = ( {
return;
}
const timestamp = Date.now();
const timeSinceLastFrame = timestamp - lastTimestamp;
if ( timeSinceLastFrame < ( 1000 / fps ) ) {
return;
// If there is no lastTimestamp, i.e. the first time this runs do not compare
if ( lastTimestamp ) {
const timeSinceLastFrame = timestamp - lastTimestamp;
if ( timeSinceLastFrame < 1000 / fps ) {
return;
}
}
patchedRunAsync( frame, () => {

View File

@@ -5,7 +5,7 @@ import React from "react";
import { GestureResponderEvent, ViewStyle } from "react-native";
import DeviceInfo from "react-native-device-info";
import Animated from "react-native-reanimated";
import { TakePhotoOptions } from "react-native-vision-camera";
import type { TakePhotoOptions } from "react-native-vision-camera";
import { useTranslation } from "sharedHooks";
const isTablet = DeviceInfo.isTablet();

View File

@@ -46,6 +46,7 @@ const TakePhoto = ( {
accessibilityState={{ disabled }}
disabled={disabled}
style={DROP_SHADOW}
testID="take-photo-button"
>
{showPrediction
? (

View File

@@ -1,11 +1,11 @@
import { useNavigation, useRoute } from "@react-navigation/native";
import {
useCameraDevice
} from "components/Camera/helpers/visionCameraWrapper";
import React, {
useState
} from "react";
import { Alert } from "react-native";
import {
useCameraDevice
} from "react-native-vision-camera";
import { useTranslation } from "sharedHooks";
import CameraWithDevice from "./CameraWithDevice";

View File

@@ -1,5 +1,9 @@
import { useAppState } from "@react-native-community/hooks";
import { useIsFocused } from "@react-navigation/native";
import {
Camera,
useCameraFormat
} from "components/Camera/helpers/visionCameraWrapper";
import useFocusTap from "components/Camera/hooks/useFocusTap.ts";
import VeryBadIpadRotator from "components/SharedComponents/VeryBadIpadRotator";
import React, {
@@ -11,7 +15,6 @@ import {
} from "react-native-gesture-handler";
import Reanimated from "react-native-reanimated";
import type { CameraDevice, CameraProps, CameraRuntimeError } from "react-native-vision-camera";
import { Camera, useCameraFormat } from "react-native-vision-camera";
import { orientationPatch } from "sharedHelpers/visionCameraPatches";
import useDeviceOrientation from "sharedHooks/useDeviceOrientation.ts";

View File

@@ -1,4 +1,5 @@
import { useIsFocused, useNavigation } from "@react-navigation/native";
import { Camera } from "components/Camera/helpers/visionCameraWrapper";
import PermissionGateContainer, {
WRITE_MEDIA_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer.tsx";
@@ -9,7 +10,7 @@ import React, {
import { StatusBar } from "react-native";
import DeviceInfo from "react-native-device-info";
import Orientation from "react-native-orientation-locker";
import { Camera, CameraDevice } from "react-native-vision-camera";
import type { CameraDevice } from "react-native-vision-camera";
// import { log } from "sharedHelpers/logger";
import { useTranslation } from "sharedHooks";
import useDeviceOrientation, {

View File

@@ -6,7 +6,7 @@ import React from "react";
import { GestureResponderEvent, ViewStyle } from "react-native";
import DeviceInfo from "react-native-device-info";
import Animated from "react-native-reanimated";
import { TakePhotoOptions } from "react-native-vision-camera";
import type { TakePhotoOptions } from "react-native-vision-camera";
import CameraFlip from "./Buttons/CameraFlip";
import Flash from "./Buttons/Flash";

View File

@@ -0,0 +1,25 @@
// This wraps the react-native-vision-camera component and methods we use,
// so we can mock them for e2e tests in simulators without camera.
/*
Note that we are not mocking the useFrameProcessor hook. So, in the e2e test
a real frame processor in the sense of react-native-vision-camera is built.
As you can see in the next wrapper file our plugin is not used though in this
frame processor and only a mocked prediction is immediately returned.
*/
import { useFrameProcessor } from "react-native-vision-camera";
import {
mockCamera,
mockUseCameraDevice,
mockUseCameraFormat
} from "tests/vision-camera/vision-camera";
const Camera = mockCamera;
const useCameraDevice = mockUseCameraDevice;
const useCameraFormat = mockUseCameraFormat;
export {
Camera,
useCameraDevice,
useCameraFormat,
useFrameProcessor
};

View File

@@ -0,0 +1,12 @@
// This wraps the react-native-vision-camera component and methods we use,
// so we can mock them for e2e tests in simulators without camera.
import {
Camera,
useCameraDevice,
useCameraFormat,
useFrameProcessor
} from "react-native-vision-camera";
export {
Camera, useCameraDevice, useCameraFormat, useFrameProcessor
};

View File

@@ -0,0 +1,34 @@
// This wraps our vision camera plugin,
// so we can mock it for e2e tests in simulators without camera.
import * as InatVision from "vision-camera-plugin-inatvision";
const mockModelResult = {
timestamp: Date.now(),
predictions: [
{
name: "Sempervivum tectorum",
rank_level: 10,
rank: "species",
score: 0.96,
taxon_id: 51779
}
]
};
const mockVision = ( ) => {
"worklet";
return mockModelResult;
};
/*
We are mocking the frame processor plugin to return a defined mocked prediction.
Note that we are not mocking the getPredictionsForImage function of the plugin,
so in the e2e test when the mocked camera "saves" the photo and the app navigates
to the suggestions screen, the real example cv model is run on the still image and
the e2e test checks for a real prediction from the model.
*/
export default {
...InatVision,
inatVision: mockVision
};

View File

@@ -0,0 +1,5 @@
// This wraps our vision camera plugin,
// so we can mock it for e2e tests in simulators without camera.
import * as InatVision from "vision-camera-plugin-inatvision";
export default InatVision;

View File

@@ -1,3 +1,4 @@
import { Camera } from "components/Camera/helpers/visionCameraWrapper";
import React, {
useCallback, useMemo, useRef, useState
} from "react";
@@ -7,7 +8,6 @@ import {
GestureStateChangeEvent,
TapGestureHandlerEventPayload
} from "react-native-gesture-handler";
import { Camera } from "react-native-vision-camera";
const HALF_SIZE_FOCUS_BOX = 33;

View File

@@ -1,9 +1,10 @@
import { Camera } from "components/Camera/helpers/visionCameraWrapper";
import { RealmContext } from "providers/contexts.ts";
import React, {
useState
} from "react";
import {
Camera, CameraDevice, PhotoFile, TakePhotoOptions
import type {
CameraDevice, PhotoFile, TakePhotoOptions
} from "react-native-vision-camera";
import ObservationPhoto from "realmModels/ObservationPhoto";
import {

View File

@@ -71,14 +71,15 @@ const PhotoContainer = ( { photo, onPress, style }: Props ): Node => {
}}
/>
);
const loadingIndicator = (
<ActivityIndicator
className={classnames(
"absolute",
loadSuccess !== null && "hidden"
)}
/>
);
const renderLoadingIndicator = ( ) => {
if ( loadSuccess === null ) {
return (
<ActivityIndicator className="absolute" />
);
}
return null;
};
return (
<Pressable
@@ -88,7 +89,7 @@ const PhotoContainer = ( { photo, onPress, style }: Props ): Node => {
className="justify-center items-center"
accessibilityLabel={photo.attribution}
>
{loadingIndicator}
{renderLoadingIndicator( )}
{image}
{loadSuccess === false && (
<OfflineNotice

View File

@@ -188,7 +188,7 @@ const DisplayTaxonName = ( {
// so in these cases we want to return text only
if ( removeStyling ) {
return (
<Text testID="display-taxon-name-no-styling">
<Text testID={`display-taxon-name-no-styling.${taxon.id}`}>
{topTextComponent}
{bottomTextComponent && (
<>
@@ -204,7 +204,7 @@ const DisplayTaxonName = ( {
return (
<View
testID="display-taxon-name"
testID={`display-taxon-name.${taxon.id}`}
className={classnames( "flex", {
"flex-row items-end flex-wrap w-11/12": isHorizontal
} )}

View File

@@ -346,6 +346,7 @@ const TaxonDetails = ( ): Node => {
{showSelectButton && (
<ButtonBar containerClass="items-center z-50">
<Button
testID="TaxonDetails.SelectButton"
className="max-w-[500px] w-full"
level="focus"
text={t( "SELECT-THIS-TAXON" )}

View File

@@ -2,7 +2,10 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import CameraContainer from "components/Camera/CameraContainer.tsx";
// Please don't change this to an aliased path or the e2e mock will not get
// used in our e2e tests on Github Actions
// eslint-disable-next-line import/no-unresolved
import CameraContainer from "components/Camera/CameraContainer";
import GroupPhotosContainer from "components/PhotoImporter/GroupPhotosContainer";
import PhotoGallery from "components/PhotoImporter/PhotoGallery";
import { Heading4 } from "components/SharedComponents";

View File

@@ -1,31 +1,29 @@
import Geolocation, {
GeolocationError,
import {
GeolocationResponse
} from "@react-native-community/geolocation";
import { CHUCKS_PAD } from "appConstants/e2e.ts";
let counter = 0;
function watchPosition(
success: ( position: GeolocationResponse ) => void,
error?: ( error: GeolocationError ) => void,
options?: {
interval?: number;
fastestInterval?: number;
timeout?: number;
maximumAge?: number;
enableHighAccuracy?: boolean;
distanceFilter?: number;
useSignificantChanges?: boolean;
}
success: ( position: GeolocationResponse ) => void
) {
console.log( "[DEBUG geolocationWrapper.e2e-mock] watchPosition" );
const watchID = Date.now();
setTimeout( ( ) => {
console.log( "[DEBUG geolocationWrapper.e2e-mock] watchPosition success" );
success( {
coords: CHUCKS_PAD,
timestamp: Date.now()
} );
}, 1000 );
/*
We have to limit this here to not run forever otherwise the e2e
test never idles and times out.
*/
if ( counter < 5 ) {
setTimeout( ( ) => {
console.log( "[DEBUG geolocationWrapper.e2e-mock] watchPosition success" );
counter += 1;
success( {
coords: CHUCKS_PAD,
timestamp: Date.now()
} );
}, 1000 );
}
return watchID;
}

View File

@@ -13,9 +13,9 @@ jest.mock( "react-native/Libraries/Utilities/Platform", ( ) => ( {
describe( "AddObsModal", ( ) => {
it( "shows the AI camera button", async ( ) => {
render( <AddObsModal closeModal={jest.fn( )} /> );
const arCameraButton = screen.getByLabelText(
const aiCameraButton = screen.getByLabelText(
i18next.t( "AI-Camera" )
);
expect( arCameraButton ).toBeOnTheScreen();
expect( aiCameraButton ).toBeOnTheScreen();
} );
} );

View File

@@ -47,7 +47,9 @@ describe( "DisplayTaxonName", ( ) => {
test( "renders correct taxon for species", ( ) => {
render( <DisplayTaxonName taxon={speciesTaxon} /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
expect(
screen.getByTestId( `display-taxon-name.${speciesTaxon.id}` )
).toHaveTextContent(
`${speciesTaxon.preferred_common_name}${speciesTaxon.name}`
);
} );
@@ -55,15 +57,17 @@ describe( "DisplayTaxonName", ( ) => {
test( "renders correct taxon w/o common name", ( ) => {
render( <DisplayTaxonName taxon={noCommonNameTaxon} /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
noCommonNameTaxon.name
);
expect(
screen.getByTestId( `display-taxon-name.${noCommonNameTaxon.id}` )
).toHaveTextContent( noCommonNameTaxon.name );
} );
test( "renders correct taxon w/o common name and no species", ( ) => {
render( <DisplayTaxonName taxon={highRankTaxon} /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
expect(
screen.getByTestId( `display-taxon-name.${highRankTaxon.id}` )
).toHaveTextContent(
`${capitalizeFirstLetter( highRankTaxon.rank )} ${highRankTaxon.name}`
);
} );
@@ -71,7 +75,9 @@ describe( "DisplayTaxonName", ( ) => {
test( "renders correct taxon for a subfamily", ( ) => {
render( <DisplayTaxonName taxon={highRankTaxon} /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
expect(
screen.getByTestId( `display-taxon-name.${highRankTaxon.id}` )
).toHaveTextContent(
`${capitalizeFirstLetter( highRankTaxon.rank )} ${highRankTaxon.name}`
);
} );
@@ -79,24 +85,24 @@ describe( "DisplayTaxonName", ( ) => {
test( "renders correct taxon for subspecies", ( ) => {
render( <DisplayTaxonName taxon={subspeciesTaxon} layout="horizontal" /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
"Silver Lupine Lupinus albifrons var. collinus"
);
expect(
screen.getByTestId( `display-taxon-name.${subspeciesTaxon.id}` )
).toHaveTextContent( "Silver Lupine Lupinus albifrons var. collinus" );
} );
test( "renders correct taxon for improperly capitalized common name", ( ) => {
render( <DisplayTaxonName taxon={uncapitalizedTaxon} layout="horizontal" /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
"Crown-of-thorns Blue Sea-Stars Acanthaster planci"
);
expect(
screen.getByTestId( `display-taxon-name.${uncapitalizedTaxon.id}` )
).toHaveTextContent( "Crown-of-thorns Blue Sea-Stars Acanthaster planci" );
} );
test( "renders correct taxon for species in grid view", ( ) => {
render( <DisplayTaxonName layout="vertical" taxon={subspeciesTaxon} /> );
// Grid view should not have a space between text
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
"Silver LupineLupinus albifrons var. collinus"
);
expect(
screen.getByTestId( `display-taxon-name.${subspeciesTaxon.id}` )
).toHaveTextContent( "Silver LupineLupinus albifrons var. collinus" );
} );
} );
@@ -104,7 +110,9 @@ describe( "DisplayTaxonName", ( ) => {
test( "renders correct taxon for species", ( ) => {
render( <DisplayTaxonName taxon={speciesTaxon} scientificNameFirst layout="horizontal" /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
expect(
screen.getByTestId( `display-taxon-name.${speciesTaxon.id}` )
).toHaveTextContent(
`${speciesTaxon.name} ${speciesTaxon.preferred_common_name}`
);
} );
@@ -112,15 +120,17 @@ describe( "DisplayTaxonName", ( ) => {
test( "renders correct taxon w/o common name", ( ) => {
render( <DisplayTaxonName taxon={noCommonNameTaxon} scientificNameFirst /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
noCommonNameTaxon.name
);
expect(
screen.getByTestId( `display-taxon-name.${noCommonNameTaxon.id}` )
).toHaveTextContent( noCommonNameTaxon.name );
} );
test( "renders correct taxon w/o common name and no species", ( ) => {
render( <DisplayTaxonName taxon={highRankTaxon} scientificNameFirst /> );
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
expect(
screen.getByTestId( `display-taxon-name.${highRankTaxon.id}` )
).toHaveTextContent(
`${capitalizeFirstLetter( highRankTaxon.rank )} ${highRankTaxon.name}`
);
} );
@@ -130,9 +140,9 @@ describe( "DisplayTaxonName", ( ) => {
<DisplayTaxonName taxon={subspeciesTaxon} scientificNameFirst layout="horizontal" />
);
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
"Lupinus albifrons var. collinus Silver Lupine"
);
expect(
screen.getByTestId( `display-taxon-name.${subspeciesTaxon.id}` )
).toHaveTextContent( "Lupinus albifrons var. collinus Silver Lupine" );
} );
test( "renders correct taxon for species in grid view", ( ) => {
@@ -145,9 +155,9 @@ describe( "DisplayTaxonName", ( ) => {
);
// Grid view should not have a space between text
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
"Lupinus albifrons var. collinusSilver Lupine"
);
expect(
screen.getByTestId( `display-taxon-name.${subspeciesTaxon.id}` )
).toHaveTextContent( "Lupinus albifrons var. collinusSilver Lupine" );
} );
} );
@@ -162,9 +172,9 @@ describe( "DisplayTaxonName", ( ) => {
/>
);
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
speciesTaxon.name
);
expect(
screen.getByTestId( `display-taxon-name.${speciesTaxon.id}` )
).toHaveTextContent( speciesTaxon.name );
} );
test( "renders correct taxon w/o common name", ( ) => {
@@ -176,9 +186,9 @@ describe( "DisplayTaxonName", ( ) => {
/>
);
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
noCommonNameTaxon.name
);
expect(
screen.getByTestId( `display-taxon-name.${noCommonNameTaxon.id}` )
).toHaveTextContent( noCommonNameTaxon.name );
} );
test( "renders correct taxon w/o common name and no species", ( ) => {
@@ -190,7 +200,9 @@ describe( "DisplayTaxonName", ( ) => {
/>
);
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
expect(
screen.getByTestId( `display-taxon-name.${highRankTaxon.id}` )
).toHaveTextContent(
`${capitalizeFirstLetter( highRankTaxon.rank )} ${highRankTaxon.name}`
);
} );
@@ -205,9 +217,9 @@ describe( "DisplayTaxonName", ( ) => {
/>
);
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
"Lupinus albifrons var. collinus"
);
expect(
screen.getByTestId( `display-taxon-name.${subspeciesTaxon.id}` )
).toHaveTextContent( "Lupinus albifrons var. collinus" );
} );
test( "renders correct taxon for species in grid view", ( ) => {
@@ -220,9 +232,9 @@ describe( "DisplayTaxonName", ( ) => {
/>
);
expect( screen.getByTestId( "display-taxon-name" ) ).toHaveTextContent(
"Lupinus albifrons var. collinus"
);
expect(
screen.getByTestId( `display-taxon-name.${subspeciesTaxon.id}` )
).toHaveTextContent( "Lupinus albifrons var. collinus" );
} );
} );
@@ -248,9 +260,9 @@ describe( "DisplayTaxonName", ( ) => {
describe( "when displayed as plain text within a Trans component", ( ) => {
it( "it displays common name followed by scientific name", async ( ) => {
render( <DisplayTaxonName taxon={subspeciesTaxon} removeStyling layout="horizontal" /> );
expect( screen.getByTestId( "display-taxon-name-no-styling" ) ).toHaveTextContent(
"Silver Lupine (Lupinus albifrons var. collinus)"
);
expect(
screen.getByTestId( `display-taxon-name-no-styling.${subspeciesTaxon.id}` )
).toHaveTextContent( "Silver Lupine (Lupinus albifrons var. collinus)" );
} );
it( "it displays scientific name followed by common name", async ( ) => {
@@ -262,9 +274,9 @@ describe( "DisplayTaxonName", ( ) => {
scientificNameFirst
/>
);
expect( screen.getByTestId( "display-taxon-name-no-styling" ) ).toHaveTextContent(
"Lupinus albifrons var. collinus (Silver Lupine)"
);
expect(
screen.getByTestId( `display-taxon-name-no-styling.${subspeciesTaxon.id}` )
).toHaveTextContent( "Lupinus albifrons var. collinus (Silver Lupine)" );
} );
it( "it displays scientific name only", async ( ) => {
@@ -277,9 +289,9 @@ describe( "DisplayTaxonName", ( ) => {
prefersCommonNames={false}
/>
);
expect( screen.getByTestId( "display-taxon-name-no-styling" ) ).toHaveTextContent(
"Lupinus albifrons var. collinus"
);
expect(
screen.getByTestId( `display-taxon-name-no-styling.${subspeciesTaxon.id}` )
).toHaveTextContent( "Lupinus albifrons var. collinus" );
} );
} );
} );

View File

@@ -291,7 +291,7 @@ exports[`TaxonResult should render correctly 1`] = `
],
]
}
testID="display-taxon-name"
testID="display-taxon-name.1"
>
<Text
ellipsizeMode="tail"

View File

@@ -1,7 +1,31 @@
import { CameraRoll } from "@react-native-camera-roll/camera-roll";
import React from "react";
import { View } from "react-native";
import RNFS, { writeFile } from "react-native-fs";
import RNFS from "react-native-fs";
const mockFrame = {
isValid: true,
width: 1920,
height: 1080,
bytesPerRow: 1920,
planesCount: 1,
isMirrored: false,
timestamp: 0,
orientation: "portrait",
pixelFormat: "yuv",
// Returns a fake ArrayBuffer
toArrayBuffer: () => new ArrayBuffer( 1920 * 1080 ),
toString: () => "Frame",
getNativeBuffer: () => ( {
// Returns a fake pointer
pointer: 0,
delete: () => null
} ),
incrementRefCount: () => null,
decrementRefCount: () => null
};
const style = { flex: 1, backgroundColor: "red" };
export class mockCamera extends React.PureComponent {
static async getAvailableCameraDevices() {
return [
@@ -11,18 +35,70 @@ export class mockCamera extends React.PureComponent {
];
}
/*
Every time the component updates we are running the frame processor that is a prop
to the camera component. We are running the frame processor with a mocked frame that
does not include any kind of image data at all.
Running it only on component update means it only is called a few times and not
every second (or so - depending on fps). This is enough to satisfy the e2e test
though because the mocked prediction needs to appear only once to be found by the
test matcher. I tried running it with a timer every second but since it never idles
the test never finishes.
*/
componentDidUpdate() {
const { frameProcessor } = this.props;
frameProcessor?.frameProcessor( mockFrame );
}
// eslint-disable-next-line class-methods-use-this, react/no-unused-class-component-methods
async takePhoto( ) {
const writePath = `${RNFS.DocumentDirectoryPath}/simulated_camera_photo.png`;
const imageDataBase64 = "some_large_base_64_encoded_simulated_camera_photo";
await writeFile( writePath, imageDataBase64, "base64" );
return { path: writePath };
async takePhoto() {
// TODO: this only works on iOS
return CameraRoll.getPhotos( {
first: 20,
assetType: "Photos"
} )
.then( async r => {
/*
Basically, here, we are reading the newest twenty photos from the simulators gallery
and return the oldest one of those. Copy it to a new path and treat it as a new photo.
*/
const testPhoto = r.edges[r.edges.length - 1].node.image;
let oldUri = testPhoto.uri;
if ( testPhoto.uri.includes( "ph://" ) ) {
let id = testPhoto.uri.replace( "ph://", "" );
id = id.substring( 0, id.indexOf( "/" ) );
oldUri = `assets-library://asset/asset.jpg?id=${id}&ext=jpg`;
console.log( `Converted file uri to ${oldUri}` );
}
const encodedUri = encodeURI( oldUri );
const destPath = `${RNFS.TemporaryDirectoryPath}temp.jpg`;
const newPath = await RNFS.copyAssetsFileIOS(
encodedUri,
destPath,
0,
0
);
const photo = { uri: newPath, predictions: [] };
if ( typeof photo !== "object" ) {
console.log( "photo is not an object", typeof photo );
return null;
}
return {
...testPhoto,
path: newPath,
metadata: {
Orientation: testPhoto.orientation
}
};
} )
.catch( err => {
console.log( "Error getting photos", err );
return null;
} );
}
render() {
return <View />;
return <View style={style} />;
}
}