From fc75e245a645d0bd2a810b7e51bfc72f9859a02f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 03:52:50 +0400 Subject: [PATCH] Assert Wildlife Sightings on both engines in the strict acceptance qa-wildlife-switch.mjs serves four Salish Sea sightings (three from iNaturalist, one obscured, and an Acartia orca) through the app's own iNaturalist and Acartia proxies over faked upstream feeds, then on Cesium, Navara, Cesium and Navara again checks the badge set and its positions, picks, the density glow, the privacy cell outline and a clicked card. Two of the switches are made from the keyboard and must keep the selection; the last is made with the pointer on the Globe Engine picker, whose click closes the card like any click outside it. --- scripts/fixtures/wildlife.mjs | 261 ++++++++++++++++ scripts/format-scope.json | 4 +- scripts/qa-all-layer-switches.mjs | 1 + scripts/qa-wildlife-switch.mjs | 502 ++++++++++++++++++++++++++++++ 4 files changed, 767 insertions(+), 1 deletion(-) create mode 100644 scripts/fixtures/wildlife.mjs create mode 100644 scripts/qa-wildlife-switch.mjs 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, []); +});