diff --git a/scripts/fixtures/wildlife.mjs b/scripts/fixtures/wildlife.mjs new file mode 100644 index 000000000..fb467028c --- /dev/null +++ b/scripts/fixtures/wildlife.mjs @@ -0,0 +1,261 @@ +// Wildlife Sightings without the network: the app's own iNaturalist and +// Acartia proxies (server/providers) answer the preview's /api routes in +// this process, over upstream feeds faked here. Four sightings over the +// Salish Sea: three iNaturalist observations (one with an obscured +// location, drawn as its privacy cell) and one Acartia orca report. +import { installInatProxy } from '../../server/providers/inaturalist/proxy.fixture.mjs'; +import { syntheticGrid } from '../../server/providers/inaturalist/grid.fixture.mjs'; +import { acartiaProxy } from '../../server/providers/acartia.js'; + +/** The view the scenario frames (straight down). */ +export const WILDLIFE_VIEW = Object.freeze({ + lat: 48.4, + lon: -123.1, + alt: 200000, +}); + +const HOUR_MS = 3_600_000; +// iNaturalist iconic taxon ids, as the UTFGrid carries them. +const ICONIC_IDS = { Aves: 3, Mammalia: 40151, Plantae: 47126 }; + +/** + * The sightings as the browser draws them (`lat`/`lon` after the proxy's + * projection: an obscured record sits at the centre of its 0.2° cell, an + * Acartia report is rounded to 0.01°), with what the upstream feeds send. + */ +export const WILDLIFE_SIGHTINGS = Object.freeze([ + { + id: 'inat:910001', + name: 'Bald Eagle', + scientific: 'Haliaeetus leucocephalus', + iconic: 'Aves', + raw: [48.4, -123.1], + lat: 48.4, + lon: -123.1, + }, + { + id: 'inat:910002', + name: "Douglas's Squirrel", + scientific: 'Tamiasciurus douglasii', + iconic: 'Mammalia', + raw: [48.46, -122.92], + lat: 48.46, + lon: -122.92, + }, + { + id: 'inat:910003', + name: 'Western Redcedar', + scientific: 'Thuja plicata', + iconic: 'Plantae', + obscured: true, + raw: [48.31, -123.29], + lat: 48.3, + lon: -123.3, + cell: { south: 48.2, west: -123.4, north: 48.4, east: -123.2 }, + }, + { + id: 'acartia:qa-orca-1', + name: 'Orca', + origin: 'acartia', + raw: [48.5234, -123.2167], + lat: 48.52, + lon: -123.22, + }, +]); + +const inat = WILDLIFE_SIGHTINGS.filter((s) => s.id.startsWith('inat:')); +const acartia = WILDLIFE_SIGHTINGS.filter((s) => s.origin === 'acartia'); + +/** An API v2 observation, with the fields the proxy asks for. */ +function observation(sighting, nowMs) { + const id = Number(sighting.id.slice(5)); + const [lat, lon] = sighting.raw; + const time = new Date(nowMs - 3 * HOUR_MS); + return { + id, + uri: `https://www.inaturalist.org/observations/${id}`, + location: `${lat},${lon}`, + geojson: { type: 'Point', coordinates: [lon, lat] }, + geoprivacy: sighting.obscured ? 'obscured' : null, + taxon_geoprivacy: null, + obscured: sighting.obscured === true, + quality_grade: 'research', + observed_on: time.toISOString().slice(0, 10), + time_observed_at: time.toISOString(), + faves_count: 1, + num_identification_agreements: 2, + // The feed asks for observations with photos; these carry no open + // licence, so no thumbnail is fetched. + photos: [ + { + url: `https://inaturalist-open-data.s3.amazonaws.com/photos/${id}/square.jpeg`, + attribution: '(c) QA observer, all rights reserved', + license_code: null, + }, + ], + taxon: { + name: sighting.scientific, + preferred_common_name: sighting.name, + iconic_taxon_name: sighting.iconic, + threatened: false, + }, + }; +} + +/** Web Mercator tile coordinates of a point at zoom z (fractional). */ +function tileOf(lat, lon, z) { + const count = 2 ** z; + const sin = Math.sin((lat * Math.PI) / 180); + return { + x: ((lon + 180) / 360) * count, + y: (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * count, + }; +} + +/** + * The faked upstream feeds: iNaturalist observations (filtered by the + * request's box), its UTFGrid density tiles, and the Acartia snapshot. + * `calls` counts what each upstream was asked. + */ +export function wildlifeUpstream({ now = () => Date.now() } = {}) { + const calls = { observations: 0, grid: 0, acartia: 0 }; + async function fetchImpl(href) { + const url = new URL(href); + if (url.host === 'acartia.io') { + calls.acartia++; + const created = new Date(now() - 2 * HOUR_MS) + .toISOString() + .slice(0, 19) + .replace('T', ' '); + return Response.json( + acartia.map((s) => ({ + entry_id: s.id.slice(8), + latitude: s.raw[0], + longitude: s.raw[1], + created, + type: s.name, + no_sighted: '3', + data_source_name: 'QA Whale Network', + data_source_entity: 'Community reports', + trusted: true, + })), + ); + } + if (url.host !== 'api.inaturalist.org') + throw Error('unexpected upstream ' + url.host); + const grid = /^\/v1\/grid\/(\d+)\/(\d+)\/(\d+)\.grid\.json$/.exec( + url.pathname, + ); + if (grid) { + calls.grid++; + const [z, x, y] = grid.slice(1).map(Number); + const cells = []; + for (const s of inat) { + const at = tileOf(s.raw[0], s.raw[1], z); + if (Math.floor(at.x) !== x || Math.floor(at.y) !== y) continue; + cells.push({ + row: Math.min(63, Math.floor((at.y - y) * 64)), + column: Math.min(63, Math.floor((at.x - x) * 64)), + count: 4, + iconic: ICONIC_IDS[s.iconic], + }); + } + return Response.json(syntheticGrid({ z, x, y, cells })); + } + if (url.pathname === '/v2/observations') { + calls.observations++; + const box = ['swlat', 'swlng', 'nelat', 'nelng'].map((key) => + url.searchParams.has(key) ? Number(url.searchParams.get(key)) : null, + ); + const inside = ({ raw: [lat, lon] }) => + box[0] === null || + (lat >= box[0] && lat <= box[2] && lon >= box[1] && lon <= box[3]); + const results = inat.filter(inside).map((s) => observation(s, now())); + return Response.json({ + total_results: results.length, + page: 1, + per_page: 200, + results, + }); + } + throw Error('unexpected upstream path ' + url.pathname); + } + return { fetchImpl, calls }; +} + +/** Mount an /api/acartia proxy over synthetic HTTP objects. */ +function installAcartiaProxy(options) { + let handler; + acartiaProxy({ warn() {}, ...options }).configurePreviewServer({ + middlewares: { + use(mount, callback) { + if (mount !== '/api/acartia') throw Error('acartia mount ' + mount); + handler = callback; + }, + }, + }); + return async (url, { headers = {} } = {}) => { + const res = { + headers: {}, + writeHead(status, values) { + this.status = status; + Object.assign(this.headers, values); + }, + setHeader(key, value) { + this.headers[key] = value; + }, + end(value) { + this.status ??= this.statusCode; + this.body = JSON.parse(value); + }, + }; + await handler( + { url, method: 'GET', headers, socket: { remoteAddress: '127.0.0.1' } }, + res, + ); + return res; + }; +} + +/** + * Strict-harness routes for the preview's wildlife API: each request is + * answered by the app's own proxy in this process (it never reaches the + * preview, so the preview never calls the real providers). `asked` counts + * the browser's requests per route. + */ +export function wildlifeRoutes(origin, options = {}) { + const upstream = wildlifeUpstream(options); + const proxies = { + '/api/inaturalist': installInatProxy('/observations', { + fetchImpl: upstream.fetchImpl, + now: options.now ?? (() => Date.now()), + }), + '/api/acartia': installAcartiaProxy({ + fetchImpl: upstream.fetchImpl, + now: options.now ?? (() => Date.now()), + }), + }; + const asked = { observations: 0, grid: 0, acartia: 0 }; + const route = (mount) => ({ + pattern: `${origin}${mount}/*`, + async respond(request) { + const url = new URL(request.url); + const path = url.pathname.slice(mount.length); + asked[mount === '/api/acartia' ? 'acartia' : path.slice(1)]++; + const headers = { host: url.host }; + for (const [name, value] of Object.entries(request.headers ?? {})) + headers[name.toLowerCase()] = value; + const res = await proxies[mount](path + url.search, { headers }); + return { + status: res.status, + contentType: 'application/json', + body: JSON.stringify(res.body), + }; + }, + }); + return { + routes: [route('/api/inaturalist'), route('/api/acartia')], + asked, + upstream: upstream.calls, + }; +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 78794b3bd..c7f1ef6b8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1614,5 +1614,7 @@ "src/layers/satellites/render/display.test.mjs", "src/layers/vessels/render/display.js", "src/layers/vessels/render/display.test.mjs", - "src/testSupport/vesselViewerRenderer.mjs" + "src/testSupport/vesselViewerRenderer.mjs", + "scripts/qa-wildlife-switch.mjs", + "scripts/fixtures/wildlife.mjs" ] diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs index f6bb378f4..df66382c7 100644 --- a/scripts/qa-all-layer-switches.mjs +++ b/scripts/qa-all-layer-switches.mjs @@ -283,6 +283,7 @@ const runs = [ ], ['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']], ['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']], + ['wildlife-switch', 'wildlife-switch', ['wildlife-sightings']], [ 'open-layers-switch', 'open-layers-switch', diff --git a/scripts/qa-wildlife-switch.mjs b/scripts/qa-wildlife-switch.mjs new file mode 100644 index 000000000..4d4024ff8 --- /dev/null +++ b/scripts/qa-wildlife-switch.mjs @@ -0,0 +1,502 @@ +import assert from 'node:assert/strict'; +import { fromDegrees } from '../src/geo/cartesian3.js'; +import { fromCartesian } from '../src/geo/cartographic.js'; +import { + WILDLIFE_SIGHTINGS, + WILDLIFE_VIEW, + wildlifeRoutes, +} from './fixtures/wildlife.mjs'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, +} from './qa-strict-harness.mjs'; + +// Wildlife Sightings over Cesium → Navara → Cesium → Navara: on each engine +// the four fixture sightings are badges in engine.movers at their positions +// (each one picks as itself), the density glow is its own sprite set, and +// the obscured record's privacy cell is an outline on engine.lines; a +// pointer click on a badge selects that sighting and opens its card. +// +// The first two switches are made from the keyboard with the card open (Tab +// to the Globe Engine picker, then type-ahead): the layer keeps its model, +// the selection and the card. A pointer on the picker is a click outside the +// card, which closes it by design, so the last switch (pointer and keyboard +// on the picker) checks that instead, with the model kept. +// +// node scripts/qa-wildlife-switch.mjs http://127.0.0.1:4260 + +const LAYER = 'wildlife-sightings'; +const BADGES = 'wildlife-sightings'; +const GLOW = 'wildlife-sightings-density'; +const CELL_LINE = 'wildlife-sightings-cell:'; +// One density cell per iNaturalist observation (Acartia has no grid). +const GLOW_CELLS = WILDLIFE_SIGHTINGS.filter((s) => + s.id.startsWith('inat:'), +).length; +const CELLS = WILDLIFE_SIGHTINGS.filter((s) => s.cell); +const byId = new Map(WILDLIFE_SIGHTINGS.map((s) => [s.id, s])); +// Each leg: its engine, how the switch to it is made, and the sighting it +// clicks (an iNaturalist badge, the Acartia report, the obscured record). +const LEGS = [ + { kind: 'cesium', click: 'inat:910001' }, + { kind: 'navara', via: 'keyboard', click: 'acartia:qa-orca-1' }, + { kind: 'cesium', via: 'keyboard', click: 'inat:910003' }, + { kind: 'navara', via: 'pointer', click: 'inat:910002' }, +]; + +const base = localPreview(process.argv[2]); +base.search = '?engine=cesium&welcome=0'; +base.hash = + `v=2&lat=${WILDLIFE_VIEW.lat.toFixed(4)}&lon=${WILDLIFE_VIEW.lon.toFixed(4)}` + + `&alt=${WILDLIFE_VIEW.alt}&heading=0&pitch=-90&roll=0&map=sentinel2&l=`; +const fixture = wildlifeRoutes(base.origin); +const strict = createStrict({ + origin: base.origin, + layers: [LAYER], + routes: fixture.routes, +}); +await strict.run({}, async (b) => { + const { page: p, errors } = await openPage(b); + await p.goto(base.href); + await waitForEngine(p, 'cesium'); + await p.evaluate(async (id) => { + const { dataManager, tileset } = window.__godsEyeView; + if (window.__GOOGLE_MAPS_API_KEY__ || tileset) + throw Error('Keyless preview required'); + await dataManager.setEnabled(id, true, { origin: 'user' }); + window.qaWild = dataManager.layers.get(id).module; + // Every selection the layer reports, in order. + window.qaWildSelected = []; + window.qaWild.subscribeSelection((row) => + window.qaWildSelected.push(row?.stableId ?? null), + ); + }, LAYER); + + // The framing view, straight down over the fixture. + const frame = () => + p.evaluate( + (destination) => { + const { camera, render } = window.__godsEyeView.engine; + camera.setView({ + destination, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + render.request('qa-wildlife-frame'); + }, + fromDegrees(WILDLIFE_VIEW.lon, WILDLIFE_VIEW.lat, WILDLIFE_VIEW.alt), + ); + // Until the camera has held still for a second (a focus flight is over). + const still = () => + p.waitForFunction( + () => { + const pose = window.__godsEyeView.engine.camera.pose().position; + const key = [pose.x, pose.y, pose.z].map((v) => v.toFixed(1)).join(); + if (window.__qaWildPose?.key !== key) + window.__qaWildPose = { key, at: performance.now() }; + return performance.now() - window.__qaWildPose.at > 1000; + }, + { timeout: 30000, polling: 100 }, + ); + // What the engine draws for the layer, and what its picker answers at each + // sighting's anchor. + const drawing = () => + p.evaluate( + (ids, prefix) => { + const { engine } = window.__godsEyeView; + const canvas = engine.host.canvas; + const box = canvas.getBoundingClientRect(); + const sets = Object.fromEntries( + engine.movers + .list() + .filter((set) => set.id.startsWith('wildlife-sightings')) + .map((set) => [set.id, { kind: set.kind, count: set.count }]), + ); + const cells = engine.lines + .list() + .filter((line) => line.id.startsWith(prefix)) + .map(({ points, shown }) => ({ points, shown })); + const badges = ids.map((id) => { + const position = window.qaWild.getSightingPosition(id); + const screen = + position && engine.projector.worldToWindow(position, {}); + const picked = screen && engine.picker.pick(screen); + return { + id, + position: position && { + x: position.x, + y: position.y, + z: position.z, + }, + screen: screen && { x: screen.x, y: screen.y }, + onCanvas: + !!screen && + canvas.contains( + document.elementFromPoint( + box.left + screen.x, + box.top + screen.y, + ), + ), + picked: picked + ? { + id: typeof picked.id === 'string' ? picked.id : picked.id?.id, + owner: picked.owner ?? null, + } + : null, + }; + }); + return { sets, cells, badges }; + }, + [...byId.keys()], + CELL_LINE, + ); + // Every badge in engine.movers and picked as itself, the glow drawn and + // the privacy cell outlined (the engines draw a few frames after the data). + const settled = async () => { + const deadline = Date.now() + 45000; + let drawn; + for (;;) { + await p.evaluate(() => + window.__godsEyeView.engine.render.request('qa-wildlife'), + ); + drawn = await drawing(); + const ok = + drawn.sets[BADGES]?.count === byId.size && + drawn.sets[GLOW]?.count === GLOW_CELLS && + drawn.cells.length === CELLS.length && + drawn.badges.every( + (badge) => badge.picked?.id === `wildlife:${badge.id}`, + ); + if (ok || Date.now() > deadline) return drawn; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + }; + const degrees = (position) => { + const c = fromCartesian(position); + return { + lat: (c.latitude * 180) / Math.PI, + lon: (c.longitude * 180) / Math.PI, + height: c.height, + }; + }; + const card = () => + p.evaluate(() => { + const hosts = document.querySelectorAll( + '[data-wildlife-sighting-card-host]', + ); + const host = hosts[hosts.length - 1]; + const section = host?.querySelector('[data-wildlife-sighting-card]'); + const box = section?.getBoundingClientRect(); + return { + hosts: hosts.length, + shown: + host?.style.display === 'block' && box?.width > 0 && box?.height > 0, + name: section?.getAttribute('aria-label') ?? null, + text: section?.textContent.replace(/\s+/g, ' ').trim() ?? '', + }; + }); + const selection = () => + p.evaluate(() => ({ + context: window.__gevContextStore?.selectedEntityId ?? null, + layer: window.qaWildSelected.at(-1) ?? null, + })); + const cardFor = (name) => + p.waitForFunction( + (name) => { + const host = [ + ...document.querySelectorAll('[data-wildlife-sighting-card-host]'), + ].at(-1); + const section = host?.querySelector('[data-wildlife-sighting-card]'); + return ( + host?.style.display === 'block' && + section?.getBoundingClientRect().width > 0 && + section.textContent.includes(name) + ); + }, + { timeout: 15000 }, + name, + ); + + const session = () => + p.evaluate( + (id) => ({ + same: + window.qaWild === + window.__godsEyeView.dataManager.layers.get(id).module, + enabled: window.__godsEyeView.dataManager.isEnabled(id), + selected: window.__gevContextStore?.selectedEntityId ?? null, + reported: window.qaWildSelected.at(-1) ?? null, + }), + LAYER, + ); + // The Globe Engine picker from the keyboard alone: Tab until it has the + // focus, then type-ahead (no pointer, so nothing clicks outside the card). + strict.extra.keyboardPicks = []; + const keyboardSwitch = async (kind) => { + const origin = await p.evaluate(() => performance.timeOrigin); + const picker = () => + p.evaluate(() => { + const el = document.querySelector('#globe-engine'); + const box = el?.getBoundingClientRect(); + return { + panel: document + .querySelector('#control-panel-toggle') + ?.getAttribute('aria-expanded'), + disabled: el?.disabled ?? null, + box: box ? [box.x, box.y, box.width, box.height] : null, + }; + }); + await p + .waitForFunction( + () => { + const el = document.querySelector('#globe-engine'); + return el && !el.disabled && el.getBoundingClientRect().height > 0; + }, + { timeout: 30000 }, + ) + .catch(async (error) => { + throw Error( + `the picker is not ready: ${JSON.stringify(await picker())}`, + { cause: error }, + ); + }); + // Each focus stop, as tag#id and label (the path is in the failure). + const path = []; + for (;;) { + const at = await p.evaluate(() => { + const el = document.activeElement; + return { + id: el?.id ?? null, + stop: el + ? `${el.tagName.toLowerCase()}#${el.id} ${( + el.getAttribute('aria-label') || + el.textContent || + '' + ) + .trim() + .slice(0, 30)}` + : 'none', + picker: (({ disabled, tabIndex } = {}) => ({ disabled, tabIndex }))( + document.querySelector('#globe-engine') ?? undefined, + ), + }; + }); + if (at.id === 'globe-engine') break; + path.push(at.stop); + assert.ok( + path.length <= 200, + `Tab never reaches the Globe Engine picker ${JSON.stringify({ picker: at.picker, path: path.slice(0, 80) })}`, + ); + await p.keyboard.press('Tab'); + } + const tabs = path.length; + const from = await p.$eval('#globe-engine', (el) => el.value); + await p.keyboard.type(kind[0]); + await waitForEngine(p, kind); + assert.equal( + await p.evaluate(() => performance.timeOrigin), + origin, + 'same document after switching globes', + ); + strict.extra.keyboardPicks.push({ from, to: kind, tabs }); + }; + const expandPanel = async () => { + const expanded = () => + p.$eval( + '#control-panel-toggle', + (el) => el.getAttribute('aria-expanded') === 'true', + ); + if (await expanded()) return; + await p.click('#control-panel-toggle'); + await p.waitForFunction( + () => + document + .querySelector('#control-panel-toggle') + ?.getAttribute('aria-expanded') === 'true', + ); + }; + + let selected = null; + for (const { kind, via, click: id } of LEGS) { + if (via) { + const reads = { ...fixture.asked }; + const sighting = byId.get(selected); + if (via === 'keyboard') { + await keyboardSwitch(kind); + // The session carries over: the same layer model, still on, the + // clicked sighting still selected, and the new globe's card shows it. + await cardFor(sighting.name); + const kept = await session(); + const shown = await card(); + await strict.check( + p, + LAYER, + `a switch keeps the layer model on, ${sighting.name} selected and its card open`, + kept.same && + kept.enabled && + kept.selected === selected && + kept.reported === selected && + shown.hosts === 1 && + shown.shown && + shown.name === sighting.name, + { ...kept, card: shown, readsBefore: reads, reads: fixture.asked }, + ); + console.log('PASS', kind, 'selection kept', selected); + } else { + await strict.switchTo(p, kind); + // The pointer on the picker clicked outside the card: it closed and + // the selection cleared; the layer model stayed on. + const kept = await session(); + const shown = await card(); + await strict.check( + p, + LAYER, + `a pointer on the picker closes the ${sighting.name} card; the layer model stays on`, + kept.same && + kept.enabled && + kept.selected === null && + kept.reported === null && + shown.hosts === 1 && + !shown.shown, + { ...kept, card: shown, readsBefore: reads, reads: fixture.asked }, + ); + console.log('PASS', kind, 'picker click closed the card'); + selected = null; + } + await frame(); + } + + // Drawn through the contract: badges, glow, privacy cell. + await still(); + const drawn = await settled(); + const badges = drawn.badges.map((badge) => ({ + ...badge, + at: badge.position && degrees(badge.position), + })); + for (const badge of badges) { + const expected = byId.get(badge.id); + assert.ok(badge.at, `${kind}: ${badge.id} has no anchor`); + assert.ok( + Math.abs(badge.at.lat - expected.lat) < 1e-6 && + Math.abs(badge.at.lon - expected.lon) < 1e-6 && + badge.at.height > -500 && + badge.at.height < 3000, + `${kind}: ${badge.id} anchored at ${JSON.stringify(badge.at)}`, + ); + } + await strict.check( + p, + LAYER, + `${byId.size} badges live in engine.movers at their sightings, each picked as itself`, + drawn.sets[BADGES]?.kind === 'sprite' && + drawn.sets[BADGES].count === byId.size && + badges.every( + (badge) => + badge.picked?.id === `wildlife:${badge.id}` && + badge.picked.owner === BADGES, + ), + { + set: drawn.sets[BADGES], + badges: badges.map(({ id, at, screen, picked }) => ({ + id, + at, + screen, + picked, + })), + }, + ); + await strict.check( + p, + LAYER, + `the density glow is its own sprite set with ${GLOW_CELLS} cells`, + drawn.sets[GLOW]?.kind === 'sprite' && + drawn.sets[GLOW].count === GLOW_CELLS, + drawn.sets[GLOW], + ); + await strict.check( + p, + LAYER, + 'the obscured record’s privacy cell is a shown outline on engine.lines', + drawn.cells.length === CELLS.length && + drawn.cells.every((line) => line.shown && line.points === 33), + drawn.cells, + ); + console.log( + 'PASS', + kind, + 'drawn', + JSON.stringify(drawn.sets), + JSON.stringify(drawn.cells), + ); + + // Escape closes the carried-over card and clears the selection. + if (selected) { + await p.keyboard.press('Escape'); + await p.waitForFunction( + () => + !window.__gevContextStore?.selectedEntityId && + window.qaWildSelected.at(-1) === null && + [ + ...document.querySelectorAll('[data-wildlife-sighting-card-host]'), + ].every((host) => host.style.display === 'none'), + { timeout: 10000 }, + ); + await strict.passed( + p, + LAYER, + 'Escape closes the card and clears the selection', + ); + } + + // The keyboard switches reach the picker in the expanded control panel + // (opened with no card up, so the click closes nothing). + await expandPanel(); + + // A pointer click on a badge selects its sighting and opens its card. + const sighting = byId.get(id); + const target = (await drawing()).badges.find((badge) => badge.id === id); + assert.ok( + target.onCanvas && target.picked?.id === `wildlife:${id}`, + `${kind}: the ${id} badge is covered or not picked`, + ); + const box = await p.evaluate(() => { + const r = window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); + return { left: r.left, top: r.top }; + }); + await p.mouse.click(box.left + target.screen.x, box.top + target.screen.y); + await p.waitForFunction( + (id) => + window.__gevContextStore?.selectedEntityId === id && + window.qaWildSelected.at(-1) === id, + { timeout: 15000 }, + id, + ); + await cardFor(sighting.name); + const opened = { selection: await selection(), card: await card() }; + await strict.check( + p, + LAYER, + `a click on the ${sighting.name} badge selects it and opens its card`, + opened.selection.context === id && + opened.selection.layer === id && + opened.card.hosts === 1 && + opened.card.shown && + opened.card.name === sighting.name, + { at: target.screen, ...opened }, + ); + console.log('PASS', kind, 'click', id, JSON.stringify(opened.card.text)); + selected = id; + // The click flies to the sighting; switch once it has landed. + await still(); + } + console.log( + 'requests', + JSON.stringify(fixture.asked), + 'upstream', + JSON.stringify(fixture.upstream), + ); + assert.ok(fixture.asked.observations && fixture.asked.grid); + assert.ok(fixture.asked.acartia); + assert.deepEqual(errors, []); +});