diff --git a/content/quiet-corners-maps.md b/content/quiet-corners-maps.md index bc6667d..fa11840 100644 --- a/content/quiet-corners-maps.md +++ b/content/quiet-corners-maps.md @@ -202,7 +202,7 @@ Proof stage. The engine builds real PDFs; what is outstanding is the physical ve - **AWS Terrain Tiles (Terrarium DEM)** — [Mapzen/Tilezen elevation tiles on the AWS Open Data registry](https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png), the elevation the optional 3D terrain panel is shaped from. Fetched only after you press "3D", never on page load. It is a mixed-source set (3DEP, SRTM, GMTED2010, ETOPO1 and others) with native tiles through level 15, so the Source decision record below says which source answers where. - **State land services** — game lands, state parks, other state-managed areas and county parcel boundaries, one service per state, measured and recorded in `state-sources.json`, the one catalogue the map reads. Boundaries only; no owner or occupancy information is ever requested. - **Finding cameras** — cameras show only where a camera list covers. What is shipped: road cameras (Caltrans in all twelve California districts, and state and city agencies in Kentucky, Texas, Kansas, Oregon, Utah, Minnesota, Indiana, Washington (Spokane, Kirkland and the state ferry terminals), Virginia, South Dakota, Louisiana and Colorado, under one **Road cameras (state DOTs and cities)** switch covering the US states); National Park Service park webcams nationwide (every webcam with a position, one list per state, read by map area); and HPWREN (California) and AlertWest (western states and Hawaii) cameras as links only, with no picture. Open **Layers and map key** and the **Cameras** block is the first thing under the search, ahead of every group, naming the five camera layers, each with its coverage and its status on one line beside its **Go to**. **Go to** beside a layer fits the map to it (road cameras open on California, park cameras at Great Smoky Mountains, camera links on the West, research and airport cameras on the lower 48 states) and opens the Cameras switches; it turns nothing on and requests nothing. Pins appear from zoom 7: zoomed out further, a camera layer reads *Zoom in to see cameras*, says how many cameras sit in the areas the view touches, and loads nothing; a view holding more than 300 cameras draws the 300 nearest its centre and says so; and a view with no cameras in reach says how far the nearest ones are. Each region shows its own status, and the states read apart: *Layer off*, *Loading the camera list*, *Camera list unavailable* (no area the view touches could be read; when only some could not, the report says how many areas did not load and the rest stay drawn), *Outside coverage* (the view never reaches the region), *Zoom in* (the region is in view but too wide to draw), *None in this view* (the region is in view but holds no camera there) and *N cameras in this view*. Searching Layers for camera, webcam, Caltrans, park, Tennessee, HPWREN, AlertWest or California finds the rows. -- **The Cameras list** — under **Cameras** in the Layers panel, one list covers every camera layer that is on. **In this view** lists the cameras the map is showing, nearest its centre first; **Near selected place** lists the cameras within 25 km of the place you last chose (a Search places pick, a name or recreation pin, or a row in Places in this view), else of the centre of the area you drew, nearest first with the distance on each row, and says the radius. Choosing the near scope and a place reads the camera files around that place even when the map is zoomed out; it opens no picture. The list is counted from the camera records loaded for that scope, not from the pins on the map, so a view with more than 300 cameras still counts them all: the list shows the nearest 50 and says how many it holds and that the map draws at most 300 pins. Each row is a button naming the camera and its source; pressing it opens the same card its pin opens, and only then is its picture requested. The **Purpose** switches filter by camera layer, and each layer is filed under exactly one purpose: **Roads** holds Road cameras (state DOTs and cities) and Camera links (HPWREN, AlertWest, state roads), whose links are mostly state road cameras (Delaware, Maryland, Missouri, New Jersey, New Mexico) though HPWREN and AlertWest fire-lookout cameras sit there too; **Scenery & parks** holds the National Park Service webcams; **Weather & aviation** holds the AviationWX airport cameras; **Wildlife & seasons** holds the PhenoCam research cameras, which also hold the volcano and Great Lakes cameras. The providers stay under Sources. The list says which of these it is, in plain words: no camera layer is on, no place chosen, zoomed out too far to list, loading, list unavailable, outside coverage, no list read yet, no cameras, all hidden by the purpose filter, partial (some areas did not load), or no picture available. The near scope reports on the camera files around its own place, so a district that failed under the map elsewhere does not make it unavailable or partial, and a place whose files all failed reads list unavailable rather than not read yet. It also says when cameras without a map position are left out. The count is a polite live announcement that is written only when it changes. A third scope, Saved, lists the cameras you have saved from a row or from a camera card. Saves are kept only in this browser, under the same saved-places store as saved places, keyed by the camera's stable id and source; nothing about them goes in the page address, the share link, the title or any analytics. A saved camera that is no longer in the current snapshot stays in the list and says so instead of vanishing. A camera saved under an older id counts as the same camera, so its row and card read Saved and removing it removes it whole, and a camera card that is open follows a save or removal made from the list, so its button always says what is stored. When the browser blocks site storage the list says plainly that saving is unavailable and that nothing saved would survive a reload. Search this area is a button, pressed on request only: it loads the camera files whose area comes within 100 km of the map's centre (a true radius, so a file in the corner of the square around the centre is left out; at most 20 files, nearest first) and states that radius and bound beside the button; if you press it again the latest search wins. Zoomed out below zoom 7, where pins are not drawn, the list shows a cluster summary instead: how many cameras are loaded in the view and how many per purpose, counted from the loaded records and not from drawn pins, with a note that only loaded cameras are counted and that Search this area loads more. +- **The Cameras list** — under **Cameras** in the Layers panel, one list covers every camera layer that is on. **In this view** lists the cameras the map is showing, nearest its centre first; **Near selected place** lists the cameras within 25 km of the place you last chose (a Search places pick, a name or recreation pin, or a row in Places in this view), else of the centre of the area you drew, nearest first with the distance on each row, and says the radius. Choosing the near scope and a place reads the camera files around that place even when the map is zoomed out; it opens no picture. The list is counted from the camera records loaded for that scope, not from the pins on the map, so a view with more than 300 cameras still counts them all: the list shows the nearest 50 and says how many it holds and that the map draws at most 300 pins. Each row is a button naming the camera and its source; pressing it opens the same card its pin opens, and only then is its picture requested. The **Purpose** switches filter by camera layer, and each layer is filed under exactly one purpose: **Roads** holds Road cameras (state DOTs and cities) and Camera links (HPWREN, AlertWest, state roads), whose links are mostly state road cameras (Delaware, Maryland, Missouri, New Jersey, New Mexico) though HPWREN and AlertWest fire-lookout cameras sit there too; **Scenery & parks** holds the National Park Service webcams; **Weather & aviation** holds the AviationWX airport cameras; **Wildlife & seasons** holds the PhenoCam research cameras, which also hold the volcano and Great Lakes cameras. The providers stay under Sources. The list says which of these it is, in plain words: no camera layer is on, no place chosen, zoomed out too far to list, loading, list unavailable, outside coverage, no list read yet, no cameras, all hidden by the purpose filter, partial (some areas did not load), or no picture available. The near scope reports on the camera files around its own place, so a district that failed under the map elsewhere does not make it unavailable or partial, and a place whose files all failed reads list unavailable rather than not read yet. It also says when cameras without a map position are left out. The rows are exposed as a list, and the count is a polite live announcement that is written only when it changes, not while a search is running; after the list redraws, keyboard focus stays on the same camera's row, or goes to the list itself if that camera has left it. In the near scope, a long list tells you to narrow by purpose, since zooming does not change its radius. A third scope, Saved, lists the cameras you have saved from a row or from a camera card. Saves are kept only in this browser, under the same saved-places store as saved places, keyed by the camera's stable id and source; nothing about them goes in the page address, the share link, the title or any analytics. A saved camera that is no longer in the current snapshot stays in the list and says so instead of vanishing. A camera saved under an older id counts as the same camera, so its row and card read Saved and removing it removes it whole, and a camera card that is open follows a save or removal made from the list, so its button always says what is stored. When the browser blocks site storage the list says plainly that saving is unavailable and that nothing saved would survive a reload. Search this area is a button, pressed on request only: it loads the camera files whose area comes within 100 km of the map's centre (a true radius, so a file in the corner of the square around the centre is left out; at most 20 files, nearest first) and states that radius and bound beside the button; if you press it again the latest search wins, and a search that cannot finish says so and offers the button again. If checking your saved cameras fails because the camera list or one of its files did not load, pressing Saved again, or saving or removing a camera, tries once more. Zoomed out below zoom 7, where pins are not drawn, the list shows a cluster summary instead: how many cameras are loaded in the view and how many per purpose, counted from the loaded records and not from drawn pins, with a note that only loaded cameras are counted and that Search this area loads more. - **Caltrans CWWP2 road cameras** — [CCTV status feeds, one per district (District 5's is linked)](https://cwwp2.dot.ca.gov/data/d5/cctv/cctvStatusD05.json), an optional screen-only surface. The camera list is a snapshot of all twelve districts this site builds ahead of time, so turning the layer on asks only this site, one small file per area the map shows, never Caltrans itself; each camera's card says its health is as of the last list check. It is never printed and never part of a PDF. A picture loads only when you open a camera, and its age is read from the image file's own `Last-Modified` header — when Caltrans's server last changed the file, never a guaranteed capture time — because the feed publishes no per-image capture time at all. A record Caltrans marks out of service is still shown, never hidden, and links to the picture and to Caltrans's own documentation page rather than fetching anything. A camera's card can be saved with **Save camera**: the saved place holds the name, the operator and the position only, never the picture or stream address, and **Show** on it switches this layer on and opens the card without a second request for the list. - **NPS park cameras (nationwide)** — [park webcam images](https://www.nps.gov/featurecontent/ard/webcams/images/), an optional screen-only surface. It is never printed and never part of a PDF. The camera list is a snapshot this site builds ahead of time, not a live request to the National Park Service, so opening the layer asks only this site; opening a camera then loads that camera's current picture from the Park Service's own host. The Park Service publishes no per-image capture time today, so a picture's age is not shown, only when the camera list was last checked; if a camera list ever carries a capture time for a camera, its card says how long ago the picture was taken, and past thirty minutes that the camera may be stuck. A camera whose list entry has passed its stated expiry shows no picture and no request is made for one: its card says the picture is out of date, which is not the same as the Park Service publishing none, and still links the owner's page. A camera the Park Service marks out of service is still shown, never hidden. The build snapshots every National Park Service webcam that has a position into a per-state list, and the map reads the lists by map area like the Caltrans ones. A source checkout without an NPS key ships only North Carolina's list (Great Smoky Mountains); the deployed site has every state's. Saved park cameras behave as Caltrans ones do. A camera that publishes no position is never drawn, so it has no card to save from; a saved camera that somehow carries no position (an imported list, say) cannot be shown on the map or checked for conditions, and its row says so. - **State and city road cameras (ArcGIS layers)** — seven operators that publish their cameras as a public ArcGIS REST layer: the [Kentucky Transportation Cabinet's](https://services2.arcgis.com/CcI36Pduqd0OR4W9/arcgis/rest/services/trafficCamerasCur_Prd/FeatureServer/0), the [City of Arlington, Texas's](https://services.arcgis.com/jXi5GuMZwfCYtZP9/arcgis/rest/services/Traffic_Camera_Updates_view/FeatureServer/0), the [City of Lawrence, Kansas's](https://services.arcgis.com/8O9UlSTnqjKptoda/arcgis/rest/services/Traffic_Cameras/FeatureServer/4), the [City of Salem, Oregon's](https://services.arcgis.com/kIA6yS9KDGqZL7U3/arcgis/rest/services/TrafficCams/FeatureServer/0), [Oregon DOT's TripCheck](https://services.arcgis.com/uUvqNMGPm7axC2dD/arcgis/rest/services/TripCheck_Cameras/FeatureServer/0), [Utah DOT's UDOT Traffic](https://services.arcgis.com/pA2nEVnB6tquxgOW/arcgis/rest/services/CCTV/FeatureServer/0) and the [City of Kirkland's](https://www.kirklandwa.gov/Home/My-Neighborhood/Traffic-Cameras). One generic reader serves all of them, asking each layer for its named fields only, one page at a time with a pause between pages, as a plain request with this site's own user agent and nothing else; a source that answers only to a borrowed origin, a scraped token or a schema probe is left out, and so is a layer with no picture address, no position or only stale pictures. A camera is kept only when its operator marks it in service, it has a position inside the state's own box (not padded; TripCheck also drops a camera whose name puts it in a neighbouring state, such as "I-5 Washington - Vancouver at Mill Plain" or "Westport - WA"), and its picture is an https address on the one host recorded for that operator (a second host, a plain http address or a doubled slash in the path drops the row). Each picture is checked when the list is built: most answered as an image to a plain request and were current by their `Last-Modified` header (the test used, because a layer's own timestamp field can be months old while its pictures are minutes old), but a few per layer were missing, failing or months old, including some stand-in images, so a picture that answers 404, 410 or a server error, was last modified over seven days before the seed list was last refreshed, or has the recorded size of an operator's stand-in logo (UDOT's re-dated one), is left out. Those checks send one request per picture, so they run only when the seed lists are refreshed by hand; a site build leaves these lists as committed. A picture can still go stale between builds. Washington State Ferries terminal cameras come from WSDOT's own ferry-map camera list ([Cameras.ashx](https://wsdot.com/ferries/vesselwatch/Cameras.ashx), a plain request with this site's user agent); only the terminal pictures under `/wsf/` are kept, so nothing overlaps a road-camera feed, each picture is checked before it is saved when the seeds are refreshed, and the cards link the [WSF vessel watch](https://www.wsdot.wa.gov/ferries/vesselwatch/default.aspx). WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) has no copying ban, but WSDOT's [web privacy notice](https://wsdot.wa.gov/about/policies/web-privacy-notice) (Section H) says "we recommend that you contact us for permission to use any content contained on this site", a recommendation and not a ban, so the usage terms stay unverified and WSDOT is credited with a link to its own page. Not used: Lexington, Kentucky (its [terms of use](https://www.lexingtonky.gov/terms-use) say users may not modify, reuse, copy or distribute city website content without the express written permission of the City/LFUCG), King County, Washington (its [terms of use](https://kingcounty.gov/en/shared-topics/about-king-county/website/terms-of-use) say "You may not publish, display, distribute or commercially exploit any of the the website or app or the content therein without the prior written permission of King County"), Iowa DOT (its picture addresses are dated per-snapshot paths that go stale once saved) and Louisiana DOTD (video streams and 511 page links only). Each card links the operator's public camera map. The recorded picture hosts are `https://www.trimarc.org/images/milestone/`, `https://webapps.arlingtontx.gov/webcams/`, `https://assets.lawrenceks.gov/gis/traffic/`, `https://egov.cityofsalem.net/traffic-cameras/cameras/`, `https://tripcheck.com/RoadCams/cams/`, `https://udottraffic.utah.gov/1_devices/`, `https://its.kirklandwa.gov/CamView/`, and for the ferry terminals `https://images.wsdot.wa.gov/wsf/` (one exact host each, never a wildcard). Credit is the operator's name, the usage terms are unverified, and no picture age is shown. Like the National Park Service list, these lists are snapshots this site builds ahead of time (one file per state), and the map reads them through the **Road cameras (state DOTs and cities)** switch, area by area like the Caltrans and NPS lists. Opening one of these cameras shows a link-only card (name, operator and a link to the operator's camera page) and makes no request: the map does not load these pictures today. diff --git a/scripts/qcm/README.md b/scripts/qcm/README.md index 257768a..f5c223d 100644 --- a/scripts/qcm/README.md +++ b/scripts/qcm/README.md @@ -485,12 +485,36 @@ removal drops every stored ref that shares it. `main.js` tracks the open camera and repaints its Save button when a row or the saved-places panel changes the store; the card's press reads the store, not its own label. Camera snapshot schema 1 and the five layer ids are unchanged. +QQA-16c polishes how the list behaves for a keyboard and screen-reader user. **Focus**: when a repaint +replaces the rows (`paintListRows`) while focus is on a row, it returns to the same camera's row, or its +Save button if that is where it was (rows carry `data-ref`); if that camera is gone it falls to the list +(`#qcm-camera-list-rows`, `role="list"`, tabindex -1), never to the page. **Announcements**: the status +line `#qcm-camera-list-status` is plain text; a separate visually hidden polite announcer +`#qcm-camera-list-announce` speaks `listAnnouncement(status, { searchPending })`, which is null for a +Loading line and while a search is pending, so a settled count is read once and only when its words change, +and one Search press is two announcements (the search status's Searching and its result) plus the list's +settled text. **Near wording**: the near scope's "Listing the nearest N of M" note says to narrow by +purpose, not to zoom in. **Search failure**: a throw after the index read ends the search as "the search +did not finish. Press the button to try again." (`searchStatus` `searchFailed`), never as Searching. +**Saved retry**: after the Saved scope's index load fails, or the index reads and a camera file that holds a +saved camera fails, pressing Saved again, or a change in the saved count, retries once +(`retrySavedFailures` clears `savedIndexFailed` and drops those files from `cameraFileFailed`; +`savedFailedCount` is the count the failure was measured against, so a retry that fails again waits for the +next press or count change); the failed note says so. Nothing in the request path changes: no request +carries a camera, and the address, history, title and share link are the same after a save or a search +(J10t). The privacy guard is `helpers/leak-check.mjs` (`findLeak`): the camera's ref, the older ref, the +`d05:` tail and its name, raw, encoded or `+`-encoded, in a URL or body, plus the bare id as a query +value or body token (a path part is left out because tile coordinates are numbers too). The page-side +recorder in `camera-find-browser.test.mjs` reads fetch (string, URL or Request, with the Request's own +body), XHR and beacon bodies as text, FormData, URLSearchParams, Blob and bytes; a ReadableStream body is +recorded as unreadable. J10x sends each form on purpose and fails if the guard misses one. + | Proof | What it holds | | --- | --- | | `camera-list.test.mjs` | The purpose map (five layer ids, each under one purpose), distance and reach box, the view and near scopes, the filter and per-purpose counts, unlocated cameras, every status key, the 300-pin note appearing only past 300, the latest-pick place order and the near/view load states. | -| `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 live status; zoomed-out, outside-coverage, none, unavailable, loading and images-unavailable states. Every camera request is stubbed. | -| `camera-find.test.mjs` | Pure: 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. | +| `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. | ## Physical print proof diff --git a/scripts/qcm/camera-find-browser.test.mjs b/scripts/qcm/camera-find-browser.test.mjs index 81ac230..cd543d5 100644 --- a/scripts/qcm/camera-find-browser.test.mjs +++ b/scripts/qcm/camera-find-browser.test.mjs @@ -16,6 +16,7 @@ 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 { findLeak } from './helpers/leak-check.mjs'; import { d05Stub, INDEX_PATH, D05_PATH, CAMERA_DIR } from './helpers/camera-snapshot-stub.mjs'; const CHROMIUM = findChromium(); @@ -68,16 +69,45 @@ function fixture() { const standIn = (f) => `(() => { const real = window.fetch.bind(window); window.__paths = []; - window.__ctl = { mode: 'ok', release: null }; + // QQA-16c: every fetch, XHR and beacon the page makes (url and body), so a test can prove what was never sent. + window.__reqs = []; + let idx = 'ok'; try { idx = sessionStorage.getItem('qcm-test-index-mode') || 'ok'; } catch { /* storage blocked */ } + let fmode = 'ok'; try { fmode = sessionStorage.getItem('qcm-test-file-mode') || 'ok'; } catch { /* storage blocked */ } + window.__ctl = { mode: 'ok', indexMode: idx, fileMode: fmode, release: null }; + // A body is read in whatever form it was sent: text, form fields, bytes, a Blob, a Request's own body. + // Async reads fill the entry in a moment; a test waits before it reads window.__reqs. + const bodyText = (b) => { + if (b == null) return ''; + if (typeof b === 'string') return b; + if (b instanceof FormData) return Promise.all([...b.entries()].map(async ([k, v]) => k + '=' + (typeof v === 'string' ? v : v.name + ':' + await v.text()))).then((p) => p.join('&')); + if (b instanceof Blob) return b.text(); + if (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) return new TextDecoder().decode(b); + if (b instanceof ReadableStream) return '[stream body: not readable here]'; + return String(b); + }; + const note = (u, b) => { + const entry = { url: String(u), body: '' }; + window.__reqs.push(entry); + Promise.resolve(b).then(bodyText).then((t) => { entry.body = t; }, () => { entry.body = '[body not readable]'; }); + }; + const xOpen = XMLHttpRequest.prototype.open, xSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (m, u, ...r) { this.__u = u; return xOpen.call(this, m, u, ...r); }; + XMLHttpRequest.prototype.send = function (b) { note(this.__u, b); return xSend.call(this, b); }; + if (navigator.sendBeacon) { const beacon = navigator.sendBeacon.bind(navigator); navigator.sendBeacon = (u, d) => { note(u, d); return beacon(u, d); }; } const json = (text) => new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }); const answers = { ${JSON.stringify(INDEX_PATH)}: ${JSON.stringify(f.indexJson)}, ${JSON.stringify(NPS_PATH)}: ${JSON.stringify(f.npsJson)}, ${JSON.stringify(LINK_PATH)}: ${JSON.stringify(f.linkJson)} }; window.fetch = (input, init) => { - const url = typeof input === 'string' ? input : input.url; + const isRequest = typeof Request !== 'undefined' && input instanceof Request; + const url = isRequest ? input.url : String(input); + note(url, init && init.body != null ? init.body : isRequest && !input.bodyUsed ? input.clone().blob() : null); let path = ''; try { path = new URL(url, location.href).pathname; } catch { /* not a url */ } if (path.startsWith(${JSON.stringify(CAMERA_DIR)})) window.__paths.push(path); + if (path.startsWith('/__leak-probe')) return Promise.resolve(new Response('', { status: 204 })); // J10x's controls + if (path === ${JSON.stringify(INDEX_PATH)} && window.__ctl.indexMode === 'fail') return Promise.resolve(new Response('nope', { status: 500 })); if (answers[path]) return Promise.resolve(json(answers[path])); if (path === ${JSON.stringify(D05_PATH)}) { + if (window.__ctl.fileMode === 'fail') return Promise.resolve(new Response('nope', { status: 500 })); if (window.__ctl.mode === 'hold') return new Promise((resolve) => { window.__ctl.release = () => resolve(json(${JSON.stringify(f.d05Json)})); }); return Promise.resolve(json(${JSON.stringify(f.d05Json)})); } @@ -335,3 +365,248 @@ test('J10k: a camera saved under an old id reads Saved on its row and card, is n assert.deepEqual(await storedRefs(page), [current]); }); }); + +// QQA-16c: what a reader hears, how a search ends when something throws, how the Saved scope retries, +// and what is never sent. +const indexReads = (page) => page.evaluate('window.__paths.filter((p) => p.endsWith("index.json")).length'); +const d05Reads = (page) => page.evaluate(`window.__paths.filter((p) => p === ${JSON.stringify(D05_PATH)}).length`); + +test('J10p: one Search press makes two announcements, the search\'s own, and the list stays quiet until it has an answer', { skip }, async () => { + await withPage(async (page) => { + await atView(page, SALINAS, 5); + for (const id of ['roadCameras', 'parkCameras', 'linkCameras']) await press(page, `#qcm-layer-${id}`); + await waitStatus(page, /zoom in to list cameras/i, 'the zoom-in state before any search'); + // Every write into a live region of the list, by region (the text added, not read later). + await page.evaluate(`window.__spoken = []; new MutationObserver((m) => { for (const r of m) { + const node = r.target.nodeType === 3 ? r.target.parentElement : r.target; + const region = node && node.closest && node.closest('[aria-live]'); + if (!region || r.type === 'attributes') continue; + const said = r.type === 'characterData' ? r.target.data : (r.addedNodes[0] || {}).textContent; + if (said) window.__spoken.push({ region: region.id === 'qcm-camera-search-status' ? 'search' : 'list', said }); + } }).observe(document.getElementById('qcm-camera-list'), { childList: true, characterData: true, subtree: true }); void 0;`); + await page.evaluate("window.__ctl.mode = 'hold'"); + await press(page, '#qcm-camera-search'); + await waitText(page, '#qcm-camera-search-status', /^Searching within 100 km/, 'the search to be pending'); + await page.evaluate('new Promise((r) => setTimeout(r, 400))'); + assert.deepEqual(await page.evaluate("window.__spoken.filter((s) => s.region === 'list')"), [], 'the list spoke while a search was pending'); + await waitInPage(page, 'the held request', 'typeof window.__ctl.release === "function"', 10000, null); + await page.evaluate('window.__ctl.release(), 0'); + await waitText(page, '#qcm-camera-search-status', /^Searched within 100 km .*: 3 of 3 camera files read\.$/, 'the search to finish'); + await waitStatus(page, /^\d+ cameras loaded in this view at this zoom:/, 'the cluster summary'); + await page.evaluate('new Promise((r) => setTimeout(r, 400))'); + const spoken = await page.evaluate('window.__spoken'); + assert.equal(spoken.filter((s) => s.region === 'search').length, 2, `the search spoke ${JSON.stringify(spoken)}`); + const list = spoken.filter((s) => s.region === 'list'); + assert.equal(list.length, 1, `the list spoke ${JSON.stringify(list)}`); + assert.match(list[0].said, /^\d+ cameras loaded in this view at this zoom:/); + assert.ok(!spoken.some((s) => /Loading the camera list/.test(s.said)), 'a Loading line was read out for a search'); + }); +}); + +test('J10q: a failure after the index read ends the search honestly, clears the pending state, and a new press works', { skip }, async () => { + await withPage(async (page) => { + await atView(page, SALINAS, 5); + await press(page, '#qcm-layer-roadCameras'); + await waitStatus(page, /zoom in to list cameras/i, 'the zoom-in state'); + // The first Promise.allSettled the page makes after this (the search's file reads) throws once. + await page.evaluate(`(() => { const real = Promise.allSettled; Promise.allSettled = function (...a) { Promise.allSettled = real; throw new Error('forced failure'); }; })(); void 0;`); + await press(page, '#qcm-camera-search'); + const where = await centreText(page); + await waitText(page, '#qcm-camera-search-status', new RegExp(`^Searched within 100 km of ${escaped(where)}: the search did not finish\\. Press the button to try again\\.$`), 'the search to say it failed'); + assert.doesNotMatch(await text(page, '#qcm-camera-search-status'), /^Searching/); + await waitStatus(page, /zoom in to list cameras/i, 'the list to leave Loading'); + assert.doesNotMatch(await text(page, '#qcm-camera-list-status'), /Loading the camera list/); + assert.equal(await page.evaluate("document.getElementById('qcm-camera-search').disabled"), false); + // Pressing again searches properly. + await press(page, '#qcm-camera-search'); + await waitText(page, '#qcm-camera-search-status', new RegExp(`^Searched within 100 km of ${escaped(where)}: 1 of 1 camera files read\\.$`), 'the second search to finish'); + }); +}); + +/** One saved camera from the list, plus a second the operator's list does not hold, then a reload with the + * camera index failing, or (fail 'file') the index fine and the District 5 file failing (sessionStorage + * carries the mode across the reload to the fetch stand-in). */ +async function reloadWithIndexFailing(page, site, { second, fail = 'index' }) { + await atView(page, SALINAS, 9); + await press(page, '#qcm-layer-roadCameras'); + await waitStatus(page, /^40 cameras in this view\./, 'the 40 road cameras'); + await press(page, FIRST_ROW_SAVE); + await waitInPage(page, 'the save to land', `localStorage.getItem(${JSON.stringify(SAVED_KEY)}) !== null`, 10000, null); + if (second) { + await page.evaluate(`(() => { const e = JSON.parse(localStorage.getItem(${JSON.stringify(SAVED_KEY)})); + e.places.push({ ref: 'camera:caltrans-cwwp2:d05:99999', name: 'Gone Cam', kind: 'camera', lat: 36.6, lon: -121.7, state: null, county: null, savedAt: '2026-10-01T00:00:00.000Z' }); + localStorage.setItem(${JSON.stringify(SAVED_KEY)}, JSON.stringify(e)); })(); void 0;`); + } + await page.evaluate(`sessionStorage.setItem(${JSON.stringify(fail === 'file' ? 'qcm-test-file-mode' : 'qcm-test-index-mode')}, 'fail'); void 0;`); + await page.navigate(`${site.origin}/quiet-corners/`); + await readyDashboard(page, { data: false }); + await openCameras(page); + await press(page, '#qcm-camera-scope-saved'); + await waitText(page, '#qcm-camera-list-notes', /could not be checked because the camera list did not load/, 'the failed check to be said'); + assert.equal(await indexReads(page), 1, 'the failed index is not asked for again by itself'); + if (fail === 'file') assert.equal(await d05Reads(page), 1, 'the failed file is not asked for again by itself'); +} + +test('J10r: after the Saved scope\'s index load fails, pressing Saved again retries once', { skip }, async () => { + await withPage(async (page, { site }) => { + await reloadWithIndexFailing(page, site, { second: true }); + assert.match(await text(page, '#qcm-camera-list-notes'), /Press Saved again to try again\.$/); + await page.evaluate("window.__ctl.indexMode = 'ok'; void 0;"); + await press(page, '#qcm-camera-scope-saved'); // already the chosen scope: no change event, a press + await waitStatus(page, /^2 saved cameras, stored only in this browser\./, 'the saved count after the retry'); + await waitText(page, '#qcm-camera-list-notes', /^1 is no longer in the camera list/, 'the checked result'); + assert.equal(await indexReads(page), 2, 'one retry, not a loop'); + }); +}); + +test('J10s: after the Saved scope\'s index load fails, a change in the saved count retries once', { skip }, async () => { + await withPage(async (page, { site }) => { + await reloadWithIndexFailing(page, site, { second: true }); + await page.evaluate("window.__ctl.indexMode = 'ok'; sessionStorage.removeItem('qcm-test-index-mode'); void 0;"); + // Removing the second saved camera changes the count from 2 to 1. + await press(page, '#qcm-camera-list-rows li:last-child button.qcm-camera-save'); + await waitStatus(page, /^1 saved camera, stored only in this browser\./, 'one saved camera'); + await waitInPage(page, 'the remaining camera to be found', "document.querySelectorAll('#qcm-camera-list-rows button.qcm-camera-list-row').length === 1", 15000, null); + assert.equal(await indexReads(page), 2, 'one retry, not a loop'); + }); +}); + +test('J10u: after a camera FILE fails with the index read fine, pressing Saved again reads the file once more', { skip }, async () => { + await withPage(async (page, { site }) => { + await reloadWithIndexFailing(page, site, { second: true, fail: 'file' }); + assert.match(await text(page, '#qcm-camera-list-notes'), /Press Saved again to try again\.$/); + await page.evaluate("window.__ctl.fileMode = 'ok'; sessionStorage.removeItem('qcm-test-file-mode'); void 0;"); + await press(page, '#qcm-camera-scope-saved'); // already the chosen scope: no change event, a press + await waitStatus(page, /^2 saved cameras, stored only in this browser\./, 'the saved count after the retry'); + await waitText(page, '#qcm-camera-list-notes', /^1 is no longer in the camera list/, 'the checked result'); + assert.equal(await d05Reads(page), 2, 'one retry of the file, not a loop'); + assert.equal(await indexReads(page), 1, 'the index was fine and is not read again'); + assert.equal(await page.evaluate("document.querySelectorAll('#qcm-camera-list-rows button.qcm-camera-list-row').length"), 1); + }); +}); + +test('J10v: after a camera FILE fails with the index read fine, a change in the saved count reads the file once more', { skip }, async () => { + await withPage(async (page, { site }) => { + await reloadWithIndexFailing(page, site, { second: true, fail: 'file' }); + await page.evaluate("window.__ctl.fileMode = 'ok'; sessionStorage.removeItem('qcm-test-file-mode'); void 0;"); + await press(page, '#qcm-camera-list-rows li:last-child button.qcm-camera-save'); // 2 saved becomes 1 + await waitStatus(page, /^1 saved camera, stored only in this browser\./, 'one saved camera'); + await waitInPage(page, 'the remaining camera to be found', "document.querySelectorAll('#qcm-camera-list-rows button.qcm-camera-list-row').length === 1", 15000, null); + assert.equal(await d05Reads(page), 2, 'one retry of the file, not a loop'); + }); +}); + +test('J10w: a camera FILE that fails again on the retry is not read in a loop, and a later press tries once more', { skip }, async () => { + await withPage(async (page, { site }) => { + await reloadWithIndexFailing(page, site, { second: true, fail: 'file' }); + await press(page, '#qcm-camera-scope-saved'); // the file still fails + await page.evaluate('new Promise((r) => setTimeout(r, 600))'); + assert.equal(await d05Reads(page), 2, 'a press retries once'); + assert.match(await text(page, '#qcm-camera-list-notes'), /Press Saved again to try again\.$/); + await press(page, '#qcm-camera-scope-saved'); + await page.evaluate('new Promise((r) => setTimeout(r, 600))'); + assert.equal(await d05Reads(page), 3, 'a later press retries once more'); + }); +}); + +test('J10x: the privacy guard sees a camera leave in any form: query, path, older ref, tail, name, id, and every kind of body', { skip }, async () => { + await withPage(async (page) => { + await atView(page, SALINAS, 9); + await press(page, '#qcm-layer-roadCameras'); + await waitStatus(page, /^40 cameras in this view\./, 'the 40 road cameras'); + await press(page, FIRST_ROW_SAVE); + await waitInPage(page, 'the save to land', `localStorage.getItem(${JSON.stringify(SAVED_KEY)}) !== null`, 10000, null); + const place = JSON.parse(await page.evaluate(`localStorage.getItem(${JSON.stringify(SAVED_KEY)})`)).places[0]; + const camera = { ref: place.ref, name: place.name }; + await page.evaluate('new Promise((r) => setTimeout(r, 400))'); + assert.equal(findLeak(await page.evaluate('window.__reqs'), camera), null, 'the page leaked the camera before any control ran'); + const id = place.ref.split(':').pop(); + const legacy = place.ref.replace(':d05:', ':'); + const tail = place.ref.split(':').slice(2).join(':'); + // Each control sends one request the page itself never would, in one form; the guard must flag it. + const forms = { + 'the ref in a query': `fetch('/__leak-probe?c=' + ${JSON.stringify(place.ref)})`, + 'the encoded ref in a query': `fetch('/__leak-probe?c=' + encodeURIComponent(${JSON.stringify(place.ref)}))`, + 'the older ref': `fetch('/__leak-probe?c=' + ${JSON.stringify(legacy)})`, + 'the d05 tail in a path': `fetch('/__leak-probe/' + ${JSON.stringify(tail)})`, + 'the bare id as a query value': `fetch('/__leak-probe?id=' + ${JSON.stringify(id)})`, + 'the name in a query': `fetch('/__leak-probe?n=' + encodeURIComponent(${JSON.stringify(place.name)}))`, + 'a URL object': `fetch(new URL('/__leak-probe?c=' + ${JSON.stringify(place.ref)}, location.href))`, + 'a string body': `fetch('/__leak-probe', { method: 'POST', body: ${JSON.stringify(place.ref)} })`, + 'a Request object with a body': `fetch(new Request('/__leak-probe', { method: 'POST', body: ${JSON.stringify(place.ref)} }))`, + 'a FormData body': `fetch('/__leak-probe', { method: 'POST', body: (() => { const f = new FormData(); f.append('camera', ${JSON.stringify(place.ref)}); return f; })() })`, + 'a URLSearchParams body': `fetch('/__leak-probe', { method: 'POST', body: new URLSearchParams({ camera: ${JSON.stringify(place.ref)} }) })`, + 'a Blob body': `fetch('/__leak-probe', { method: 'POST', body: new Blob([${JSON.stringify(place.ref)}]) })`, + 'a typed-array body': `fetch('/__leak-probe', { method: 'POST', body: new TextEncoder().encode(${JSON.stringify(place.ref)}) })`, + 'an XHR body': `(() => { const x = new XMLHttpRequest(); x.open('POST', '/__leak-probe'); x.send(${JSON.stringify(place.ref)}); })()`, + 'a beacon': `navigator.sendBeacon('/__leak-probe', ${JSON.stringify(place.ref)})`, + }; + const missed = []; + for (const [what, code] of Object.entries(forms)) { + const from = await page.evaluate('window.__reqs.length'); + await page.evaluate(`(async () => { try { await (${code}); } catch { /* the probe path may 404 */ } await new Promise((r) => setTimeout(r, 120)); })()`); + const mine = (await page.evaluate('window.__reqs')).slice(from); + if (!mine.length || !findLeak(mine, camera)) missed.push(`${what} (${mine.length} recorded)`); + } + assert.deepEqual(missed, [], 'the guard missed a leak'); + }); +}); + +test('J10t: saving and searching send nothing that carries the camera, and leave the address, history, title and share link alone', { skip }, async () => { + await withPage(async (page) => { + await page.evaluate(`window.__shared = []; Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: (t) => { window.__shared.push(t); return Promise.resolve(); } } }); void 0;`); + const shareLink = async () => { + const n = await page.evaluate('window.__shared.length'); + await page.evaluate("document.getElementById('qc-view-share').click(); void 0;"); + await waitInPage(page, 'the share link', `window.__shared.length > ${n}`, 10000, null); + return page.evaluate('window.__shared[window.__shared.length - 1]'); + }; + const state = () => page.evaluate('({ hash: location.hash, historyState: JSON.stringify(history.state), title: document.title, href: location.href })'); + await atView(page, SALINAS, 9); + await press(page, '#qcm-layer-roadCameras'); + await waitStatus(page, /^40 cameras in this view\./, 'the 40 road cameras'); + await page.evaluate('new Promise((r) => setTimeout(r, 400))'); // the map writes its own view into the address, then settles + let before = await state(); + let linkBefore = await shareLink(); + assert.ok(!before.hash.includes('camera') && !linkBefore.includes('camera')); + // Save a camera. + await press(page, FIRST_ROW_SAVE); + await waitInPage(page, 'the row to read Saved', "/Saved/.test(document.querySelector('#qcm-camera-list-rows button.qcm-camera-save').textContent)", 10000, null); + const [ref] = await storedRefs(page); + assert.match(ref, /^camera:caltrans-cwwp2:d05:\d+$/); + const name = JSON.parse(await page.evaluate(`localStorage.getItem(${JSON.stringify(SAVED_KEY)})`)).places[0].name; + const sent = async () => { + const reqs = await page.evaluate('window.__reqs'); + const resources = await page.evaluate("performance.getEntriesByType('resource').map((e) => ({ url: e.name, body: '' }))"); + return [...reqs, ...resources]; + }; + const assertNoCamera = async (when) => { + const all = await sent(); + assert.ok(all.length > 0, `${when}: nothing was recorded, so the check proves nothing`); + const leak = findLeak(all, { ref, name }); + assert.equal(leak, null, `${when}: a request carries the camera (${leak && leak.carrier}): ${leak && leak.req.url.slice(0, 160)} ${leak && leak.req.body.slice(0, 80)}`); + }; + const assertUnchanged = async (when) => { + assert.deepEqual(await state(), before, `${when}: the address, history state or title changed`); + assert.equal(await shareLink(), linkBefore, `${when}: the share link changed`); + }; + await page.evaluate('new Promise((r) => setTimeout(r, 400))'); + await assertNoCamera('after the save'); + await assertUnchanged('after the save'); + // Search the area from a broad view, so it reads the other layers' files. The view and layers are set + // first and measured, so only the search itself can move any of it. + await atView(page, SALINAS, 5); + for (const id of ['parkCameras', 'linkCameras']) await press(page, `#qcm-layer-${id}`); + await waitStatus(page, /zoom in to list cameras/i, 'the zoom-in state'); + await page.evaluate('new Promise((r) => setTimeout(r, 400))'); + before = await state(); + linkBefore = await shareLink(); + await press(page, '#qcm-camera-search'); + await waitText(page, '#qcm-camera-search-status', /: 3 of 3 camera files read\.$/, 'the search to read three files'); + await waitStatus(page, /^\d+ cameras loaded in this view at this zoom:/, 'the cluster summary'); + await page.evaluate('new Promise((r) => setTimeout(r, 400))'); + assert.ok((await sent()).some((r) => r.url.includes('nps-test.json')), 'the search should have asked for the park file'); + await assertNoCamera('after the search'); + await assertUnchanged('after the search'); + }); +}); diff --git a/scripts/qcm/camera-find.test.mjs b/scripts/qcm/camera-find.test.mjs index f87dc80..6eb90fe 100644 --- a/scripts/qcm/camera-find.test.mjs +++ b/scripts/qcm/camera-find.test.mjs @@ -7,6 +7,7 @@ import { CAMERA_PURPOSES, MIN_PIN_ZOOM, SEARCH_RADIUS_KM, SEARCH_MAX_FILES, SEARCH_HINT, reachBox, searchBox, searchFiles, searchStatus, filesHolding, canonicalCameraRef, savedCameraRows, savedRowText, savedListStatus, cameraListStatus, } from '../../themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js'; +import { cameraCarriers, findLeak } from './helpers/leak-check.mjs'; const VIEW = { minLon: -122, minLat: 36, maxLon: -121, maxLat: 37 }; const file = (name, layer, bbox) => ({ file: name, layer, provider: 'caltrans-cwwp2', bbox }); @@ -84,6 +85,10 @@ test('the search status states the radius, the centre and what happened', () => assert.match(capped, /nearest 20 of 31 files/); assert.match(capped, /search closer in/i); assert.match(searchStatus({ ...at, indexFailed: true }), /camera index did not load/i); + // QQA-16c: any other failure after the index read is said too, with the retry, never left as Searching. + const broke = searchStatus({ ...at, searchFailed: true }); + assert.match(broke, /^Searched within 100 km of 36\.50, -121\.50: the search did not finish\. Press the button to try again\.$/); + assert.doesNotMatch(broke, /Searching/); assert.equal(searchStatus({ ...at, idle: true }), ''); }); @@ -173,5 +178,49 @@ test('the Saved status: saving unavailable, nothing saved, and a count with what assert.match(s({ listed: 1, gone: 2 }).notes.join(' '), /2 are no longer in the camera list/); assert.match(s({ listed: 2, gone: 1 }).notes.join(' '), /^1 is no longer in the camera list\./); assert.match(s({ listed: 2, failed: 1 }).notes.join(' '), /1 could not be checked because the camera list did not load/); + assert.match(s({ listed: 2, failed: 1 }).notes.join(' '), /Press Saved again to try again\.$/, 'a failed check says how to retry'); assert.equal(s({ listed: 2, pending: 1 }).key, 'loading'); }); + +// QQA-16c: the privacy guard's detector, proven against each way a camera could leak. +const SAVED = { ref: 'camera:caltrans-cwwp2:d05:17', name: 'US-101 : Spot 3' }; +const leaks = (url, body = '') => findLeak([{ url, body }], SAVED); + +test('the privacy detector catches the ref in every form a request could carry it', () => { + for (const [what, url, body] of [ + ['the ref in a query', 'https://x.test/a?c=camera:caltrans-cwwp2:d05:17'], + ['the encoded ref in a query', `https://x.test/a?c=${encodeURIComponent(SAVED.ref)}`], + ['the ref in a path', '/a/camera:caltrans-cwwp2:d05:17/b'], + ['the older ref (no district)', 'https://x.test/a?c=camera:caltrans-cwwp2:17'], + ['the encoded older ref', `https://x.test/a?c=${encodeURIComponent('camera:caltrans-cwwp2:17')}`], + ['the d05 tail in a path', '/a/d05:17'], + ['the encoded d05 tail in a query', '/a?x=d05%3A17'], + ['the name with %20', `/a?n=${encodeURIComponent(SAVED.name)}`], + ['the name with +', '/a?n=US-101+%3A+Spot+3'], + ['the bare id as a query value', '/a?id=17'], + ['the ref in a body', '/a', 'camera:caltrans-cwwp2:d05:17'], + ['the encoded ref in a body', '/a', `c=${encodeURIComponent(SAVED.ref)}`], + ['the name in a body', '/a', 'US-101 : Spot 3'], + ['the bare id in a JSON body', '/a', '{"id":17}'], + ]) assert.ok(leaks(url, body), `${what} should be a leak`); + assert.equal(leaks('/a?c=camera:caltrans-cwwp2:d05:17').carrier, 'camera:caltrans-cwwp2:d05:17'); +}); + +test('the privacy detector does not flag requests that only look similar', () => { + for (const [what, url, body] of [ + ['the camera file', '/data/quiet-corners-maps/cameras/caltrans-cwwp2/ca-d05.json'], + ['the index', '/data/quiet-corners-maps/cameras/index.json'], + ['a tile with the id as a coordinate', 'https://tile.test/9/17/50.png'], + ['an image named for the id', 'https://cwwp2.dot.ca.gov/data/d5/cctv/image/c17/c17.jpg'], + ['another camera in the same district', '/a?c=camera:caltrans-cwwp2:d05:170'], + ['an id that only contains the digits', '/a?id=117'], + ['an empty body', '/a', ''], + ['a body with the digits inside a word', '/a', 'x117y'], + ]) assert.equal(leaks(url, body), null, `${what} should be clean`); +}); + +test('the privacy detector names every carrier form and drops duplicates', () => { + const carriers = cameraCarriers(SAVED); + for (const c of ['camera:caltrans-cwwp2:d05:17', 'camera:caltrans-cwwp2:17', 'd05:17', 'US-101 : Spot 3', 'US-101%20%3A%20Spot%203', 'US-101+%3A+Spot+3']) assert.ok(carriers.includes(c), c); + assert.equal(new Set(carriers).size, carriers.length); +}); diff --git a/scripts/qcm/camera-list-browser.test.mjs b/scripts/qcm/camera-list-browser.test.mjs index 0cbe9c8..75b1145 100644 --- a/scripts/qcm/camera-list-browser.test.mjs +++ b/scripts/qcm/camera-list-browser.test.mjs @@ -116,9 +116,11 @@ test('J10: one Cameras list, scoped to the view and to a chosen place, filtered const title = document.getElementById('qcm-camera-list-title'); const rows = document.getElementById('qcm-camera-list-rows'); const status = document.getElementById('qcm-camera-list-status'); + const announce = document.getElementById('qcm-camera-list-announce'); return { inGroup: !!host && group.contains(host), heading: title && title.tagName, titleText: title && title.textContent, - list: rows && rows.tagName, labelled: rows && rows.getAttribute('aria-labelledby'), live: status && status.getAttribute('aria-live'), - role: status && status.getAttribute('role'), scopes: [...host.querySelectorAll('input[name="qcm-camera-scope"]')].map((r) => r.value), + list: rows && rows.tagName, labelled: rows && rows.getAttribute('aria-labelledby'), rowsRole: rows && rows.getAttribute('role'), + statusLive: status && status.getAttribute('aria-live'), statusRole: status && status.getAttribute('role'), + live: announce && announce.getAttribute('aria-live'), role: announce && announce.getAttribute('role'), scopes: [...host.querySelectorAll('input[name="qcm-camera-scope"]')].map((r) => r.value), purposes: [...host.querySelectorAll('input[data-camera-purpose]')].map((r) => r.getAttribute('data-camera-purpose')), radius: host.textContent }; })()`); @@ -127,8 +129,13 @@ test('J10: one Cameras list, scoped to the view and to a chosen place, filtered assert.equal(shape.titleText, 'Cameras in this view'); assert.equal(shape.list, 'UL'); assert.equal(shape.labelled, 'qcm-camera-list-title'); + // QQA-16c: the live region is the list's own announcer, which speaks a count only when it changes; the + // status line it mirrors is plain text (2026-10-08: these two assertions moved from the status line to the announcer). + assert.equal(shape.rowsRole, 'list', 'the rows are not exposed as a list'); assert.equal(shape.live, 'polite'); assert.equal(shape.role, 'status'); + assert.equal(shape.statusLive, null, 'the status line is still a live region, so every repaint of it is read out'); + assert.equal(shape.statusRole, null); assert.deepEqual(shape.scopes, ['view', 'near', 'saved']); // 2026-10-08 QQA-16b adds the Saved scope; the two it had are unchanged assert.deepEqual(shape.purposes, ['roads', 'scenery', 'weather', 'wildlife']); assert.match(shape.radius, /within 25 km/, 'the radius is not stated in the UI'); @@ -338,3 +345,69 @@ test('J10e: a name pin picked after a finder pick moves the near list to the pin assert.deepEqual(await page.evaluate('window.__refused'), [], 'a request reached past the stand-in'); } finally { page.close(); await site.close(); } }); + +// QQA-16c: a keyboard user's place in the list survives a repaint. +const rowRefs = (page) => page.evaluate("[...document.querySelectorAll('#qcm-camera-list-rows button.qcm-camera-save')].map((b) => b.dataset.ref)"); +const focusIn = (page) => page.evaluate(`(() => { const a = document.activeElement; const li = a && a.closest && a.closest('#qcm-camera-list-rows li'); + const save = li && li.querySelector('button.qcm-camera-save'); + return { ref: save ? save.dataset.ref : null, cls: a && a.className, id: a && a.id, tag: a && a.tagName }; })()`); + +test('J10m: after a repaint the focus stays on the same camera row, and falls to the list when the row is gone, never to the page', { skip }, async () => { + await withPage(GRID.slice(0, 40), async (page) => { + await goToRoad(page); + await press(page, '#qcm-layer-roadCameras'); + await waitStatus(page, /^40 cameras in this view\./, 'the list to count 40 cameras'); + // Focus the third row's button; a pan re-sorts the rows (nearest the new centre first). + const before = await rowRefs(page); + const target = await page.evaluate("(() => { const b = document.querySelectorAll('#qcm-camera-list-rows button.qcm-camera-list-row')[2]; b.focus(); return b.closest('li').querySelector('button.qcm-camera-save').dataset.ref; })()"); + await page.evaluate("(() => { const c = window.qcMap.getCenter(); window.qcMap.setView([c.lat + 0.06, c.lng], 9, { animate: false }); })(); void 0;"); + await waitInPage(page, 'the rows to re-sort', `(() => { const r = [...document.querySelectorAll('#qcm-camera-list-rows button.qcm-camera-save')].map((b) => b.dataset.ref); return r.length === ${before.length} && r.join() !== ${JSON.stringify(before.join())}; })()`, 10000, null); + const sorted = await rowRefs(page); + assert.ok(sorted.includes(target), 'the fixture should keep the focused camera in view'); + const kept = await focusIn(page); + assert.equal(kept.ref, target, `focus left its row (now on ${JSON.stringify(kept)})`); + assert.match(kept.cls, /qcm-camera-list-row/, 'focus should stay on the row button, not move to another control'); + // The same holds on a row's Save button. + await page.evaluate(`document.querySelector('#qcm-camera-list-rows button.qcm-camera-save[data-ref=${JSON.stringify(target)}]').focus(); void 0;`); + await page.evaluate("(() => { const c = window.qcMap.getCenter(); window.qcMap.setView([c.lat - 0.12, c.lng], 9, { animate: false }); })(); void 0;"); + await waitInPage(page, 'the rows to re-sort again', `[...document.querySelectorAll('#qcm-camera-list-rows button.qcm-camera-save')].map((b) => b.dataset.ref).join() !== ${JSON.stringify(sorted.join())}`, 10000, null); + const saveKept = await focusIn(page); + assert.equal(saveKept.ref, target, `focus left its Save button (now on ${JSON.stringify(saveKept)})`); + assert.match(saveKept.cls, /qcm-camera-save/); + // The row goes (the map moves to a place with no cameras): focus goes to the list, not to the page. + await page.evaluate('window.qcMap.setView([35.0, -121.9], 9, { animate: false }); void 0;'); + await waitStatus(page, /^No cameras in this view\./, 'the none state'); + const gone = await focusIn(page); + assert.equal(gone.id, 'qcm-camera-list-rows', `focus fell to ${JSON.stringify(gone)}`); + assert.equal(await rowCount(page), 0); + }); +}); + +test('J10n: rows read as a list while each row stays a plain button, and a repaint of the same count says nothing', { skip }, async () => { + await withPage(GRID.slice(0, 40), async (page) => { + await goToRoad(page); + await press(page, '#qcm-layer-roadCameras'); + await waitStatus(page, /^40 cameras in this view\./, 'the list to count 40 cameras'); + const shape = await page.evaluate(`(() => { const ul = document.getElementById('qcm-camera-list-rows'); + const li = ul.querySelector('li'); const b = li.querySelector('button.qcm-camera-list-row'); + return { role: ul.getAttribute('role'), items: ul.querySelectorAll(':scope > li').length, liRole: li.getAttribute('role'), + buttonRole: b.getAttribute('role'), buttonTag: b.tagName, saveRole: li.querySelector('button.qcm-camera-save').getAttribute('role') }; })()`); + assert.deepEqual(shape, { role: 'list', items: 40, liRole: null, buttonRole: null, buttonTag: 'BUTTON', saveRole: null }); + // The announcer holds the count; a filter flipped off and on in one tick, and a nudge, speak nothing. + assert.equal(await text(page, '#qcm-camera-list-announce'), '40 cameras in this view.'); + await page.evaluate(`window.__spoken = []; new MutationObserver((m) => { for (const r of m) { if (r.type === 'characterData') window.__spoken.push(r.target.data); else if (r.addedNodes.length) window.__spoken.push(r.addedNodes[0].textContent); } }) + .observe(document.getElementById('qcm-camera-list-announce'), { childList: true, characterData: true, subtree: true }); void 0;`); + await page.evaluate(`(() => { const c = window.qcMap.getCenter(); window.qcMap.setView([c.lat + 0.0005, c.lng], 9, { animate: false }); })(); void 0;`); + await page.evaluate(`(() => { const b = document.querySelector('input[data-camera-purpose="roads"]'); b.click(); b.click(); })(); void 0;`); + await page.evaluate('new Promise((r) => setTimeout(r, 1200))'); + assert.deepEqual(await page.evaluate('window.__spoken'), [], 'the count was announced again though it did not change'); + // A count that does change is announced, once. + await press(page, 'input[data-camera-purpose="roads"]'); + await waitStatus(page, /40 cameras in this view are hidden by the purpose filter/, 'the filtered-out state'); + await press(page, 'input[data-camera-purpose="roads"]'); + await waitStatus(page, /^40 cameras in this view\./, 'the list to return'); + await page.evaluate('new Promise((r) => setTimeout(r, 300))'); + assert.deepEqual(await page.evaluate('window.__spoken'), + ['40 cameras in this view are hidden by the purpose filter. Turn a purpose back on to list them.', '40 cameras in this view.']); + }); +}); diff --git a/scripts/qcm/camera-list.test.mjs b/scripts/qcm/camera-list.test.mjs index 2bdde4d..4177e84 100644 --- a/scripts/qcm/camera-list.test.mjs +++ b/scripts/qcm/camera-list.test.mjs @@ -156,3 +156,24 @@ test('the load state of a scope comes from the files that scope reaches, not fro assert.equal(view({ isFailed: set('a.json') }).nothingLoaded, true, 'view: none loaded yet and the flag not set reads nothing-loaded as before'); assert.equal(view({ reaching: [] }).nothingLoaded, false); }); + +test('QQA-16c: the near scope never tells a reader to zoom in; the view scope still may', () => { + const s = (o) => cameraListStatus({ scope: 'view', onLayers: 1, belowZoom: false, hasPlace: true, placeName: 'Salinas', touches: true, + loading: false, unavailable: false, failedAreas: 0, nothingLoaded: false, inScope: 80, matching: 80, listed: LIST_LIMIT, noPicture: 0, unlocated: 0, ...o }); + const near = s({ scope: 'near' }).notes.join(' '); + assert.match(near, new RegExp(`Listing the nearest ${LIST_LIMIT} of 80`)); + assert.match(near, /narrow by purpose to see the rest/); + assert.doesNotMatch(near, /zoom/i, 'zoom is not the remedy for a near list: its radius is fixed'); + assert.match(s({}).notes.join(' '), /zoom in or narrow by purpose to see the rest/); +}); + +test('QQA-16c: the list speaks its count only when it settles, and never while a search is pending', () => { + const settled = cameraListStatus({ scope: 'view', onLayers: 1, inScope: 12, matching: 12, listed: 12 }); + assert.equal(cameraLayer.listAnnouncement(settled), '12 cameras in this view.'); + assert.equal(cameraLayer.listAnnouncement(settled, { searchPending: true }), null, 'a pending search is the only voice'); + const loading = cameraListStatus({ scope: 'view', onLayers: 1, loading: true }); + assert.equal(loading.key, 'loading'); + assert.equal(cameraLayer.listAnnouncement(loading), null, 'a passing Loading line is not read out between two equal counts'); + const off = cameraListStatus({ onLayers: 0 }); + assert.equal(cameraLayer.listAnnouncement(off), off.text, 'a state that is not a count still speaks'); +}); diff --git a/scripts/qcm/helpers/leak-check.mjs b/scripts/qcm/helpers/leak-check.mjs new file mode 100644 index 0000000..44000d8 --- /dev/null +++ b/scripts/qcm/helpers/leak-check.mjs @@ -0,0 +1,38 @@ +// QQA-16c: the privacy guard's detector. Given the requests a page recorded ({ url, body }) and the camera +// that was saved, it says whether any request carries that camera. Pure, so camera-find.test.mjs proves it +// catches each way a camera could leak, and camera-find-browser.test.mjs proves the page-side recorder feeds it. + +/** The strings that name a saved camera: its ref, the ref an older visit stored, the `d05:` tail, its name. */ +export function cameraCarriers({ ref, name }) { + const parts = ref.split(':'); // camera:::, or the older camera:: + const id = parts[parts.length - 1]; + const raw = [ref, `${parts[0]}:${parts[1]}:${id}`, parts.slice(2).join(':'), name].filter(Boolean); + return [...new Set(raw.flatMap((c) => [c, encodeURIComponent(c), encodeURIComponent(c).replace(/%20/g, '+')]))]; +} + +/** `text` holds `carrier` as a whole token: not the front of a longer id (d05:17 is not d05:170). */ +const holds = (text, carrier) => { + for (let at = text.indexOf(carrier); at !== -1; at = text.indexOf(carrier, at + 1)) if (!/\w/.test(text[at + carrier.length] || '')) return true; + return false; +}; + +const decoded = (text) => { try { return decodeURIComponent(text.replace(/\+/g, ' ')); } catch { return text; } }; + +/** The first recorded request that carries the camera, as { req, carrier }, or null. */ +export function findLeak(reqs, camera) { + const carriers = cameraCarriers(camera); + const id = camera.ref.split(':').pop(); + for (const req of reqs) { + const url = String(req.url), body = String(req.body ?? ''); + for (const text of [url, decoded(url), body, decoded(body)]) { + const carrier = carriers.find((c) => holds(text, c)); + if (carrier) return { req, carrier }; + } + // The bare id is only a carrier where it stands alone: a query or hash value, or a token in a body. + // (As a path part it would also match tile coordinates.) + let values = []; + try { const u = new URL(url, 'http://probe.invalid/'); values = [...u.searchParams.values(), ...new URLSearchParams(u.hash.replace(/^#/, '')).values()]; } catch { /* not a url */ } + if (values.includes(id) || new RegExp(`(^|[^\\w])${id}($|[^\\w])`).test(body)) return { req, carrier: id }; + } + return null; +} diff --git a/themes/headlessmode/static/css/quiet-corners-maps.css b/themes/headlessmode/static/css/quiet-corners-maps.css index cdd4ac4..d2908ff 100644 --- a/themes/headlessmode/static/css/quiet-corners-maps.css +++ b/themes/headlessmode/static/css/quiet-corners-maps.css @@ -199,6 +199,9 @@ .qcm-camera-list-scopes label, .qcm-camera-list-purposes label { display: inline-block; margin: 0 0.7rem 0 0; } .qcm-camera-purpose-count { color: #a3a3a3; font-size: 0.72rem; } .qcm-camera-list-status { margin: 0 0 0.3rem; color: #d4d4d8; } +/* QQA-16c: the list's polite announcer. It speaks a settled count once, apart from the status line (which is + rewritten on every state, Loading included), and takes no room: same hiding as .qcm-preset-preview. */ +.qcm-camera-list-announce { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; clip-path: inset(50%); overflow: hidden; white-space: nowrap; } .qcm-camera-list-status[data-status="outside-coverage"], .qcm-camera-list-status[data-status="filtered-out"], .qcm-camera-list-status[data-status="partial"], .qcm-camera-list-status[data-status="saving-unavailable"] { color: #fde68a; } .qcm-camera-list-status[data-status="unavailable"] { color: #fca5a5; } .qcm-camera-list-rows { margin: 0 0 0.3rem; padding: 0; list-style: none; max-height: 14rem; overflow-y: auto; } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js b/themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js index 5b86d0e..4448850 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/camera-layer.js @@ -633,6 +633,14 @@ function clusterStatus({ counts, inScope, failedAreas, unlocated }) { return { key: 'cluster', text: `${plural(inScope, 'camera')} loaded in this view at this zoom: ${parts.join(', ')}. Zoom in to list cameras (they are listed from zoom ${MIN_PIN_ZOOM}).`, notes }; } +/** What the list's polite announcer should say for `status`, or null to leave it as it was. QQA-16c: the + * count is spoken when the answer settles, never for a passing Loading line (which would read the same + * count again once it returns) and never while a search is pending (the search's own status is the one voice). */ +export function listAnnouncement(status, { searchPending = false } = {}) { + if (searchPending || status.key === 'loading') return null; + return status.text; +} + /** The one state the list is in, as { key, text, notes }. Keys read apart: layers-off, no-place, zoom-in, * cluster (broad zoom, loaded cameras counted per purpose), loading, unavailable, outside-coverage, nothing-loaded, none, filtered-out, partial, images-unavailable, * in-scope. `notes` are the extra sentences a listed result owes the reader (a failed area, the list's @@ -667,7 +675,8 @@ export function cameraListStatus({ scope = 'view', onLayers = 0, belowZoom = fal if (matching === 0) { return state('filtered-out', `${inScope} ${camerasWord(inScope)} ${phrase} ${inScope === 1 ? 'is' : 'are'} hidden by the purpose filter. Turn a purpose back on to list ${inScope === 1 ? 'it' : 'them'}.`, notes); } - if (listed < matching) notes.push(`Listing the nearest ${listed} of ${matching}; zoom in or narrow by purpose to see the rest.`); + // QQA-16c: the near scope's radius is fixed, so zooming is no remedy there. + if (listed < matching) notes.push(`Listing the nearest ${listed} of ${matching}; ${near ? 'narrow' : 'zoom in or narrow'} by purpose to see the rest.`); if (inScope > MAX_PINS) notes.push(`The map draws at most ${MAX_PINS} pins at a time; this list counts every loaded camera ${phrase}, so it can hold more than the map shows.`); const allBare = noPicture === matching; if (noPicture > 0 && !allBare) notes.push(`${noPicture} of the ${matching} have no picture here and open the owner's page or say why.`); @@ -713,11 +722,12 @@ export function searchFiles({ files, box, layers = [], centre, max = SEARCH_MAX_ } /** The search's one status sentence, always naming its radius and centre. `pending` while it runs. */ -export function searchStatus({ radiusKm = SEARCH_RADIUS_KM, centre, pending = false, indexFailed = false, idle = false, total = 0, shown = 0, read = 0, failed = 0 } = {}) { +export function searchStatus({ radiusKm = SEARCH_RADIUS_KM, centre, pending = false, indexFailed = false, searchFailed = false, idle = false, total = 0, shown = 0, read = 0, failed = 0 } = {}) { if (idle) return ''; const where = `within ${radiusKm} km of ${centre.lat.toFixed(2)}, ${centre.lon.toFixed(2)}`; if (pending) return `Searching ${where}.`; if (indexFailed) return `Searched ${where}: the camera index did not load. Press the button to try again.`; + if (searchFailed) return `Searched ${where}: the search did not finish. Press the button to try again.`; if (total === 0) return `Searched ${where}: no camera list covers that area.`; let text = `Searched ${where}: ${read} of ${shown} camera files read.`; if (failed > 0) text += ` ${failed} did not load.`; @@ -769,7 +779,7 @@ export function savedListStatus({ readState = 'ok', readMessage = '', total = 0, if (pending > 0) return state('loading', `Checking ${plural(total, 'saved camera')} against the camera list.`); const notes = []; if (gone > 0) notes.push(`${gone} ${gone === 1 ? 'is' : 'are'} no longer in the camera list. ${gone === 1 ? 'It stays' : 'They stay'} saved until you remove ${gone === 1 ? 'it' : 'them'}.`); - if (failed > 0) notes.push(`${failed} could not be checked because the camera list did not load.`); + if (failed > 0) notes.push(`${failed} could not be checked because the camera list did not load. Press Saved again to try again.`); return state('saved', `${plural(total, 'saved camera')}, stored only in this browser.`, notes); } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/main.js b/themes/headlessmode/static/js/quiet-corners-maps/main.js index 0ff3fdb..8861878 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/main.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/main.js @@ -74,7 +74,7 @@ import { mountSavedPlaces, statusTextFor, CAMERA_NO_LOCATION } from './saved-pla // layerReport aliased: app-state.js already exports its own layerReport (the printable-vector // summary line); this is camera-layer.js's own, for the roadCameras report paragraph only. import { cameraList, cameraListStatus, cameraRowText, listPlace, listLoadState, reachBox, CAMERA_PURPOSES, NEAR_RADIUS_KM, - SEARCH_RADIUS_KM, SEARCH_MAX_FILES, SEARCH_HINT, SAVING_UNAVAILABLE, searchBox, searchFiles, searchStatus, filesHolding, savedCameraRows, savedRowText, savedListStatus, + SEARCH_RADIUS_KM, SEARCH_MAX_FILES, SEARCH_HINT, SAVING_UNAVAILABLE, searchBox, searchFiles, searchStatus, listAnnouncement, filesHolding, savedCameraRows, savedRowText, savedListStatus, camerasInView, layerReport as cameraLayerReport, cardLines as cameraCardLines, ageLine as cameraAgeLine, stillPlan as cameraStillPlan, PICTURE_TIMEOUT_MS, cameraRef, canonicalCameraRef, findCamera, viewTouchesCoverage, viewTouchesLayer, cameraStatus, MIN_PIN_ZOOM, padView, filesInView, failureProvider, pinsToDraw, unloadedFilesNear, nearestCoverage, providerRow as cameraProviderRow, aboutLinkText as cameraAboutLinkText } from './camera-layer.js'; import { expandSnapshot } from './camera-snapshot.js'; @@ -1784,7 +1784,7 @@ function renderCameraEntry() { const CAMERA_LIST_DELAY_MS = 150; let cameraListHost = null, cameraListTimer = 0, cameraListRowsKey = '', cameraListScope = 'view', cameraNearPending = false; // QQA-16b: the latest "Search this area" ({ centre, pending | result }), and the Saved scope's reads. -let cameraSearch = null, cameraSearchToken = 0, savedLoading = false, savedIndexFailed = false; +let cameraSearch = null, cameraSearchToken = 0, savedLoading = false, savedIndexFailed = false, savedFailedCount = 0; const cameraListPurposes = new Set(CAMERA_PURPOSES.map((p) => p.id)); function buildCameraList() { @@ -1807,6 +1807,8 @@ function buildCameraList() { input.type = 'radio'; input.name = 'qcm-camera-scope'; input.id = `qcm-camera-scope-${value}`; input.value = value; input.checked = cameraListScope === value; input.addEventListener('change', () => { cameraListScope = value; savedIndexFailed = false; scheduleCameraList(); }); + // QQA-16c: a later press of Saved, when it is already the chosen scope (no change event), retries what failed once. + if (value === 'saved') input.addEventListener('click', () => { retrySavedFailures(savedCameraPlaces(savedStore.read())); scheduleCameraList(); }); label.append(input, ` ${text}`); scopes.append(label); } @@ -1840,14 +1842,19 @@ function buildCameraList() { } const saving = document.createElement('p'); saving.id = 'qcm-camera-list-saving'; saving.className = 'qcm-hint'; saving.hidden = true; + // QQA-16c: the status line is plain text (it is rewritten on every state, Loading included); the polite + // announcer beside it speaks only a settled answer, and only when that answer's words change. const status = document.createElement('p'); status.id = 'qcm-camera-list-status'; status.className = 'qcm-camera-list-status'; - status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); + const announce = document.createElement('p'); + announce.id = 'qcm-camera-list-announce'; announce.className = 'qcm-camera-list-announce'; + announce.setAttribute('role', 'status'); announce.setAttribute('aria-live', 'polite'); const rows = document.createElement('ul'); rows.id = 'qcm-camera-list-rows'; rows.className = 'qcm-camera-list-rows'; rows.setAttribute('aria-labelledby', 'qcm-camera-list-title'); + rows.setAttribute('role', 'list'); // list-style: none drops the list role in some browsers; the rows are still one list const notes = document.createElement('p'); notes.id = 'qcm-camera-list-notes'; notes.className = 'qcm-camera-list-notes'; notes.hidden = true; - host.append(title, scopes, search, radius, purposes, saving, status, rows, notes); + host.append(title, scopes, search, radius, purposes, saving, status, announce, rows, notes); cameraListHost = host; cameraListRowsKey = ''; return host; } @@ -1963,20 +1970,37 @@ async function searchCameraArea() { speak({ pending: true }); let index; try { index = await loadCameraIndex(); } catch (err) { speak({ indexFailed: true }); return; } - const { rows, total } = searchFiles({ files: index.files, box: searchBox(view, centre), layers: onIds, centre, max: SEARCH_MAX_FILES }); - for (const r of rows) cameraFileFailed.delete(r.file); - const wanted = rows.filter((r) => !cameraFiles.has(r.file)); - const results = await Promise.allSettled(wanted.map(loadCameraFile)); - results.forEach((r, i) => { if (r.status === 'rejected') cameraFileFailed.add(wanted[i].file); }); - for (const id of onIds) syncCameraPins(id); - renderCameraEntry(); - const failed = rows.filter((r) => cameraFileFailed.has(r.file)).length; - speak({ total, shown: rows.length, read: rows.length - failed, failed }); + // QQA-16c: anything that throws after the index read ends the search honestly, never as Searching forever. + try { + const { rows, total } = searchFiles({ files: index.files, box: searchBox(view, centre), layers: onIds, centre, max: SEARCH_MAX_FILES }); + for (const r of rows) cameraFileFailed.delete(r.file); + const wanted = rows.filter((r) => !cameraFiles.has(r.file)); + const results = await Promise.allSettled(wanted.map(loadCameraFile)); + results.forEach((r, i) => { if (r.status === 'rejected') cameraFileFailed.add(wanted[i].file); }); + for (const id of onIds) syncCameraPins(id); + renderCameraEntry(); + const failed = rows.filter((r) => cameraFileFailed.has(r.file)).length; + speak({ total, shown: rows.length, read: rows.length - failed, failed }); + } catch (err) { speak({ searchFailed: true }); } +} + +const savedCameraPlaces = (result) => (result.places || []).filter((p) => p.ref.startsWith('camera:')); +const savedLayerOf = (p) => cameraLayerFor(p.ref.split(':')[1]); + +/** Forgets what failed for the saved cameras (the index, and the files that can hold them) so the next + * read asks again, once. A press of Saved and a change in the saved count are the two ways to get here. */ +function retrySavedFailures(places) { + savedIndexFailed = false; savedFailedCount = 0; + const files = cameraIndex ? cameraIndex.files : []; + for (const p of places) { + const layer = savedLayerOf(p); + if (layer) for (const r of filesHolding(p, files, layer)) cameraFileFailed.delete(r.file); + } } /** The files that can hold the saved cameras, read once on the reader's choice of the Saved scope - * (whatever the layers' switches and the zoom). A file that failed is asked again only on a new choice - * of the scope's first press; the index is not asked again after a failure until then. */ + * (whatever the layers' switches and the zoom). A file or the index that failed is asked again only + * by a later press of Saved or a change in the saved count (retrySavedFailures), once each. */ async function loadSavedCameras(places, layerOf) { if (savedLoading || savedIndexFailed) return; const missing = () => { @@ -1987,10 +2011,11 @@ async function loadSavedCameras(places, layerOf) { if (cameraIndex && !missing().length) return; savedLoading = true; try { - try { await loadCameraIndex(); } catch (err) { savedIndexFailed = true; return; } + try { await loadCameraIndex(); } catch (err) { savedIndexFailed = true; savedFailedCount = places.length; return; } const wanted = missing(); const results = await Promise.allSettled(wanted.map(loadCameraFile)); results.forEach((r, i) => { if (r.status === 'rejected') cameraFileFailed.add(wanted[i].file); }); + if (results.some((r) => r.status === 'rejected')) savedFailedCount = places.length; // the count a retry is measured against } finally { savedLoading = false; scheduleCameraList(); } } @@ -2003,22 +2028,37 @@ function paintListStatus(host, status) { const notesEl = host.querySelector('#qcm-camera-list-notes'); write(notesEl, status.notes.join(' ')); notesEl.hidden = status.notes.length === 0; + const said = listAnnouncement(status, { searchPending: !!cameraSearch && !!cameraSearch.pending }); + if (said !== null) write(host.querySelector('#qcm-camera-list-announce'), said); } /** Replaces the rows when `key` says what they show has changed, so a pan never drops a keyboard - * user's focus. `items` are built only then. */ + * user's focus. `items` are built only then. QQA-16c: when the replace takes the focus out from under a + * keyboard user, it goes back to the same camera's row (its button, or Save if that is where it was); + * a row that is gone sends it to the list itself, never to the page. */ function paintListRows(host, key, items) { if (key === cameraListRowsKey) return; cameraListRowsKey = key; - host.querySelector('#qcm-camera-list-rows').replaceChildren(...items()); + const list = host.querySelector('#qcm-camera-list-rows'); + const active = document.activeElement; + const held = active && active !== list && list.contains(active) + ? { ref: active.closest('li')?.dataset.ref, save: active.classList.contains('qcm-camera-save') } : null; + list.replaceChildren(...items()); + if (!held) return; + paintSaveButtons(host); + const item = held.ref ? [...list.children].find((li) => li.dataset.ref === held.ref) : null; + const target = item && (item.querySelector(held.save ? 'button.qcm-camera-save' : 'button.qcm-camera-list-row') || item.querySelector('button')); + if (target && !target.disabled) { target.focus(); return; } + list.tabIndex = -1; list.focus(); } /** The Saved scope: the cameras this browser saved, each found, or said to be gone, unchecked or * being checked. Nothing is dropped from view because the operator's list lost it. */ function renderSavedScope(host) { const result = savedStore.read(); - const places = (result.places || []).filter((p) => p.ref.startsWith('camera:')); - const layerOf = (p) => cameraLayerFor(p.ref.split(':')[1]); + const places = savedCameraPlaces(result); + const layerOf = savedLayerOf; + if (savedFailedCount && places.length !== savedFailedCount) retrySavedFailures(places); // QQA-16c: a new count is worth one more try if (places.length) loadSavedCameras(places, layerOf); const rows = savedCameraRows({ saved: places, layerOf, files: cameraIndex ? cameraIndex.files : [], @@ -2037,6 +2077,7 @@ function renderSavedScope(host) { paintListStatus(host, status); paintListRows(host, JSON.stringify(['saved', rows.map((r) => [r.place.ref, r.state])]), () => rows.map((row) => { const item = document.createElement('li'); + item.dataset.ref = row.place.ref; const text = savedRowText(row, cameraProviderRow(row.place.ref.split(':')[1])); const listed = row.state === 'listed'; const body = document.createElement(listed ? 'button' : 'span'); @@ -2107,6 +2148,7 @@ function renderCameraList() { const rows = shown ? found.listed : []; paintListRows(host, JSON.stringify([scope, rows.map((r) => [cameraRef(r.camera), near ? Math.round(r.km * 10) : 0])]), () => rows.map((row) => { const item = document.createElement('li'); + item.dataset.ref = cameraRef(row.camera); const button = document.createElement('button'); button.type = 'button'; button.className = 'qcm-camera-list-row'; const text = cameraRowText(row, { near });