diff --git a/scripts/qcm/README.md b/scripts/qcm/README.md index f5c223d..9b47e26 100644 --- a/scripts/qcm/README.md +++ b/scripts/qcm/README.md @@ -515,6 +515,7 @@ recorded as unreadable. J10x sends each form on purpose and fails if the guard m | `camera-list-browser.test.mjs` | Journey 10: a heading, count and real list; the view scope counting 350 cameras while the map draws 300 pins and the list says why; purpose filter and its filtered-out state; the near scope with its stated radius and nearest-first rows; J10d/J10d2: the near scope reads unavailable only when its own place's files failed, and a failure under the map elsewhere does not touch it; J10e: a name pin picked after a finder pick moves the near list to the pin; opening a camera from a row requests one picture; a quiet status (the polite announcer speaks a settled count only); J10m: focus stays on the same row or Save button across a re-sort and falls to the list, never the page, when the row is gone; J10n: the rows are a `role=list` of plain buttons and a repaint of the same count says nothing; zoomed-out, outside-coverage, none, unavailable, loading and images-unavailable states. Every camera request is stubbed. | | `camera-find.test.mjs` | Pure: the privacy detector (`helpers/leak-check.mjs`) on every carrier form and on near misses; the search box and file choice and its bound, the search status text, the cluster summary counts and note, saved-row states (listed, gone, failed, pending) and the saved-list status keys; a search reads only files within the radius (not the square's corner); `canonicalCameraRef`. | | `camera-find-browser.test.mjs` | Journey 10 f-i: save from a row and the card, reload, still saved; blocked storage (throwing accessor) shows the saving-unavailable state; Search this area fires one request set and states the radius; below zoom 7 the summary counts match the loaded records; J10j: an open card follows a save or removal made from the list, and its press reads the store; J10k: a camera saved under an old id reads Saved, is not saved twice and is removed whole; J10p: one Search press makes two search announcements and the list says nothing until it has an answer; J10q: a throw after the index read says the search did not finish, clears the pending state and a new press works; J10r/J10s: a failed Saved index load retries once on a later Saved press and on a change in the saved count; J10u/J10v/J10w: the same when the index reads and the camera file fails, and a file that fails again is not read in a loop; J10x: the privacy guard flags a camera sent in a query, path, older ref, tail, name, bare id, or any kind of body; J10t: no recorded request (fetch, XHR, beacon, resource) carries a saved camera (ref, older ref, tail, name or id), and the address, history state, title and share link are unchanged after a save and after a search. | +| `camera-mixed-scene-browser.test.mjs` | QQA-10b: road, park, camera-link, research and airport layers on together at one point (fixture rows on each provider's real header, served by `mixedSceneStub` in `helpers/camera-snapshot-stub.mjs`): each layer draws its own pin class and the count line reads 5 layers on; each Cameras region and layer row reports its own cameras; the list names each row's own source and the link-only rows say so; one pin per provider applies its own picture plan (a blob `` for the fetch plan, a plain `` pointed at the owner for the img plan, no `` and no owner request for link-only) and each card names its own operator. Then one layer's file fails while the others load: its row reads unavailable, the others keep pins and pictures, the list says it is partial, surviving rows and pins still open cards, and cycling the layer recovers it; the road layer failing alone and all five failing (list reads unavailable, never partial) are covered too. Every camera and owner-host request is stubbed. | ## Physical print proof diff --git a/scripts/qcm/camera-mixed-scene-browser.test.mjs b/scripts/qcm/camera-mixed-scene-browser.test.mjs new file mode 100644 index 0000000..b4f3257 --- /dev/null +++ b/scripts/qcm/camera-mixed-scene-browser.test.mjs @@ -0,0 +1,372 @@ +// QQA-10b: a mixed-provider camera scene in one view, with one layer failing. Until now only road and +// park cameras had ever been on together in a browser (camera-discovery-browser.test.mjs J03), and the +// Cameras list test used road cameras alone. Here all five camera layers are on at one public point +// (Salinas, California): road (a Caltrans district file, picture read with fetch into a blob), park (an +// NPS file, picture painted straight into an ), camera links (an HPWREN file, no picture at all), +// PhenoCam research cameras and AviationWX airport cameras (both plain ). +// +// The browser is offline (helpers/browser.mjs launch). The five snapshot files come from the page-side +// stand-in below, built from the committed files' own headers and fixed fixture rows +// (helpers/camera-snapshot-stub.mjs mixedSceneStub), so a test owns the rows it asserts against. Every +// press is a trusted CDP press; the page is read only for assertions. +// +// What it proves: each layer draws its own pin class and the count line says five layers are on; the +// Cameras entry and each layer row report their own cameras; the Cameras list names each row's own +// source; opening one pin per provider applies that provider's own picture plan (a blob for the +// fetch plan, a plain pointed at the owner for the img plan, no and no request to the owner +// for the link-only layer) and each card names its own operator. Then one layer's file fails while the +// others load: that layer's row reads unavailable, the others keep their pins and pictures, the list says +// it is partial, a surviving row still opens its card, and cycling the layer recovers it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { findChromium, launch } from './helpers/browser.mjs'; +import { trustedClick } from './helpers/trusted-click.mjs'; +import { startSiteServer, siteIsBuilt } from './helpers/site-server.mjs'; +import { readyDashboard, waitInPage } from './helpers/waits.mjs'; +import { SOURCES } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-registry.js'; +import { INDEX_PATH, MIXED_POINT, mixedSceneStub } from './helpers/camera-snapshot-stub.mjs'; + +const CHROMIUM = findChromium(); +const skip = !CHROMIUM ? 'no Chromium on this box (set CHROME_PATH to run this proof)' + : !siteIsBuilt('quiet-corners') ? 'public/quiet-corners/ not built -- run `bash scripts/build-site.sh --configDir site-config --minify` first' + : false; + +const STUB = mixedSceneStub(); +const SCENE = STUB.scene; +const BY_LAYER = Object.fromEntries(SCENE.map((s) => [s.layer, s])); +const LAYER_IDS = SCENE.map((s) => s.layer); +const fileTail = (layer) => BY_LAYER[layer].file.split('/').slice(-2).join('/'); +const PIN_CLASS = { roadCameras: 'road', parkCameras: 'park', linkCameras: 'link', phenoCameras: 'research', airportCameras: 'airport' }; +const TINY_JPEG_B64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='; +const CCTV_ORIGIN = SOURCES.caltransCctv.origin; +// Every host a camera picture or owner page could come from; the stand-in logs a request to any of them. +const OWNER_HOSTS = [...new Set(SCENE.flatMap((s) => s.cameras.flatMap((c) => [c.still, c.officialUrl])) + .filter(Boolean).map((u) => new URL(u).origin))]; + +/** window.fetch and Image.src replaced before any page script runs. fetch: the five stub files are + * answered here (a path listed in __ctl.failPaths answers 500), the road picture is a tiny JPEG, any + * other cross-origin request is refused and logged. Image.src: a picture an owner host would serve is + * logged ({ requested, alt }) and shown as a tiny JPEG, so the offline browser still paints it and + * the log records the address the page actually asked for. */ +const standIn = `(() => { + const real = window.fetch.bind(window); + const files = ${JSON.stringify(STUB.files)}; + const jpegB64 = ${JSON.stringify(TINY_JPEG_B64)}; + const jpeg = Uint8Array.from(atob(jpegB64), (c) => c.charCodeAt(0)); + const owners = ${JSON.stringify(OWNER_HOSTS)}; + window.__camera = { pictures: 0 }; + window.__ctl = { failPaths: [] }; + window.__refused = []; + window.__imgs = []; + window.__fileFetches = []; + const json = (text) => new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }); + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input.url; + let parsed = null; + try { parsed = new URL(url, location.href); } catch { /* not a url */ } + const path = parsed ? parsed.pathname : ''; + if (path in files) { + window.__fileFetches.push(path); + if (window.__ctl.failPaths.some((p) => path.endsWith(p))) return Promise.resolve(new Response('nope', { status: 500 })); + return Promise.resolve(json(files[path])); + } + if (url.startsWith(${JSON.stringify(CCTV_ORIGIN)})) { + window.__camera.pictures++; + return Promise.resolve(new Response(jpeg, { status: 200, headers: { 'content-type': 'image/jpeg', 'last-modified': new Date(Date.now() - 180000).toUTCString() } })); + } + // The place, road and water name sources the default-on Names and Roads layers ask for: nothing to + // label in this scene, answered as an empty page and a zero count. + if (/\\/(geonames|transportation|nhd)\\/MapServer\\/\\d+\\/query/.test(url)) return Promise.resolve(json('{"features":[],"count":0}')); + if (parsed && parsed.origin !== location.origin) { window.__refused.push(url.slice(0, 120)); return Promise.reject(new TypeError('refused: not stubbed')); } + return real(input, init); + }; + const desc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'); + Object.defineProperty(HTMLImageElement.prototype, 'src', { + configurable: true, enumerable: desc.enumerable, + get() { return desc.get.call(this); }, + set(value) { + const requested = String(value); + if (owners.some((o) => requested.startsWith(o + '/'))) { + window.__imgs.push({ requested, alt: this.alt }); + desc.set.call(this, 'data:image/jpeg;base64,' + jpegB64); + } else desc.set.call(this, value); + }, + }); + window.__ownerRequests = () => performance.getEntriesByType('resource').filter((e) => owners.some((o) => e.name.startsWith(o + '/'))).length; +})()`; + +const press = (page, selector, opts = {}) => trustedClick(page, selector, { settleMs: 0, ...opts }); +const text = (page, selector) => page.evaluate(`(document.querySelector(${JSON.stringify(selector)}) || {}).textContent || ''`); +const attr = (page, selector, name) => page.evaluate(`(document.querySelector(${JSON.stringify(selector)}) || document.body).getAttribute(${JSON.stringify(name)})`); +const regionSel = (layer) => `#qcm-camera-entry [data-camera-region="${layer}"] .qcm-camera-region-status`; +const pinCount = (page, layer) => page.evaluate(`document.querySelectorAll('.qcm-camera-pin--${PIN_CLASS[layer]}').length`); +const listRows = (page) => page.evaluate(`[...document.querySelectorAll('#qcm-camera-list-rows button.qcm-camera-list-row')].map((b) => ({ + name: b.querySelector('.qcm-camera-row-name').textContent, meta: b.querySelector('.qcm-camera-row-meta').textContent }))`); +const waitListStatus = (page, re, what) => waitInPage(page, what, `${re}.test(document.getElementById('qcm-camera-list-status').textContent)`, 20000, null); +const waitRegion = (page, layer, key, what) => waitInPage(page, what, `document.querySelector(${JSON.stringify(regionSel(layer))}).getAttribute('data-status') === ${JSON.stringify(key)}`, 20000, null); + +async function atScene(page) { + // Wait until the map is idle, then set the view and keep setting it until it holds for two frames. + await page.evaluate(`new Promise((resolve, reject) => { + const m = window.qcMap, raf2 = (f) => requestAnimationFrame(() => requestAnimationFrame(f)); + const t0 = Date.now(), idle = () => !m._animatingZoom && !m._panAnim; + const step = () => { + if (Date.now() - t0 > 10000) return reject(new Error('the map never settled at the scene, zoom 13')); + if (!idle()) return raf2(step); + m.setView(${JSON.stringify([MIXED_POINT.lat, MIXED_POINT.lon])}, 13, { animate: false }); + raf2(() => (idle() && m.getZoom() === 13 ? resolve(0) : step())); + }; + raf2(step); + })`); +} + +async function withScene(fn, { failPaths = [] } = {}) { + const site = await startSiteServer(); + const page = await launch(CHROMIUM, 'about:blank', { args: ['--hide-scrollbars'], viewport: { width: 1280, height: 900 } }); + try { + await page.send('Page.addScriptToEvaluateOnNewDocument', { source: standIn }); + await page.navigate(`${site.origin}/quiet-corners/`); + await readyDashboard(page, { data: false }); + await page.evaluate(`window.__ctl.failPaths = ${JSON.stringify(failPaths)}; void 0;`); + await press(page, '#qcm-layer-panel > summary', { until: "document.getElementById('qcm-layer-panel').open" }); + await press(page, '.qcm-layer-group[aria-label="Cameras"] > summary', { until: "document.querySelector('.qcm-layer-group[aria-label=\"Cameras\"]').open" }); + await atScene(page); + return await fn(page); + } finally { page.close(); await site.close(); } +} + +async function allFiveOn(page) { + for (const layer of LAYER_IDS) await press(page, `#qcm-layer-${layer}`); +} + +// What a card holds once open, read from the page. +const cardState = (page) => page.evaluate(`(() => { + const card = document.querySelector('.qcm-site-card'); + if (!card) return null; + const imgs = [...card.querySelectorAll('img')].map((i) => ({ src: i.src.slice(0, 40), alt: i.alt })); + return { text: card.textContent, imgs, links: [...card.querySelectorAll('a')].map((a) => ({ text: a.textContent, href: a.href })) }; +})()`); + +/** Opens the first pin of a layer with a trusted press and returns the card once it holds `name`. */ +async function openPin(page, layer, camera) { + await press(page, `.qcm-camera-pin--${PIN_CLASS[layer]}[aria-label=${JSON.stringify(camera.name)}]`); + await waitInPage(page, `the card of ${camera.name}`, `(() => { const c = document.querySelector('.qcm-site-card'); return Boolean(c) && c.textContent.includes(${JSON.stringify(camera.name)}); })()`, 15000, null); +} +const closeCard = async (page) => { + await page.evaluate('window.qcMap.closePopup(); void 0;'); + await waitInPage(page, 'the card to close', "!document.querySelector('.qcm-site-card')", 10000, null); +}; + +/** The proof of one provider's own picture plan, run on an open card. `before` is the page's counters + * just before the pin was pressed. */ +async function assertPlan(page, layer, camera, before) { + const s = BY_LAYER[layer]; + const after = await page.evaluate('({ pictures: window.__camera.pictures, imgs: window.__imgs.length, owner: window.__ownerRequests() })'); + const card = await cardState(page); + assert.ok(card.text.includes(camera.name), `${layer}: the card does not name its camera`); + if (s.plan === 'fetch') { + assert.equal(after.pictures, before.pictures + 1, `${layer}: opening the pin should read its picture once`); + assert.equal(after.imgs, before.imgs, `${layer}: the fetch plan must not paint an owner `); + await waitInPage(page, `${layer}'s blob picture`, "(() => { const i = document.querySelector('.qcm-site-card img'); return Boolean(i) && i.src.startsWith('blob:'); })()", 15000, null); + const shown = (await cardState(page)).imgs; + assert.equal(shown.length, 1); + assert.match(shown[0].alt, new RegExp(`^Picture from ${camera.name} \\(${s.altCredit}\\)$`)); + } else if (s.plan === 'img') { + assert.equal(after.pictures, before.pictures, `${layer}: the img plan must not fetch the picture`); + assert.equal(after.imgs, before.imgs + 1, `${layer}: opening the pin should point one at the owner`); + const logged = await page.evaluate('window.__imgs[window.__imgs.length - 1]'); + assert.equal(logged.requested, camera.still, `${layer}: the was not pointed straight at the owner's picture`); + assert.doesNotMatch(logged.requested, /^blob:/); + assert.equal(logged.alt, `View from ${camera.name} (${s.altCredit})`); + await waitInPage(page, `${layer}'s picture`, "Boolean(document.querySelector('.qcm-site-card img'))", 15000, null); + } else { + assert.equal(after.pictures, before.pictures, `${layer}: a link-only pin read a picture`); + assert.equal(after.imgs, before.imgs, `${layer}: a link-only pin pointed an at the owner`); + assert.equal(after.owner, before.owner, `${layer}: the browser made a request to the owner`); + assert.equal(card.imgs.length, 0, `${layer}: a link-only card holds a picture`); + assert.ok(card.links.some((l) => l.text === `View on ${s.operator}`), `${layer}: the owner link reads ${JSON.stringify(card.links)}`); + } + assert.ok(card.text.includes(s.cardOperator), `${layer}: the card does not name ${s.cardOperator}: ${JSON.stringify(card.text)}`); + for (const other of SCENE) { + if (other.layer !== layer) assert.ok(!card.text.includes(other.cardOperator) || other.cardOperator === s.cardOperator, `${layer}: the card names another provider (${other.cardOperator})`); + } +} + +const counters = (page) => page.evaluate('({ pictures: window.__camera.pictures, imgs: window.__imgs.length, owner: window.__ownerRequests() })'); + +test('QQA-10b: five providers on in one view draw their own pins, rows and cards', { skip }, async () => { + await withScene(async (page) => { + // Nothing on yet: no file, no picture, no owner request. + assert.deepEqual(await page.evaluate('window.__fileFetches'), []); + await allFiveOn(page); + for (const layer of LAYER_IDS) { + await waitInPage(page, `${layer} pins`, `document.querySelectorAll('.qcm-camera-pin--${PIN_CLASS[layer]}').length === ${BY_LAYER[layer].cameras.length}`, 30000, null); + } + assert.equal(await text(page, '#qcm-layer-count'), '5 layers on'); + + // Each layer drew exactly its own cameras, under its own class, and no pin wears two. + for (const layer of LAYER_IDS) assert.equal(await pinCount(page, layer), BY_LAYER[layer].cameras.length, `${layer} pins`); + const total = SCENE.reduce((n, s) => n + s.cameras.length, 0); + const shape = await page.evaluate(`(() => { + const classes = ${JSON.stringify(Object.values(PIN_CLASS))}; + const pins = [...document.querySelectorAll('.qcm-camera-pin')]; + return { pins: pins.length, multi: pins.filter((p) => classes.filter((c) => p.classList.contains('qcm-camera-pin--' + c)).length !== 1).length }; + })()`); + assert.deepEqual(shape, { pins: total, multi: 0 }); + // Every pin carries its own camera's name, and the name belongs to the layer whose class it wears. + for (const layer of LAYER_IDS) { + const titles = await page.evaluate(`[...document.querySelectorAll('.qcm-camera-pin--${PIN_CLASS[layer]}')].map((p) => p.getAttribute('aria-label')).sort()`); + assert.deepEqual(titles, BY_LAYER[layer].cameras.map((c) => c.name).sort(), `${layer} pin names`); + } + + // The Cameras entry and each layer row report their own cameras. + for (const layer of LAYER_IDS) { + await waitRegion(page, layer, 'in-view', `${layer}'s region status`); + assert.equal(await text(page, regionSel(layer)), `${BY_LAYER[layer].cameras.length} cameras in this view.`); + assert.match(await text(page, `#qcm-layer-${layer}-report`), new RegExp(`^${BY_LAYER[layer].cameras.length} ${BY_LAYER[layer].reportNoun}s? in this view\\.`), `${layer} row report`); + } + + // The Cameras list: one list, every row naming its own source. + await waitListStatus(page, new RegExp(`^${total} cameras in this view\\.`), 'the list to count every camera'); + assert.equal(await text(page, '#qcm-camera-list-title'), `Cameras in this view (${total})`); + assert.equal(await attr(page, '#qcm-camera-list-status', 'data-status'), 'in-scope'); + const rows = await listRows(page); + assert.equal(rows.length, total); + for (const s of SCENE) { + for (const c of s.cameras) { + const row = rows.find((r) => r.name === c.name); + assert.ok(row, `no list row for ${c.name}`); + assert.ok(row.meta.startsWith(s.cardOperator), `${c.name}: the row names ${JSON.stringify(row.meta)}, not ${s.cardOperator}`); + assert.equal(row.meta.includes('link only'), s.plan === 'link', `${c.name}: "link only" belongs to the link-only layer alone`); + } + } + // The purpose counts follow the layers: road and camera links are roads, the rest one each. + const purpose = await page.evaluate(`Object.fromEntries([...document.querySelectorAll('[data-camera-purpose-count]')].map((e) => [e.getAttribute('data-camera-purpose-count'), e.textContent]))`); + assert.deepEqual(purpose, { roads: `(${BY_LAYER.roadCameras.cameras.length + BY_LAYER.linkCameras.cameras.length})`, + scenery: `(${BY_LAYER.parkCameras.cameras.length})`, weather: `(${BY_LAYER.airportCameras.cameras.length})`, wildlife: `(${BY_LAYER.phenoCameras.cameras.length})` }); + // The two link-only cameras are the ones the list says have no picture. + assert.match(await text(page, '#qcm-camera-list-notes'), new RegExp(`${BY_LAYER.linkCameras.cameras.length} of the ${total} have no picture here`)); + + // No picture and no owner request happened for listing alone. + assert.deepEqual(await counters(page), { pictures: 0, imgs: 0, owner: 0 }); + + // One pin per provider, one after another: each applies its own plan and names its own operator. + for (const layer of LAYER_IDS) { + const camera = BY_LAYER[layer].cameras[0]; + const before = await counters(page); + await openPin(page, layer, camera); + await assertPlan(page, layer, camera, before); + await closeCard(page); + assert.equal(await page.evaluate("document.querySelectorAll('.qcm-site-card img').length"), 0, `${layer}: a picture outlived its card`); + } + // Five opens: the road picture by fetch, three by , and nothing at all for the link-only layer. + const end = await counters(page); + assert.equal(end.pictures, 1, 'only the road camera is read with fetch'); + assert.equal(end.imgs, 3, 'park, research and airport each pointed one at their owner'); + assert.equal(end.owner, 0, 'the stand-in served every owner picture, so the browser asked no owner host'); + assert.deepEqual(await page.evaluate('window.__refused'), [], 'a request reached past the stand-in'); + const fetched = await page.evaluate('window.__fileFetches'); + assert.deepEqual(fetched.filter((p) => p !== INDEX_PATH).sort(), LAYER_IDS.map((l) => BY_LAYER[l].file).sort(), 'each snapshot file is read once'); + assert.equal(fetched.filter((p) => p === INDEX_PATH).length, 1, 'the index is read once'); + }); +}); + +test('QQA-10b: one layer\'s file failing leaves the others\' pins, pictures and rows, and the list says it is partial', { skip }, async () => { + const failing = 'phenoCameras'; + await withScene(async (page) => { + await allFiveOn(page); + const survivors = LAYER_IDS.filter((l) => l !== failing); + for (const layer of survivors) { + await waitInPage(page, `${layer} pins`, `document.querySelectorAll('.qcm-camera-pin--${PIN_CLASS[layer]}').length === ${BY_LAYER[layer].cameras.length}`, 30000, null); + } + await waitRegion(page, failing, 'unavailable', 'the failed layer to read unavailable'); + const alive = survivors.reduce((n, l) => n + BY_LAYER[l].cameras.length, 0); + + // The failed layer: unavailable in the Cameras entry, its own words on its row, no pins. It stays on. + assert.equal(await text(page, '#qcm-layer-count'), '5 layers on'); + assert.match(await text(page, regionSel(failing)), /^Camera list unavailable\. Turn the layer off and on to retry\./); + assert.match(await text(page, `#qcm-layer-${failing}-report`), /PhenoCam camera list did not load/); + assert.equal(await pinCount(page, failing), 0); + // The others keep their pins, their own status and their own row report. + for (const layer of survivors) { + assert.equal(await pinCount(page, layer), BY_LAYER[layer].cameras.length, `${layer} lost pins to another layer's failure`); + assert.equal(await attr(page, regionSel(layer), 'data-status'), 'in-view', `${layer} status`); + assert.equal(await text(page, regionSel(layer)), `${BY_LAYER[layer].cameras.length} cameras in this view.`); + assert.doesNotMatch(await text(page, `#qcm-layer-${layer}-report`), /did not load/, `${layer} row report took on the failure`); + } + + // The list: partial, not unavailable, counting only what loaded, and saying what did not. + await waitListStatus(page, new RegExp(`^${alive} cameras in this view\\. This list is partial\\.`), 'the list to say it is partial'); + assert.equal(await attr(page, '#qcm-camera-list-status', 'data-status'), 'partial'); + assert.equal(await text(page, '#qcm-camera-list-title'), `Cameras in this view (${alive})`); + assert.match(await text(page, '#qcm-camera-list-notes'), /1 area did not load, so cameras may be missing\./); + const rows = await listRows(page); + assert.equal(rows.length, alive); + assert.ok(rows.every((r) => !r.meta.startsWith(BY_LAYER[failing].cardOperator)), 'a row of the failed layer was listed'); + assert.equal(await page.evaluate(`window.__fileFetches.filter((p) => p.endsWith(${JSON.stringify(fileTail(failing))})).length`), 1, 'the failed file is asked for once, not retried behind the reader'); + + // A surviving row still opens its card, with its picture: the road camera, from the list. + const road = BY_LAYER.roadCameras.cameras[0]; + const before = await counters(page); + await press(page, `#qcm-camera-list-rows li[data-ref=${JSON.stringify(road.ref)}] button.qcm-camera-list-row`); + await waitInPage(page, 'the road card', `(document.querySelector('.qcm-site-card') || { textContent: '' }).textContent.includes(${JSON.stringify(road.name)})`, 15000, null); + await assertPlan(page, 'roadCameras', road, before); + await closeCard(page); + // And a pin of another provider still opens too: the airport camera, a plain . + const airport = BY_LAYER.airportCameras.cameras[0]; + const beforeAirport = await counters(page); + await openPin(page, 'airportCameras', airport); + await assertPlan(page, 'airportCameras', airport, beforeAirport); + await closeCard(page); + + // The retry the status promises: the file answers again, the layer is cycled off and on, all of it returns. + await page.evaluate('window.__ctl.failPaths = []; void 0;'); + await press(page, `#qcm-layer-${failing}`); + await waitInPage(page, 'the failed layer to switch off', `!document.getElementById('qcm-layer-${failing}').checked`, 10000, null); + await press(page, `#qcm-layer-${failing}`); + await waitInPage(page, 'the research pins to return', `document.querySelectorAll('.qcm-camera-pin--research').length === ${BY_LAYER[failing].cameras.length}`, 30000, null); + await waitRegion(page, failing, 'in-view', 'the retried layer to read in view'); + const total = alive + BY_LAYER[failing].cameras.length; + await waitListStatus(page, new RegExp(`^${total} cameras in this view\\.(?! This list is partial)`), 'the list to be whole again'); + assert.equal(await attr(page, '#qcm-camera-list-status', 'data-status'), 'in-scope'); + assert.equal(await text(page, '#qcm-layer-count'), '5 layers on'); + assert.deepEqual(await page.evaluate('window.__refused'), []); + }, { failPaths: [fileTail('phenoCameras')] }); +}); + +test('QQA-10b: the road layer failing instead reads unavailable on its own, and the other four stay whole', { skip }, async () => { + const failing = 'roadCameras'; + await withScene(async (page) => { + await allFiveOn(page); + const survivors = LAYER_IDS.filter((l) => l !== failing); + for (const layer of survivors) { + await waitInPage(page, `${layer} pins`, `document.querySelectorAll('.qcm-camera-pin--${PIN_CLASS[layer]}').length === ${BY_LAYER[layer].cameras.length}`, 30000, null); + } + await waitRegion(page, failing, 'unavailable', 'the road layer to read unavailable'); + assert.match(await text(page, `#qcm-layer-${failing}-report`), /Caltrans's camera list did not load/); + assert.equal(await pinCount(page, failing), 0); + for (const layer of survivors) assert.equal(await attr(page, regionSel(layer), 'data-status'), 'in-view', `${layer} status`); + const alive = survivors.reduce((n, l) => n + BY_LAYER[l].cameras.length, 0); + await waitListStatus(page, new RegExp(`^${alive} cameras in this view\\. This list is partial\\.`), 'the list to say it is partial'); + // The link-only layer is the other roads layer: it still lists, and the picture-less note still counts it. + assert.equal(await page.evaluate("document.querySelector('[data-camera-purpose-count=\"roads\"]').textContent"), `(${BY_LAYER.linkCameras.cameras.length})`); + // A surviving provider still opens: the park camera, a plain . + const park = BY_LAYER.parkCameras.cameras[0]; + const before = await counters(page); + await openPin(page, 'parkCameras', park); + await assertPlan(page, 'parkCameras', park, before); + }, { failPaths: [fileTail('roadCameras')] }); +}); + +test('QQA-10b: every layer failing reads unavailable in the list too, never partial', { skip }, async () => { + await withScene(async (page) => { + await allFiveOn(page); + for (const layer of LAYER_IDS) await waitRegion(page, layer, 'unavailable', `${layer} to read unavailable`); + await waitListStatus(page, /camera list unavailable/i, 'the list to read unavailable'); + assert.equal(await attr(page, '#qcm-camera-list-status', 'data-status'), 'unavailable'); + assert.equal((await listRows(page)).length, 0); + assert.equal(await text(page, '#qcm-layer-count'), '5 layers on'); + assert.equal(await page.evaluate("document.querySelectorAll('.qcm-camera-pin').length"), 0); + }, { failPaths: LAYER_IDS.map(fileTail) }); +}); + diff --git a/scripts/qcm/helpers/camera-snapshot-stub.mjs b/scripts/qcm/helpers/camera-snapshot-stub.mjs index 72ac24c..550224b 100644 --- a/scripts/qcm/helpers/camera-snapshot-stub.mjs +++ b/scripts/qcm/helpers/camera-snapshot-stub.mjs @@ -94,6 +94,62 @@ export function fixedFilesStub() { return out; } +// QQA-10b: one public point (Salinas, California) with a camera from every layer that can sit there, +// so a browser test puts road, park, link-only, research and airport cameras in one view. Each file +// keeps its provider's real header (operator, attribution, rights) from the committed seed; the rows +// are fixtures with public picture addresses on the provider's own host, laid on a small grid around +// the point so no two pins cover each other. The index lists only these five, so nothing else loads. +export const MIXED_POINT = Object.freeze({ lat: 36.6777, lon: -121.6555 }); +const MIXED_REGION = { region: 'scene-salinas', regionName: 'Fixture scene (Salinas)' }; +const mixedPos = (slot) => [+(MIXED_POINT.lat + (1 - Math.floor(slot / 3)) * 0.01).toFixed(5), +(MIXED_POINT.lon + ((slot % 3) - 1) * 0.013).toFixed(5)]; +const MIXED_PROVIDERS = [ + { layer: 'roadCameras', seed: 'caltrans-cwwp2/ca-d05.json', dir: 'caltrans-cwwp2', plan: 'fetch', reportNoun: 'road camera', altCredit: 'Caltrans', + names: ['US-101 : Fixture Road One', 'US-101 : Fixture Road Two', 'SR-68 : Fixture Road Three'], + still: (i) => `https://cwwp2.dot.ca.gov/data/d5/cctv/image/fixtureroad${i}/fixtureroad${i}.jpg`, page: () => null }, + { layer: 'parkCameras', seed: 'nps-webcams/nps-nc.json', dir: 'nps-webcams', plan: 'img', reportNoun: 'park camera', altCredit: 'National Park Service', + names: ['Fixture Park Webcam One', 'Fixture Park Webcam Two'], + still: (i) => `https://www.nps.gov/featurecontent/ard/webcams/images/fixturepark${i}.jpg`, page: (i) => `https://www.nps.gov/media/webcam/view.htm?id=FX-${i}` }, + { layer: 'linkCameras', seed: 'hpwren/hpwren-ca.json', dir: 'hpwren', plan: 'link', reportNoun: 'camera', altCredit: null, + names: ['Fixture Link Camera One', 'Fixture Link Camera Two'], + still: () => null, page: (i) => `https://www.hpwren.ucsd.edu/cameras/#mode=realTime&cams=fixture${i}` }, + { layer: 'phenoCameras', seed: 'phenocam/us.json', dir: 'phenocam', plan: 'img', reportNoun: 'research camera', altCredit: 'PhenoCam Network (CC BY)', + names: ['Fixture Research Camera One', 'Fixture Research Camera Two'], + still: (i) => `https://phenocam.nau.edu/data/latest/fixtureresearch${i}.jpg`, page: (i) => `https://phenocam.nau.edu/webcam/sites/fixtureresearch${i}/` }, + { layer: 'airportCameras', seed: 'aviationwx/us.json', dir: 'aviationwx', plan: 'img', reportNoun: 'airport camera', altCredit: 'AviationWX.org', + names: ['Fixture Airport (camera 1)', 'Fixture Airport (camera 2)'], + still: (i) => `https://api.aviationwx.org/v1/airports/fx${i}/webcams/0/image?size=720`, page: (i) => `https://fx${i}.aviationwx.org/` }, +]; + +/** { files: { [path]: json string } (the five files and the index, keyed by url path), scene: [{ layer, + * provider, plan, operator, cardOperator, altCredit, reportNoun, file, cameras: [{ ref, providerId, + * name, lat, lon, still, officialUrl }] }] }. `cardOperator` is the name the camera's own card shows: + * the operator, or for a link-only layer the owner's credit line. */ +export function mixedSceneStub() { + const files = {}; + let slot = 0; + const scene = MIXED_PROVIDERS.map((p) => { + const { fields, rows, schema, ...header } = readSeed(p.seed); + const cameras = p.names.map((name, i) => { + const [lat, lon] = mixedPos(slot++); + const providerId = `fx${i + 1}`; + return { ref: `camera:${header.provider}:${providerId}`, providerId, name, lat, lon, still: p.still(i + 1), officialUrl: p.page(i + 1) || header.officialUrl }; + }); + const path = `${p.dir}/${MIXED_REGION.region}.json`; + files[`${CAMERA_DIR}/${path}`] = JSON.stringify({ + schema: 1, ...header, ...MIXED_REGION, generatedAt: FIXED_AT, fields: FIXED_FIELDS, + rows: cameras.map((c) => [c.providerId, c.name, c.lat, c.lon, c.still, 'active', null, c.officialUrl === header.officialUrl ? null : c.officialUrl]), + }); + const lats = cameras.map((c) => c.lat), lons = cameras.map((c) => c.lon); + return { layer: p.layer, provider: header.provider, plan: p.plan, operator: header.operator, + cardOperator: p.plan === 'link' ? header.operator : header.operator, altCredit: p.altCredit, reportNoun: p.reportNoun, file: `${CAMERA_DIR}/${path}`, cameras, + index: { file: path, provider: header.provider, layer: p.layer, ...MIXED_REGION, + bbox: { minLon: Math.min(...lons), minLat: Math.min(...lats), maxLon: Math.max(...lons), maxLat: Math.max(...lats) }, + count: cameras.length, generatedAt: FIXED_AT } }; + }); + files[INDEX_PATH] = JSON.stringify({ schema: 1, files: scene.map((s) => s.index) }); + return { files, scene }; +} + /** { [path]: json string } for a stub index and ONE schema 2 park file (nps-webcams, region nps-nc) * holding exactly `cameras`, each { providerId, name, lat, lon, still, capturedAt?, validUntil? }. * For a browser test that needs a row with a capture time or an expiry, which the fixed schema 1 diff --git a/scripts/qcm/site-coupling.json b/scripts/qcm/site-coupling.json index 3899b3c..06ccce8 100644 --- a/scripts/qcm/site-coupling.json +++ b/scripts/qcm/site-coupling.json @@ -15,6 +15,7 @@ "scripts/qcm/camera-expiry-browser.test.mjs", "scripts/qcm/camera-find-browser.test.mjs", "scripts/qcm/camera-list-browser.test.mjs", + "scripts/qcm/camera-mixed-scene-browser.test.mjs", "scripts/qcm/camera-picture-states-browser.test.mjs", "scripts/qcm/camera-requests.test.mjs", "scripts/qcm/card-over-panels-browser.test.mjs",