Merge upstream/main 73c14ac74

This commit is contained in:
daikaginza committed 2026-11-04 11:21:01 +07:00
commit 0e0557e7f5
1594 files changed
+155855 -24143

No files matched your search

+46 -34
View File
@@ -111,8 +111,8 @@ const cacheDir = path.resolve(
);
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
async function fetchPinned(name) {
const { url, sha256 } = SOURCES[name];
export async function fetchPinned(name, source = SOURCES[name]) {
const { url, sha256 } = source;
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
let bytes = await readFile(file).catch(() => null);
if (!bytes) {
@@ -137,7 +137,7 @@ const EARTH_RADIUS_KM = 6371;
const toRad = (d) => (d * Math.PI) / 180;
/** Spherical ring area (km²); matches src/data/naturalEarthRegions.js. */
function ringAreaKm2(ring) {
export function ringAreaKm2(ring) {
const n = ring.length;
if (n < 3) return 0;
let sum = 0;
@@ -151,7 +151,7 @@ function ringAreaKm2(ring) {
}
/** Signed planar (shoelace) area: positive counter-clockwise in lon/lat. */
function signedArea(ring) {
export function signedArea(ring) {
let sum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
sum += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
@@ -209,15 +209,16 @@ function douglasPeucker(points, tolerance) {
* Simplify a closed ring to an OPEN rounded ring. The ring is split at its
* farthest vertex from the start so Douglas-Peucker has two anchored halves
* (a closed ring has no chord to measure against). Returns null when fewer
* than four distinct vertices survive.
* than `minVertices` distinct vertices survive (four by default; a pack that
* keeps triangles passes three).
*/
function simplifyRing(ring, tolerance, decimals) {
function simplifyRing(ring, tolerance, decimals, minVertices = 4) {
let open = ring.slice();
const first = open[0];
const last = open[open.length - 1];
if (open.length > 1 && first[0] === last[0] && first[1] === last[1])
open = open.slice(0, -1);
if (open.length < 4) return null;
if (open.length < minVertices) return null;
let far = 1;
let farDist = -1;
for (let i = 1; i < open.length; i++) {
@@ -247,7 +248,7 @@ function simplifyRing(ring, tolerance, decimals) {
out[0][1] === out[out.length - 1][1]
)
out.pop();
return out.length >= 4 ? out : null;
return out.length >= minVertices ? out : null;
}
/**
@@ -255,16 +256,17 @@ function simplifyRing(ring, tolerance, decimals) {
* area floors (the largest part always stays), orient outers
* counter-clockwise and holes clockwise, sort parts by area descending.
*/
function simplifyPolygons(polygons, params, tolerance, decimals) {
export function simplifyPolygons(polygons, params, tolerance, decimals) {
const minVertices = params.minRingVertices ?? 4;
const parts = [];
for (const poly of polygons) {
const outer = simplifyRing(poly[0], tolerance, decimals);
const outer = simplifyRing(poly[0], tolerance, decimals, minVertices);
if (!outer) continue;
const area = ringAreaKm2(outer);
if (signedArea(outer) < 0) outer.reverse();
const holes = [];
for (const hole of poly.slice(1)) {
const ring = simplifyRing(hole, tolerance, decimals);
const ring = simplifyRing(hole, tolerance, decimals, minVertices);
if (!ring || ringAreaKm2(ring) < params.minHoleKm2) continue;
if (signedArea(ring) > 0) ring.reverse();
holes.push(ring);
@@ -287,18 +289,22 @@ function simplifyPolygons(polygons, params, tolerance, decimals) {
* and a fifth of the minimum tolerance, or failing that unsimplified at two
* more decimals; the caller records `d`.
*/
function simplifyUnit(polygons, params) {
export function unitTolerance(polygons, params) {
const sourceArea = polygons.reduce(
(sum, poly) => sum + ringAreaKm2(poly[0]),
0,
);
const tolerance = Math.min(
return Math.min(
params.maxToleranceDeg,
Math.max(
params.minToleranceDeg,
(params.toleranceFactor * Math.sqrt(sourceArea)) / 111.32,
),
);
}
export function simplifyUnit(polygons, params) {
const tolerance = unitTolerance(polygons, params);
const coarse = simplifyPolygons(polygons, params, tolerance, params.decimals);
if (coarse.length) return { polygons: coarse, decimals: params.decimals };
const fine = simplifyPolygons(
@@ -334,7 +340,7 @@ function encodeRing(ring, decimals) {
return out;
}
function encodeUnit({ polygons, decimals }) {
export function encodeUnit({ polygons, decimals }) {
return polygons.map((poly) => poly.map((ring) => encodeRing(ring, decimals)));
}
@@ -362,7 +368,7 @@ function bboxOf(polygons) {
// ── zip / shapefile / dbf ───────────────────────────────────────────────
/** Read named entries from a zip archive (stored or deflated). */
function unzip(buffer) {
export function unzip(buffer) {
let eocd = buffer.length - 22;
while (eocd >= 0 && buffer.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('zip: end of central directory not found');
@@ -394,7 +400,7 @@ function unzip(buffer) {
}
/** Polygon records (shape type 5) as arrays of closed [lon, lat] rings. */
function readShp(buffer) {
export function readShp(buffer) {
const shapes = [];
let offset = 100;
while (offset < buffer.length) {
@@ -431,7 +437,7 @@ function readShp(buffer) {
}
/** dBASE records as plain objects (character and numeric fields). */
function readDbf(buffer) {
export function readDbf(buffer) {
const count = buffer.readUInt32LE(4);
const headerLength = buffer.readUInt16LE(8);
const recordLength = buffer.readUInt16LE(10);
@@ -458,7 +464,7 @@ function readDbf(buffer) {
}
/** Group shapefile rings into polygons: clockwise outers, holes assigned by containment. */
function shpPolygons(rings) {
export function shpPolygons(rings) {
const outers = [];
const holes = [];
for (const ring of rings) {
@@ -475,7 +481,7 @@ function shpPolygons(rings) {
return polygons;
}
function pointInRing(ring, x, y) {
export function pointInRing(ring, x, y) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
@@ -742,18 +748,24 @@ async function writePack(relative, pack) {
);
}
if (!only || only === 'countries')
await writePack(
'src/data/local_data/natural_earth/countries.json',
await buildCountries(),
);
if (!only || only === 'admin1')
await writePack(
'src/data/local_data/natural_earth/states_provinces.json',
await buildAdmin1(),
);
if (!only || only === 'counties')
await writePack(
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
if (!only || only === 'countries')
await writePack(
'src/data/local_data/natural_earth/countries.json',
await buildCountries(),
);
if (!only || only === 'admin1')
await writePack(
'src/data/local_data/natural_earth/states_provinces.json',
await buildAdmin1(),
);
if (!only || only === 'counties')
await writePack(
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
}
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env node
/**
* Build the bundled US places pack read by `src/data/placeBoundaries.js`:
*
* src/data/local_data/us_census_places/index.json state list, boxes, meta
* src/data/local_data/us_census_places/<ST>.json one file per state
* src/data/local_data/us_census_places/files.js their URLs, for bundlers
*
* Source: US Census Bureau 2025 cartographic boundary places
* (cb_2025_us_place_500k, public domain), pinned by URL and SHA-256, so a
* rerun reproduces the pack byte for byte. The download is cached outside the
* repository (default: the system temp directory).
*
* Geometry uses the county pack's simplification and encoding
* (scripts/build-admin-packs.mjs) with two differences: triangles are kept,
* and a unit whose simplified rings cross themselves or each other is
* simplified again at half the tolerance (finer precision last). Then
* `scripts/pack_geometry.py repair census` checks every feature as a whole
* MultiPolygon under GEOS and repairs the ones that are still invalid (rings
* touching themselves, overlapping parts), so it needs Python with
* shapely==2.1.2 on GEOS 3.13.1.
*
* Usage:
* node scripts/build-census-places.mjs [--cache <dir>] [--python <exe>]
*/
import { execFileSync } from 'node:child_process';
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
PARAMS as ADMIN_PARAMS,
encodeUnit,
fetchPinned,
readDbf,
readShp,
shpPolygons,
simplifyPolygons,
unitTolerance,
unzip,
} from './build-admin-packs.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT = path.join(ROOT, 'src/data/local_data/us_census_places');
export const SOURCE = Object.freeze({
url: 'https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_place_500k.zip',
sha256: 'ce0e4019ecd4123d03d53aaa936eed0459b82e3e14b89a3dcd4d5e8b3308627d',
});
/** County-pack parameters; triangles are valid rings here. */
export const PARAMS = Object.freeze({
...ADMIN_PARAMS.counties,
minRingVertices: 3,
maxRepairSteps: 8,
});
/** Census LSAD codes for census designated places (unincorporated). */
const CDP_LSAD = new Set(['55', '57', '62']);
// ── validity ─────────────────────────────────────────────────────────────
function orient(ax, ay, bx, by, cx, cy) {
const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
return v > 0 ? 1 : v < 0 ? -1 : 0;
}
function onSegment(ax, ay, bx, by, px, py) {
return (
Math.min(ax, bx) <= px &&
px <= Math.max(ax, bx) &&
Math.min(ay, by) <= py &&
py <= Math.max(ay, by)
);
}
/** Whether segments ab and cd share any point (touching counts). */
export function segmentsIntersect(a, b, c, d) {
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
if (o1 !== o2 && o3 !== o4) return true;
if (o1 === 0 && onSegment(a[0], a[1], b[0], b[1], c[0], c[1])) return true;
if (o2 === 0 && onSegment(a[0], a[1], b[0], b[1], d[0], d[1])) return true;
if (o3 === 0 && onSegment(c[0], c[1], d[0], d[1], a[0], a[1])) return true;
if (o4 === 0 && onSegment(c[0], c[1], d[0], d[1], b[0], b[1])) return true;
return false;
}
/**
* Whether segments of two different rings cross or overlap. Rings may touch
* at a point (a hole meeting its outer ring at a vertex is valid).
*/
function segmentsCross(s, t) {
const [a, b, c, d] = [s.a, s.b, t.a, t.b];
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
if (o1 && o2 && o3 && o4) return o1 !== o2 && o3 !== o4;
if (o1 === 0 && o2 === 0) {
// Collinear: overlapping by more than a point.
const key = a[0] !== b[0] ? 0 : 1;
const lo = Math.max(Math.min(a[key], b[key]), Math.min(c[key], d[key]));
const hi = Math.min(Math.max(a[key], b[key]), Math.max(c[key], d[key]));
return hi > lo;
}
// One endpoint on the other segment: a crossing only when the rings pass
// through each other there; a touch at a point is allowed.
return false;
}
/**
* Whether the rings of one part ([outer, ...holes], open, integer
* coordinates) are simple: no ring crosses or touches itself away from its
* shared vertices, no two rings cross or overlap (they may touch at a point),
* and no ring is flat.
*/
export function partIsValid(rings) {
const segments = [];
for (let r = 0; r < rings.length; r++) {
const ring = rings[r];
if (ring.length < 3) return false;
let twice = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
twice += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
if (twice === 0) return false;
for (let i = 0; i < ring.length; i++) {
const a = ring[i];
const b = ring[(i + 1) % ring.length];
segments.push({
r,
i,
n: ring.length,
a,
b,
minX: Math.min(a[0], b[0]),
maxX: Math.max(a[0], b[0]),
});
}
}
segments.sort((s, t) => s.minX - t.minX);
for (let x = 0; x < segments.length; x++) {
const s = segments[x];
for (let y = x + 1; y < segments.length; y++) {
const t = segments[y];
if (t.minX > s.maxX) break;
if (s.r === t.r) {
const gap = Math.abs(s.i - t.i);
// Neighbours share exactly one vertex; that is not a crossing — unless
// they fold back over each other.
if (gap === 1 || gap === s.n - 1) {
if (s.n > 3 && collinearOverlap(s, t)) return false;
continue;
}
}
if (segmentsCross(s, t)) return false;
}
}
return true;
}
function collinearOverlap(s, t) {
const shared =
s.a === t.a || s.a === t.b ? s.a : s.b === t.a || s.b === t.b ? s.b : null;
if (!shared) return false;
const p = s.a === shared ? s.b : s.a;
const q = t.a === shared ? t.b : t.a;
if (orient(shared[0], shared[1], p[0], p[1], q[0], q[1]) !== 0) return false;
// Collinear and pointing the same way from the shared vertex: a spike.
return (
(p[0] - shared[0]) * (q[0] - shared[0]) +
(p[1] - shared[1]) * (q[1] - shared[1]) >
0
);
}
function toIntegers(polygons, decimals) {
const f = 10 ** decimals;
return polygons.map((poly) =>
poly.map((ring) =>
ring.map(([lon, lat]) => [Math.round(lon * f), Math.round(lat * f)]),
),
);
}
export function polygonsValid(polygons, decimals) {
return toIntegers(polygons, decimals).every(partIsValid);
}
/**
* Simplify a place like `simplifyUnit`, then halve the tolerance until every
* part is valid; then one more decimal; then the source rings at two more.
* @returns {{polygons: Array, decimals: number, repaired: boolean, valid: boolean}}
*/
export function simplifyPlace(source, params = PARAMS) {
let tolerance = unitTolerance(source, params);
const attempts = [];
for (let step = 0; step <= params.maxRepairSteps; step++) {
attempts.push([tolerance, params.decimals]);
tolerance /= 2;
}
attempts.push([params.minToleranceDeg / 5, params.decimals + 1]);
attempts.push([0, params.decimals + 1]);
attempts.push([0, params.decimals + 2]);
let first = null;
for (let i = 0; i < attempts.length; i++) {
const [tol, decimals] = attempts[i];
const polygons = simplifyPolygons(source, params, tol, decimals);
if (!polygons.length) continue;
first ||= { polygons, decimals };
if (polygonsValid(polygons, decimals))
return { polygons, decimals, repaired: i > 0, valid: true };
}
return first
? { ...first, repaired: false, valid: false }
: {
polygons: [],
decimals: params.decimals,
repaired: false,
valid: false,
};
}
// ── names ────────────────────────────────────────────────────────────────
const GOVERNMENT =
/\s+(?:metropolitan government|unified government|consolidated government|metro government|city and county|urban county)\b.*$/i;
/**
* Everyday names for records whose Census name is formal: "Nashville-Davidson
* metropolitan government (balance)" is also "Nashville"; "Urban Honolulu"
* is "Honolulu".
*/
export function placeAliases(name) {
const out = new Set();
let base = String(name).replace(/\s*\(balance\)\s*$/i, '');
if (base !== name) out.add(base);
const stripped = base.replace(GOVERNMENT, '');
if (stripped !== base) {
out.add(stripped);
base = stripped;
}
if (base !== name && /[-/]/.test(base)) out.add(base.split(/[-/]/)[0].trim());
const urban = /^Urban (.+)$/.exec(name);
if (urban) out.add(urban[1]);
out.delete(name);
return [...out].filter(Boolean).sort();
}
// ── pack ─────────────────────────────────────────────────────────────────
function bboxOf(polygons) {
let w = Infinity;
let s = Infinity;
let e = -Infinity;
let n = -Infinity;
for (const poly of polygons)
for (const [lon, lat] of poly[0]) {
if (lon < w) w = lon;
if (lon > e) e = lon;
if (lat < s) s = lat;
if (lat > n) n = lat;
}
return [w, s, e, n];
}
function union(boxes) {
return [
Math.min(...boxes.map((b) => b[0])),
Math.min(...boxes.map((b) => b[1])),
Math.max(...boxes.map((b) => b[2])),
Math.max(...boxes.map((b) => b[3])),
];
}
/**
* The box of a state's places. Alaska crosses the antimeridian: its box is
* taken with western longitudes shifted past 180 when that is narrower.
*/
function unionBox(boxes) {
const plain = union(boxes);
const shifted = union(
boxes.map(([w, s, e, n]) =>
e < 0 ? [w + 360, s, e + 360, n] : [w, s, e, n],
),
);
return shifted[2] - shifted[0] < plain[2] - plain[0] ? shifted : plain;
}
async function build() {
const params = PARAMS;
const source = await fetchPinned('places', SOURCE);
const files = unzip(source.bytes);
const base = path.basename(new URL(SOURCE.url).pathname, '.zip');
const shapes = readShp(files.get(`${base}.shp`));
const records = readDbf(files.get(`${base}.dbf`));
if (shapes.length !== records.length)
throw new Error('places: shp/dbf record counts differ');
const states = new Map();
const stats = { records: records.length, repaired: [], invalid: [] };
records.forEach((record, index) => {
const unit = simplifyPlace(shpPolygons(shapes[index]), params);
if (!unit.polygons.length) throw new Error(`no geometry: ${record.GEOID}`);
if (unit.repaired) stats.repaired.push(record.GEOID);
if (!unit.valid) stats.invalid.push(record.GEOID);
const alt = placeAliases(record.NAME);
const lsad = record.NAMELSAD.startsWith(record.NAME)
? record.NAMELSAD.slice(record.NAME.length).trim()
: '';
let state = states.get(record.STUSPS);
if (!state) {
state = {
st: record.STUSPS,
fips: record.STATEFP,
name: record.STATE_NAME,
features: [],
boxes: [],
};
states.set(record.STUSPS, state);
}
state.boxes.push(bboxOf(unit.polygons));
state.features.push({
geoid: record.GEOID,
name: record.NAME,
...(alt.length ? { alt } : {}),
full: record.NAMELSAD,
...(lsad ? { lsad } : {}),
...(CDP_LSAD.has(record.LSAD) ? { cdp: true } : {}),
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
polygons: encodeUnit(unit),
});
});
if (stats.invalid.length)
throw new Error(`places still invalid: ${stats.invalid.join(', ')}`);
await mkdir(OUT, { recursive: true });
for (const file of await readdir(OUT))
if (/^[A-Z]{2}\.json$/.test(file)) await rm(path.join(OUT, file));
const list = [...states.values()].sort((a, b) => a.st.localeCompare(b.st));
let bytes = 0;
for (const state of list) {
state.features.sort((a, b) => a.geoid.localeCompare(b.geoid));
const text =
JSON.stringify({
st: state.st,
state: state.name,
fips: state.fips,
features: state.features,
}) + '\n';
bytes += Buffer.byteLength(text);
await writeFile(path.join(OUT, `${state.st}.json`), text);
}
const round = (v) => Math.round(v * 1e4) / 1e4;
const index = {
meta: {
title: 'US Census Bureau cartographic boundary places',
source: base,
url: SOURCE.url,
sha256: source.digest,
license: 'Public domain (U.S. Government work, 17 U.S.C. § 105)',
schema:
'states[]: st (USPS), fips, name, count, bbox [w, s, e, n] (e > 180 across the antimeridian). <st>.json: st, state, fips, features[]: geoid, name (NAME), alt?[] (everyday names for formal records), full (NAMELSAD), lsad? (city, town, CDP, …), cdp? (census designated place), d? (decimals when not meta.decimals), label? [lon, lat] (verified filled anchor in degrees when the outer centroid is unfilled), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
decimals: params.decimals,
curation: params,
script: 'scripts/build-census-places.mjs',
},
states: list.map((s) => ({
st: s.st,
fips: s.fips,
name: s.name,
count: s.features.length,
bbox: unionBox(s.boxes).map(round),
})),
};
await writeFile(
path.join(OUT, 'index.json'),
JSON.stringify(index, null, 1) + '\n',
);
await writeFile(path.join(OUT, 'files.js'), filesModule(list));
const count = list.reduce((n, s) => n + s.features.length, 0);
console.log(
`us_census_places: ${count} places in ${list.length} files, ${bytes} bytes; ` +
`${stats.repaired.length} re-simplified to stay valid`,
);
const args = process.argv.slice(2);
const python = args.includes('--python')
? args[args.indexOf('--python') + 1]
: 'python3';
execFileSync(
python,
[path.join(ROOT, 'scripts/pack_geometry.py'), 'repair', 'census'],
{ stdio: 'inherit', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } },
);
}
/**
* The file list as literal URLs, so a bundler emits every state file as an
* asset and the loader can fetch one by its code.
*/
function filesModule(list) {
const lines = [
'// Generated by scripts/build-census-places.mjs; do not edit.',
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
'export const STATE_URLS = {',
...list.map(
(s) => ` ${s.st}: () => new URL('./${s.st}.json', import.meta.url),`,
),
'};',
];
return lines.join('\n') + '\n';
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) await build();
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""Maintainer-only Who's On First neighborhood pack build (Shapely 2.1).
Reads the pinned Who's On First neighbourhood, macrohood and microhood
archives and the WOF sources registry, keeps current polygon records whose
geometry source matches the reviewed open-licence allowlist, simplifies them
to about 15 m, then applies pinned source/ID exclusions after partitioning, and
writes src/data/local_data/wof_neighborhoods/:
index.json format, licences per source, tile list with boxes
<tile>.json records whose box centre falls in that quadtree tile
files.js their URLs, for bundlers
ATTRIBUTION.md per-source credits
Usage:
python scripts/build-wof-neighborhoods.py --cache /tmp/wof-neighborhoods [--verify]
Requires shapely==2.1.2 on GEOS 3.13.1 (checked). --verify rebuilds in memory
and fails unless every shipped file matches byte for byte.
Downloads go to --cache and are checked against the pinned SHA-256. The
neighbourhood archive is about 400 MB.
"""
import argparse
import collections
import hashlib
import json
import math
import subprocess
import tarfile
from pathlib import Path
from shapely import make_valid
from shapely.affinity import scale
from shapely.geometry import MultiPolygon, shape
import pack_geometry
ROOT = Path(__file__).resolve().parent.parent
PACK = ROOT / 'src/data/local_data/wof_neighborhoods'
# The legacy distribution is frozen (last published 2025-10-14) and only
# offers `-latest` names; the SHA-256 pins make a changed file fail loudly.
DIST = 'https://data.geocode.earth/wof/dist/legacy'
ARCHIVES = {
'neighbourhood': {
'url': DIST + '/whosonfirst-data-neighbourhood-latest.tar.bz2',
'sha256': '8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455',
},
'macrohood': {
'url': DIST + '/whosonfirst-data-macrohood-latest.tar.bz2',
'sha256': 'f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679',
},
'microhood': {
'url': DIST + '/whosonfirst-data-microhood-latest.tar.bz2',
'sha256': 'e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac',
},
}
# The registry at a fixed commit.
SOURCES = {
'url': 'https://raw.githubusercontent.com/whosonfirst/whosonfirst-sources/'
'e17af153c144fe6e08aa8db2607b4bf8aecd448d/data/sources-spec-latest.json',
'sha256': 'd8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39',
}
TOLERANCE_M = 15
PRECISION = 5
TILE_MAX_BYTES = 200_000
TILE_MAX_DEPTH = 16
LICENSE_POLICY = json.loads((ROOT / 'scripts/wof-open-licenses.json').read_text())
ALLOWED_LICENSES = frozenset(LICENSE_POLICY['licenses'])
REVIEWED_SOURCES = LICENSE_POLICY.get('reviewedSources', {})
RECORD_EXCLUSIONS = LICENSE_POLICY['recordExclusions']
FIELDS = ['id', 'name', 'country', 'type', 'bbox', 'aliases', 'source', 'rings', 'label']
def fetch(cache, name, url, sha256):
path = cache / name
if not path.exists():
print('Downloading', url, flush=True)
# Plain urllib requests are refused by the WOF host; curl is not.
subprocess.run(['curl', '--fail', '-L', '-o', str(path), url], check=True)
digest = hashlib.sha256()
with open(path, 'rb') as handle:
for chunk in iter(lambda: handle.read(1 << 20), b''):
digest.update(chunk)
if digest.hexdigest() != sha256:
raise ValueError(f'{name}: SHA-256 {digest.hexdigest()} is not the pinned {sha256}')
return path
# ── geometry ────────────────────────────────────────────────────────────
def simplify(geometry):
"""Simplify at TOLERANCE_M in local metres, snap to the pack grid, keep it
valid, and order parts largest first. Returns (rings, label) where the
label is a point inside the largest part, or ([], None)."""
geometry = MultiPolygon(pack_geometry.polygon_parts(make_valid(geometry) if not geometry.is_valid else geometry))
if geometry.is_empty:
return [], None
lat = geometry.centroid.y
sx = 111320 * max(0.001, math.cos(math.radians(lat)))
sy = 110574
metric = scale(geometry, xfact=sx, yfact=sy, origin=(0, 0))
metric = metric.simplify(TOLERANCE_M, preserve_topology=True)
geometry = scale(metric, xfact=1 / sx, yfact=1 / sy, origin=(0, 0))
# To whole pack units (10^-PRECISION degrees), where GEOS is exact.
units = scale(geometry, xfact=10 ** PRECISION, yfact=10 ** PRECISION, origin=(0, 0))
parts = pack_geometry.ordered_parts(pack_geometry.settle(units))
if not parts:
return [], None
rings = [
[pack_geometry.encode_polyline(pack_geometry.open_ring(p.exterior.coords))]
+ [pack_geometry.encode_polyline(pack_geometry.open_ring(r.coords)) for r in p.interiors]
for p in parts
]
# Check what ships: the geometry decoded from the encoded strings.
decoded = [[pack_geometry.decode_polyline(r) for r in poly] for poly in rings]
why = pack_geometry.problems(decoded)
if why:
raise ValueError(f'encoded geometry invalid: {why}')
point = parts[0].representative_point()
point = shape({'type': 'Point', 'coordinates': [round(point.x) / 10 ** PRECISION, round(point.y) / 10 ** PRECISION]})
return rings, [point.x, point.y]
# ── records ─────────────────────────────────────────────────────────────
def read_archive(path, records):
with tarfile.open(path, 'r|bz2') as archive:
for member in archive:
if not member.name.endswith('.geojson') or '-alt-' in member.name:
continue
feature = json.load(archive.extractfile(member))
if feature['geometry']['type'] not in ('Polygon', 'MultiPolygon'):
records.pop(feature['properties']['wof:id'], None)
continue
records[feature['properties']['wof:id']] = feature
def keep(properties):
deprecated = properties.get('edtf:deprecated', 'uuuu') not in ('uuuu', '', None)
superseded = bool(properties.get('wof:superseded_by', []))
return properties.get('mz:is_current', -1) != 0 and not deprecated and not superseded
def source_table(spec):
lookup = {}
for source in spec.values():
for key in (source.get('name'), source.get('prefix')):
if key:
lookup[key] = source
return lookup
def verify_source_reviews(lookup):
"""Pin exceptional source decisions to the exact reviewed registry evidence."""
registry_commit = SOURCES['url'].split('/')[-3]
for key, review in REVIEWED_SOURCES.items():
source = lookup.get(key)
if not source:
raise ValueError(f'{key}: reviewed source is absent from the pinned registry')
if review.get('registryCommit') != registry_commit:
raise ValueError(f'{key}: review does not match the pinned registry commit')
if review.get('registrySha256') != SOURCES['sha256']:
raise ValueError(f'{key}: review does not match the pinned registry SHA-256')
if review.get('rawLicenseType') != source.get('license_type'):
raise ValueError(f'{key}: reviewed raw licence no longer matches the registry')
text = source.get('license_text') or ''
if review.get('licenseTextSha256') != hashlib.sha256(text.encode()).hexdigest():
raise ValueError(f'{key}: reviewed licence text no longer matches the registry')
if review['rawLicenseType'] not in ALLOWED_LICENSES:
raise ValueError(f'{key}: reviewed raw licence is not admitted by policy')
def licence_allowed(value):
"""Only exact raw registry strings reviewed in the policy are accepted."""
return isinstance(value, str) and value in ALLOWED_LICENSES
def source_allowed(key, source):
return (key != 'unknown' and bool(source) and
licence_allowed(source.get('license_type')))
def build_records(archives, lookup):
raw = {}
for kind in ('neighbourhood', 'macrohood', 'microhood'):
read_archive(archives[kind], raw)
print(kind, len(raw), flush=True)
features = []
excluded = collections.Counter()
for wof_id in sorted(raw):
properties = raw[wof_id]['properties']
if not keep(properties):
continue
key = properties.get('src:geom', 'unknown')
if not source_allowed(key, lookup.get(key)):
excluded[key] += 1
continue
name = properties['wof:name']
aliases = sorted({
alias
for k, values in properties.items()
if k.startswith('name:') and isinstance(values, list)
for alias in values
if isinstance(alias, str) and alias != name
})
if not name.strip() and not aliases:
continue # nothing to ask for it by
geometry = shape(raw[wof_id]['geometry'])
if geometry.is_empty:
continue
rings, label = simplify(geometry)
if not rings:
continue
country = properties.get('iso:country', properties.get('wof:country'))
features.append([
wof_id, name, country, properties['wof:placetype'],
[round(v, PRECISION) for v in geometry.bounds], aliases, key, rings, label,
])
return features, excluded
# ── tiles ───────────────────────────────────────────────────────────────
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
def tiles(features, key='', box=(-180.0, -90.0, 180.0, 90.0)):
size = sum(len(compact(f).encode()) for f in features)
if size <= TILE_MAX_BYTES or len(key) >= TILE_MAX_DEPTH:
yield key or 'world', features
return
west, south, east, north = box
mx, my = (west + east) / 2, (south + north) / 2
quads = [[], [], [], []]
for f in features:
x = (f[4][0] + f[4][2]) / 2
y = (f[4][1] + f[4][3]) / 2
quads[(1 if x >= mx else 0) + (2 if y >= my else 0)].append(f)
boxes = [(west, south, mx, my), (mx, south, east, my), (west, my, mx, north), (mx, my, east, north)]
for i, quad in enumerate(quads):
if quad:
yield from tiles(quad, key + str(i), boxes[i])
def union_box(features):
return [
min(f[4][0] for f in features), min(f[4][1] for f in features),
max(f[4][2] for f in features), max(f[4][3] for f in features),
]
def exclude_records(partition):
"""Filter approved source+IDs AFTER the original byte-sized partition."""
if RECORD_EXCLUSIONS['archives'] != {k: a['sha256'] for k, a in ARCHIVES.items()}:
raise ValueError('record exclusions do not match the pinned archives')
if RECORD_EXCLUSIONS['registrySha256'] != SOURCES['sha256']:
raise ValueError('record exclusions do not match the pinned registry')
expected = {}
for source, rule in RECORD_EXCLUSIONS['sources'].items():
if rule['count'] != len(rule['ids']) or len(set(rule['ids'])) != len(rule['ids']):
raise ValueError(f'{source}: invalid exclusion membership')
for record_id in rule['ids']:
if record_id in expected:
raise ValueError(f'{record_id}: duplicate exclusion')
expected[record_id] = source
seen = set()
kept = []
for key, members in partition:
retained = []
for row in members:
record_id, source = row[0], row[6]
if record_id in expected:
if source != expected[record_id] or record_id in seen:
raise ValueError(f'{record_id}: exclusion source or membership changed')
seen.add(record_id)
elif source in RECORD_EXCLUSIONS['sources']:
raise ValueError(f'{record_id}: unreviewed exclusion-source member')
else:
retained.append(row)
if not retained:
raise ValueError(f'{key}: exclusion would change the tile layout')
kept.append((key, retained))
if seen != set(expected):
raise ValueError(f'missing excluded records: {sorted(set(expected) - seen)}')
return kept
# ── credits ─────────────────────────────────────────────────────────────
def licence_label(key, source):
licence = source['license_type']
if key == 'karmashapes':
return 'CC BY 4.0'
return {
'CC-BY': 'CC BY', 'Attribution 3.0 Unported': 'CC BY 3.0', 'Public domain': 'Public Domain',
'Open Government License - British Columbia, v2.0': 'Open Government Licence - British Columbia, v2.0',
}.get(licence, licence)
def exclusion_note():
groups = ', '.join(f'{rule["count"]} `{key}` records' for key, rule in RECORD_EXCLUSIONS['sources'].items())
lead = f'This derived pack excludes {groups}. ' if groups else ''
return f'{lead}{RECORD_EXCLUSIONS["reason"]} Original upstream notices remain unchanged.'
def attribution(counts, lookup):
lines = [
'# Neighborhood boundary credits',
'',
"Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licenses/) "
'and contributors, used under the licences below. Modified: boundaries simplified to about 15 m, repaired and re-encoded.',
'',
'| Source | Records | Licence | Terms |',
'|---|---:|---|---|',
]
for key, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
source = lookup[key]
name = source.get('fullname') or key
terms = source.get('license') or ''
link = f'[terms]({terms})' if terms.startswith('http') else terms
lines.append(f'| {name} (`{key}`) | {count:,} | {licence_label(key, source)} | {link} |')
via = [(key, lookup[key]) for key in sorted(counts) if lookup[key].get('src:via')]
if via:
lines += ['', '## Upstream sources', '']
for key, source in via:
lines += [f'### {source.get("fullname") or key}', '']
for v in source['src:via']:
note = f' ({v["source_note"]})' if v.get('source_note') else ''
context = f'{v["context"]}: ' if v.get('context') else ''
lines.append(f'- {context}{v.get("source_name", "")}{note} {v.get("source_link", "")}'.rstrip())
lines.append('')
while lines[-1] == '':
lines.pop()
lines += ['', '## Source-specific exclusions', '', exclusion_note()]
return '\n'.join(lines).rstrip() + '\n'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--cache', type=Path, required=True)
parser.add_argument('--verify', action='store_true', help='rebuild in memory and compare with the shipped pack')
args = parser.parse_args()
pack_geometry.require_pinned_tooling()
args.cache.mkdir(parents=True, exist_ok=True)
archives = {
kind: fetch(args.cache, Path(a['url']).name, a['url'], a['sha256'])
for kind, a in ARCHIVES.items()
}
spec = json.loads(fetch(args.cache, 'sources-spec-latest.json', SOURCES['url'], SOURCES['sha256']).read_text())
lookup = source_table(spec)
verify_source_reviews(lookup)
features, excluded = build_records(archives, lookup)
if len({f[0] for f in features}) != len(features):
raise ValueError('duplicate WOF id')
out = {}
tile_list = []
# Partition the full pinned input first: early filtering can collapse tiles.
partition = exclude_records(list(tiles(features)))
features = [row for _, members in partition for row in members]
for key, members in partition:
members.sort(key=lambda f: f[0])
out[f'{key}.json'] = compact({'tile': key, 'features': members}) + '\n'
tile_list.append({'key': key, 'count': len(members), 'bbox': union_box(members)})
counts = collections.Counter(f[6] for f in features)
index = {
'meta': {
'title': "Who's On First neighbourhood, macrohood and microhood polygons",
'archives': ARCHIVES,
'sourcesRegistry': SOURCES,
'recordExclusions': {key: rule['count'] for key, rule in RECORD_EXCLUSIONS['sources'].items()},
'recordExclusionReason': RECORD_EXCLUSIONS['reason'],
'license': 'Per source; see sources and ATTRIBUTION.md',
'format': 'neighborhood-polylines-v1',
'fields': FIELDS,
'precision': PRECISION,
'coordinateOrder': 'lon,lat',
'toleranceM': TOLERANCE_M,
'schema': (
'tiles[]: key, count, bbox [w, s, e, n] of its records. <key>.json: tile, features[] as '
'fields: WOF id, name, ISO country, placetype, bbox, aliases[] (other-language names), '
'geometry source key, rings (polygons, largest first, each [outer, ...holes]; each ring '
'an encoded polyline string: zigzag varint deltas of lon then lat at 10^-precision '
'degrees), label [lon, lat] (a point inside the largest polygon)'
),
'sources': {
key: {
'name': lookup[key].get('fullname') or key,
'license': licence_label(key, lookup[key]),
'terms': lookup[key].get('license') or '',
'records': counts[key],
}
for key in sorted(counts)
},
'script': 'scripts/build-wof-neighborhoods.py',
},
'tiles': tile_list,
}
out['index.json'] = json.dumps(index, ensure_ascii=False, indent=1) + '\n'
out['ATTRIBUTION.md'] = attribution(counts, lookup)
# Literal URLs, so a bundler emits every tile as an asset.
out['files.js'] = '\n'.join([
'// Generated by scripts/build-wof-neighborhoods.py; do not edit.',
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
'export const TILE_URLS = {',
*[f" t{t['key']}: () => new URL('./{t['key']}.json', import.meta.url)," for t in tile_list],
'};',
]) + '\n'
if args.verify:
# Reproducibility: a rebuild must match what ships, byte for byte.
shipped = {p.name for p in PACK.glob('*.json')} | {'ATTRIBUTION.md', 'files.js'}
differ = sorted(
name for name in shipped | set(out)
if not (PACK / name).exists() or name not in out
or (PACK / name).read_bytes() != out[name].encode('utf-8')
)
if differ:
raise SystemExit(f'rebuild differs from the shipped pack: {differ[:10]}')
print(f'wof_neighborhoods: rebuild matches all {len(out)} shipped files')
return
PACK.mkdir(parents=True, exist_ok=True)
for old in PACK.glob('*.json'):
old.unlink()
for name, text in out.items():
(PACK / name).write_text(text, encoding='utf-8')
print(f'wof_neighborhoods: {len(features)} records in {len(tile_list)} tiles; '
f'{sum(rule["count"] for rule in RECORD_EXCLUSIONS["sources"].values())} source-specific record exclusions; '
f'excluded {sum(excluded.values())} with an unapproved licence: {dict(excluded)}', flush=True)
if __name__ == '__main__':
main()
-8
View File
@@ -153,14 +153,6 @@ export function checkImportDirections(root) {
file,
'Actions must consume feed state without the manager facade',
);
if (
file === 'src/app/constructCatalog.js' &&
to === 'src/data/localGeojson.js'
)
report(
file,
'Catalog must use the services owner without compatibility layer construction',
);
}
return record;
}
+10 -3
View File
@@ -1,6 +1,8 @@
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
import { build, normalizePath } from 'vite';
/** Build each browser or Node export group and reject imports outside its declared ownership. */
@@ -99,15 +101,20 @@ export async function checkPackageBoundaries(root) {
ssr: node ? { noExternal: true } : undefined,
logLevel: 'silent',
plugins: [
vue(VUE_PLUGIN_OPTIONS),
{
name: 'check-package-ownership',
moduleParsed(info) {
if (!allowed.has(info.id)) {
// Plugin helpers are virtual; a single-file component's style and
// template blocks belong to the component file itself.
if (info.id.startsWith('\0')) return;
const id = info.id.split('?')[0];
if (!allowed.has(id)) {
throw new Error(
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
);
}
seen.add(info.id);
seen.add(id);
},
},
],
+183 -9
View File
@@ -6,6 +6,7 @@
"scripts/qa-form-fields.mjs",
"scripts/package-boundaries.json",
"scripts/format.mjs",
"scripts/qa-layer-visibility.mjs",
"scripts/next-layer-state-token.mjs",
"scripts/check-layer-state-tokens.mjs",
"scripts/qa-layer-token-twochar.mjs",
@@ -28,6 +29,8 @@
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarks.js",
"src/annotations/drawMarks.test.mjs",
"src/annotations/drawMode.js",
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
@@ -54,6 +57,9 @@
"src/app/mapLifecycle.test.mjs",
"src/app/startupCamera.test.mjs",
"src/app/viewer.js",
"src/app/zoomOutGuard.test.mjs",
"src/app/controls.test.mjs",
"scripts/qa-zoom-out-guard.mjs",
"src/app/atmosphereCompat.test.mjs",
"src/standalone/application.js",
"src/standalone/controls.js",
@@ -67,6 +73,7 @@
"server/standalone/vite.config.js",
"vite.config.js",
"src/tooling/viteBuild.test.mjs",
"src/tooling/vueLoader.test.mjs",
"src/tooling/allowedHosts.test.mjs",
"server/providers/common/http.js",
"server/providers/common/query.js",
@@ -179,6 +186,8 @@
"src/ui/panelDisclosure.js",
"src/ui/imagerySplit.js",
"src/ui/imagerySplit.test.mjs",
"src/ui/imagerySplitView.test.mjs",
"src/ui/stores/imagerySplit.js",
"src/ui/surfaceKeyboard.js",
"src/ui/surfaceKeyboard.test.mjs",
"src/ui/panelRails.js",
@@ -193,11 +202,10 @@
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"scripts/qa-scope-hud.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
"src/ui/styles/segmented-control.css",
"src/ui/displayControls.test.mjs",
"src/ui/cameraOrientationControls.js",
"src/ui/cameraOrientationControls.test.mjs",
"src/ui/effects.js",
@@ -210,7 +218,6 @@
"src/styles/thermal.js",
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
"scripts/qa-geo-import.mjs",
@@ -296,6 +303,8 @@
"src/ui/styles.js",
"src/ui/shellLifecycle.test.mjs",
"src/testSupport/readStylesheet.mjs",
"src/testSupport/voiceUi.mjs",
"src/testSupport/voiceView.mjs",
"src/testSupport/streetLevelFakes.mjs",
"src/splitFlap.js",
"style.css",
@@ -396,6 +405,15 @@
"src/layers/perimeters/cards.test.mjs",
"src/layers/perimeters/inciweb.test.mjs",
"src/layers/perimeters/ownership.test.mjs",
"src/layers/droneAirspace/index.js",
"src/layers/droneAirspace/records.js",
"src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/cards.js",
"src/layers/droneAirspace/records.test.mjs",
"src/layers/droneAirspace/source.test.mjs",
"src/layers/droneAirspace/cards.test.mjs",
"src/layers/droneAirspace/ownership.test.mjs",
"src/layers/droneAirspace/docs.test.mjs",
"src/data/firmsHeatmap.js",
"src/data/fireAnchors.js",
"src/layers/firms/anchors.js",
@@ -517,7 +535,6 @@
"src/layers/bikeshare/selection.js",
"src/data/traffic.js",
"src/data/bikeshare.js",
"src/data/flowTiles.js",
"src/layers/traffic/source.test.mjs",
"src/layers/bikeshare/source.test.mjs",
"src/layers/bikeshare/proxyRoute.test.mjs",
@@ -526,6 +543,7 @@
"scripts/qa-traffic-navigation.mjs",
"src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs",
"src/ui/frameRateView.test.mjs",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js",
@@ -561,7 +579,9 @@
"src/data/cctvCataloniaSource.test.mjs",
"src/data/cctvHk.test.mjs",
"src/data/cctvVegvesen.test.mjs",
"src/data/cctvA22.test.mjs",
"src/data/cctvNyc.test.mjs",
"src/data/cctvHouston.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
@@ -649,8 +669,6 @@
"src/voice/realtimeBackend.test.mjs",
"src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sourceSlot.js",
"src/app/sources.js",
"src/ui/composition.js",
"src/services/application.js",
@@ -676,6 +694,7 @@
"src/app/layers/radio.js",
"src/app/layers/earthquakes.js",
"src/app/layers/perimeters.js",
"src/app/layers/droneAirspace.js",
"src/app/layers/cctv.js",
"src/app/layers/firms.js",
"src/app/layers/satellites.js",
@@ -703,7 +722,6 @@
"src/ui/shareRestoration.js",
"src/ui/visualSettings.js",
"src/ui/panelChrome.js",
"src/ui/panelChrome.radioOwnership.test.mjs",
"src/testSupport/readShellSource.mjs",
"src/ui/stateOwners.test.mjs",
"src/layers/flights/records.js",
@@ -741,6 +759,8 @@
"server/providers/openai/toolDescriptions.js",
"src/voice/session.js",
"src/voice/sessionCommands.js",
"src/ui/stores/pointerReticle.js",
"src/voice/pointerReticleView.test.mjs",
"src/voice/realtimeSession.js",
"src/voice/realtimeEvents.js",
"src/voice/session.test.mjs",
@@ -860,6 +880,7 @@
"src/layers/weather/imageryOrder.test.mjs",
"server/providers/cyclones.js",
"server/providers/firePerimeters.js",
"server/providers/droneAirspace.js",
"src/data/cyclonesProxy.test.mjs",
"src/data/firePerimetersProxy.test.mjs",
"server/providers/gnssIntegrity.js",
@@ -872,6 +893,19 @@
"src/layers/gnss/source.test.mjs",
"src/layers/gnss/ownership.test.mjs",
"scripts/qa-gnss.mjs",
"server/providers/ghostMaps.js",
"server/providers/ghostMaps.test.mjs",
"src/app/layers/ghostMaps.js",
"src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/kml.js",
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/source.js",
"src/layers/ghostMaps/catalog.test.mjs",
"src/layers/ghostMaps/records.test.mjs",
"src/layers/ghostMaps/lifecycle.test.mjs",
"scripts/qa-ghostmaps.mjs",
"src/data/droneAirspaceProxy.test.mjs",
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
@@ -886,6 +920,7 @@
"scripts/qa-weather-swap.mjs",
"scripts/qa-weather-teardown.mjs",
"src/layers/weather/imageryHost.test.mjs",
"src/layers/weather/display.test.mjs",
"src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs",
"src/sdr/decoderDiagnostics.test.mjs",
@@ -898,6 +933,8 @@
"src/sources/adsbRecords.test.mjs",
"src/layers/localAdsb/index.test.mjs",
"src/ui/localSdrControls.test.mjs",
"src/ui/sdrCardActions.test.mjs",
"src/ui/radioPanelActions.test.mjs",
"src/ui/localSdrMarkup.test.mjs",
"src/ui/localSdrPresentation.test.mjs",
"src/data/localReceiversProxy.test.mjs",
@@ -1081,12 +1118,149 @@
"scripts/qa-anime-sky.mjs",
"scripts/qa-sky-model-lighting.mjs",
"server/providers/overpass/requestAdmission.test.mjs",
"src/tooling/trackArrivalObservation.mjs",
"src/tooling/trackArrivalObservation.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs",
"src/cameraGlobeRotation.test.mjs",
"src/voice/globeResetConfirmation.test.mjs"
"src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs",
"src/hudSummaryLine.test.mjs",
"src/testSupport/stubMgrs.mjs",
"src/tools/queries/hud-caption-feed-state.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/preferencesView.test.mjs",
"src/voice/voiceControlView.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
"src/voice/panel/nativeActivity.test.mjs",
"src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs",
"src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
"src/share/viewCodec.test.mjs",
"src/share/legacyShareState.test.mjs",
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs",
"src/ui/kit/UiRail.test.mjs",
"src/ui/kit/UiSegmented.vue",
"src/ui/kit/UiSegmented.test.mjs",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSlider.test.mjs",
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiButton.test.mjs",
"src/ui/kit/UiChip.vue",
"src/ui/kit/UiChip.test.mjs",
"src/ui/globeActions.test.mjs",
"src/ui/safeFrameView.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
"src/ui/kit/UiBadge.vue",
"src/ui/kit/UiBadge.test.mjs",
"src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/owners.js",
"src/ui/stores/owners.test.mjs",
"src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs",
"src/ui/toast.js",
"src/ui/toastView.test.mjs",
"src/ui/stores/sceneInteractions.js",
"src/scenes/dataPacks/presentationView.test.mjs",
"src/ui/stores/dataPacks.js",
"src/scenes/interactionsView.test.mjs",
"src/layers/wind/inspectionMarker.js",
"src/layers/wind/inspectionMarker.test.mjs",
"src/ui/stores/windInspection.js",
"src/ui/stores/missionReplay.js",
"src/layers/launches/replayOverlay.test.mjs",
"src/ui/stores/bhoteKoshiPanel.js",
"src/data/bhoteKoshiPanelView.test.mjs",
"src/ui/stores/celestialRing.js",
"src/celestialRingView.test.mjs",
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs",
"src/awarenessEcho.js",
"src/awarenessEcho.test.mjs",
"src/data/detectionAwarenessEcho.test.mjs",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
"src/testSupport/hudReadouts.mjs",
"src/ui/stores/firstRun.js",
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js",
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
"src/data/boundaryLabels.test.mjs",
"src/data/packGeometry.test.mjs",
"src/annotations/placeOutline.test.mjs",
"src/annotations/outlineIdentity.test.mjs",
"src/annotations/outlineRungs.test.mjs",
"src/annotations/outlineRungsFallback.test.mjs",
"src/search/photonSetting.test.mjs",
"src/sources/nominatimOutlines.test.mjs",
"src/sources/openFreeMapOutlines.test.mjs",
"src/sources/openFreeMapOutlinesEdges.test.mjs",
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs",
"server/providers/common/rateLimitKeys.test.mjs",
"src/voice/areaCandidateContract.js",
"src/voice/layerActionOwnership.js",
"src/data/areaGeometry.test.mjs",
"src/data/areaResolver.test.mjs",
"src/sources/osmFeatures.test.mjs",
"src/layers/osmPlaces/index.test.mjs",
"src/tooling/boundedCache.test.mjs",
"src/tooling/osmFeaturesRoute.test.mjs",
"src/voice/areaActions.test.mjs",
"src/voice/imageryActions.test.mjs",
"src/voice/osmActions.test.mjs",
"src/voice/realtimeProtocol.test.mjs",
"src/layers/awareness/rangeRing.test.mjs",
"src/voice/voiceMiniPreview.js",
"src/voice/voiceMiniPreview.test.mjs",
"src/voice/chatPreferences.test.mjs",
"src/voice/textChat.test.mjs",
"src/voice/chatAttachments.test.mjs",
"src/voice/chatControlView.test.mjs",
"src/textChatServer.test.mjs",
"src/geminiChatServer.test.mjs",
"src/voice/geminiTextChat.test.mjs",
"server/providers/common/textChat.js",
"server/providers/common/textInstructions.js",
"server/providers/common/textTools.js",
"server/providers/gemini/chat.js",
"server/providers/openai/chat.js",
"src/ui/styles/ai-chat.css",
"src/voice/chatAttachments.js",
"src/voice/chatControl.js",
"src/voice/chatPreferences.js",
"src/voice/geminiTextChat.js",
"src/voice/panelLayout.js",
"src/voice/textChat.js",
"src/voice/textProtocol.js"
]
+1 -1
View File
@@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) {
const files = [];
for (const name of new Set(candidates)) {
if (
!/\.[cm]?js$/.test(name) ||
!/\.([cm]?js|vue)$/.test(name) ||
/\.test\.[cm]?js$/.test(name) ||
!existsSync(path.join(root, name))
)
+66 -8
View File
@@ -8,7 +8,7 @@
// lists it.
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// plus the markup opted in with data-i18n* in index.html and the Vue views.
// It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
@@ -119,14 +119,17 @@ export function extractMessages() {
}
}
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
const markup = markupFiles();
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
for (const file of markup) {
const html = readFileSync(path.join(ROOT, file), 'utf8');
const html = readMarkup(file);
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
const where = `${file} <${tag}>`;
if (/\sdata-i18n(\s|=|$)/.test(attrs))
add(normalize(decodeHtml(text)), where);
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
const context = attrs.match(/\sdata-i18n-context="([^"]*)"/);
const message = normalize(decodeHtml(text));
add(context ? `${decodeHtml(context[1])}|${message}` : message, where);
}
for (const [, name] of attrs.matchAll(
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
)) {
@@ -153,7 +156,7 @@ const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
/**
* User-visible markup text without data-i18n*. Skipped, with everything
* inside: translate="no" (text that stays as is, as in HTML),
* data-i18n-runtime (code sets this text through t()), and icons
* data-i18n-runtime (page markup whose text code sets through t()), and icons
* (aria-hidden, Material Symbols).
*/
export function unmarkedMarkup(file, html) {
@@ -401,9 +404,12 @@ export function unmarkedScript(file, original) {
*/
export function findUnmarked() {
const found = [];
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
for (const file of markupFiles()) {
found.push(...unmarkedMarkup(file, readMarkup(file)));
}
for (const file of trackedFiles('src/**/*.vue'))
found.push(
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
...markedTemplate(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
@@ -420,6 +426,57 @@ export function findUnmarked() {
});
}
/**
* data-i18n* in a Vue template: views translate with t() as they render, so
* their text follows the locale; the attributes are for static page markup.
* Reads the whole single-file component, so lines are the file's own.
*/
export function markedTemplate(file, source) {
const start = source.indexOf('<template>');
if (start === -1) return [];
const end = source.lastIndexOf('</template>');
// Comments and attribute values are blanked, keeping every offset.
const blank = (text) => text.replace(/[^\n]/g, ' ');
const template = source
.slice(0, end === -1 ? undefined : end)
.replace(/<!--[\s\S]*?-->/g, blank);
const found = [];
const TAG = /<[a-zA-Z][\w-]*((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
TAG.lastIndex = start + '<template>'.length;
for (let tag; (tag = TAG.exec(template));) {
const attrs = tag[1].replace(/"[^"]*"|'[^']*'/g, blank);
const offset = tag.index + tag[0].length - 1 - tag[1].length;
for (const match of attrs.matchAll(
/(?<=\s)(?::|v-bind:)?(data-i18n[\w-]*)(?=[\s=/]|$)/g,
))
found.push({
where: `${file}:${lineAt(source, offset + match.index)}`,
text: `${match[1]}: use t() in a Vue template`,
});
}
return found;
}
/** Markup checked for unmarked text: the page and its views. */
function markupFiles() {
return trackedFiles('index.html', 'src/ui/views/*.vue');
}
/**
* A file's markup; for a single-file component, its template block without
* interpolations, which are code: their t() calls are read with the sources.
*/
function readMarkup(file) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
if (!file.endsWith('.vue')) return source;
const start = source.indexOf('<template>');
const end = source.lastIndexOf('</template>');
if (start === -1) return '';
return source
.slice(start + '<template>'.length, end)
.replace(/\{\{[\s\S]*?\}\}/g, '');
}
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
@@ -430,6 +487,7 @@ function sourceFiles() {
'src/*.mjs',
'src/**/*.js',
'src/**/*.mjs',
'src/**/*.vue',
).filter(
(file) =>
!file.startsWith('src/i18n/') &&
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Maintainer-only topology check and repair for the bundled outline packs.
python scripts/pack_geometry.py check census|wof every shipped feature,
decoded as a whole MultiPolygon, is valid under GEOS, keeps at least
three distinct vertices per ring, and lists its largest part first
python scripts/pack_geometry.py repair census rewrite the Census place
files so every feature passes `check` (run by build-census-places.mjs)
Geometry is checked and repaired in the packs' own integer units (10^-d
degrees), where GEOS arithmetic is exact: a vertex that lies on another edge
after rounding is seen as the touch it is.
Requires Shapely 2.1.2 on GEOS 3.13.1, pinned so repairs are reproducible.
"""
import argparse
import json
import math
import sys
from pathlib import Path
import shapely
from shapely import make_valid, set_precision
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.polygon import orient
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / 'src/data/local_data'
SHAPELY_VERSION = '2.1.2'
GEOS_VERSION = (3, 13, 1)
def require_pinned_tooling():
if shapely.__version__ != SHAPELY_VERSION or shapely.geos_version != GEOS_VERSION:
raise RuntimeError(
f'Use shapely=={SHAPELY_VERSION} on GEOS {".".join(map(str, GEOS_VERSION))}; '
f'found {shapely.__version__} on GEOS {shapely.geos_version}'
)
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
# ── encodings ────────────────────────────────────────────────────────────
def decode_deltas(encoded):
"""[x0, y0, dx1, dy1, …] → [(x, y), …] in pack units (Census/admin packs)."""
x = y = 0
ring = []
for i in range(0, len(encoded) - 1, 2):
x += encoded[i]
y += encoded[i + 1]
ring.append((x, y))
return ring
def encode_deltas(coords):
out = []
px = py = 0
for fx, fy in coords:
x, y = round(fx), round(fy)
out += [x - px, y - py]
px, py = x, y
return out
def decode_polyline(encoded):
"""Zigzag varint deltas, 5-bit groups offset by 63, lon then lat; pack units."""
values = []
last = [0, 0]
i = 0
while i < len(encoded):
n = shift = 0
while True:
b = ord(encoded[i]) - 63
i += 1
n |= (b & 31) << shift
shift += 5
if b < 32:
break
axis = len(values) % 2
last[axis] += ~(n >> 1) if n & 1 else n >> 1
values.append(last[axis])
return list(zip(values[::2], values[1::2]))
def encode_polyline(coords):
out = []
last = [0, 0]
for x, y in coords:
for i, value in enumerate([round(x), round(y)]):
n = value - last[i]
last[i] = value
n = (n << 1) ^ (n >> 63)
while n >= 32:
out.append(chr((32 | (n & 31)) + 63))
n >>= 5
out.append(chr(n + 63))
return ''.join(out)
# ── geometry ─────────────────────────────────────────────────────────────
def polygon_parts(geometry):
if geometry.geom_type == 'Polygon':
return [] if geometry.is_empty else [geometry]
if hasattr(geometry, 'geoms'):
return [p for g in geometry.geoms for p in polygon_parts(g)]
return []
def open_ring(coords):
coords = [tuple(c[:2]) for c in coords]
return coords[:-1] if len(coords) > 1 and coords[0] == coords[-1] else coords
def ordered_parts(geometry):
"""Polygon parts oriented (outer counter-clockwise), largest area first."""
parts = [orient(p, 1.0) for p in polygon_parts(geometry)]
return sorted(parts, key=lambda p: (-p.area, p.bounds))
def on_grid(geometry):
"""Snap to whole pack units keeping the result valid; polygon parts only."""
snapped = set_precision(geometry, 1, mode='valid_output')
return MultiPolygon(polygon_parts(snapped))
def settle(geometry):
"""A valid MultiPolygon of `geometry` (in pack units) on whole units."""
if not geometry.is_valid:
geometry = make_valid(geometry)
geometry = on_grid(MultiPolygon(polygon_parts(geometry)))
for _ in range(3):
if geometry.is_valid:
break
geometry = on_grid(make_valid(geometry))
return geometry
def rings_ok(polygons):
return all(len(set(ring)) >= 3 for poly in polygons for ring in poly)
def problems(polygons):
"""Why decoded `polygons` ([[outer, *holes], …]) fail the pack contract."""
if not polygons or not rings_ok(polygons):
return 'ring with fewer than three distinct vertices'
try:
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons])
except Exception as error: # noqa: BLE001 - malformed input is a finding
return f'unbuildable: {error}'
if not geometry.is_valid:
return shapely.is_valid_reason(geometry)
areas = [part.area for part in geometry.geoms]
if max(areas) > areas[0]:
return 'largest part is not first'
return None
# ── packs ────────────────────────────────────────────────────────────────
def census_features():
folder = DATA / 'us_census_places'
decimals = json.loads((folder / 'index.json').read_text())['meta']['decimals']
for path in sorted(folder.glob('[A-Z][A-Z].json')):
pack = json.loads(path.read_text(encoding='utf-8'))
yield path, pack, decimals
def census_polygons(feature):
return [[decode_deltas(r) for r in poly] for poly in feature['polygons']]
def census_degrees(feature, decimals):
factor = 10 ** feature.get('d', decimals)
return [[[(x / factor, y / factor) for x, y in ring]
for ring in poly] for poly in census_polygons(feature)]
def ring_contains(ring, lat, lon):
"""Match adminBoundaries.pointInRing on final degree-decoded vertices."""
inside = False
for i in range(len(ring)):
xi, yi = ring[i]
xj, yj = ring[i - 1]
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
inside = not inside
return inside
def label_is_filled(polygons, label):
if not isinstance(label, (list, tuple)) or len(label) != 2:
return False
lon, lat = label
if not all(isinstance(n, (int, float)) and math.isfinite(n) for n in label):
return False
return any(ring_contains(poly[0], lat, lon)
and not any(ring_contains(hole, lat, lon) for hole in poly[1:])
for poly in polygons)
def census_label(feature, polygons):
"""The effective runtime label, including its area-weighted fallback."""
if isinstance(feature.get('label'), list):
return feature['label']
ring = polygons[0][0]
area = cx = cy = 0
for i in range(len(ring)):
x0, y0 = ring[i - 1]
x1, y1 = ring[i]
cross = x0 * y1 - x1 * y0
area += cross
cx += (x0 + x1) * cross
cy += (y0 + y1) * cross
return [cx / (3 * area), cy / (3 * area)] if area else list(ring[0])
def wof_features():
folder = DATA / 'wof_neighborhoods'
index = json.loads((folder / 'index.json').read_text(encoding='utf-8'))
for tile in index['tiles']:
pack = json.loads((folder / f'{tile["key"]}.json').read_text(encoding='utf-8'))
for feature in pack['features']:
yield feature, index['meta']['precision']
def check(pack):
bad = []
total = 0
if pack == 'census':
for _, data, decimals in census_features():
for feature in data['features']:
total += 1
why = problems(census_polygons(feature))
if not why:
polygons = census_degrees(feature, decimals)
if not label_is_filled(polygons, census_label(feature, polygons)):
why = 'effective label is outside filled geometry'
if why:
bad.append((feature['geoid'], feature['name'], why))
else:
for feature, precision in wof_features():
total += 1
polygons = [[decode_polyline(r) for r in poly] for poly in feature[7]]
why = problems(polygons)
if why:
bad.append((feature[0], feature[1], why))
for row in bad[:20]:
print('invalid', *row)
print(f'{pack}: {total} features, {len(bad)} failing')
return not bad
def repair_census():
repaired = 0
labelled = 0
for path, data, decimals in census_features():
changed = False
for feature in data['features']:
polygons = census_polygons(feature)
if problems(polygons):
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons if len(set(p[0])) >= 3])
parts = ordered_parts(settle(geometry))
feature['polygons'] = [
[encode_deltas(open_ring(p.exterior.coords))]
+ [encode_deltas(open_ring(r.coords)) for r in p.interiors]
for p in parts
]
feature.pop('label', None)
polygons = census_polygons(feature)
why = problems(polygons)
if why:
raise ValueError(f'{feature["geoid"]} {feature["name"]}: still {why}')
repaired += 1
changed = True
decoded = census_degrees(feature, decimals)
if label_is_filled(decoded, census_label(feature, decoded)):
continue
factor = 10 ** feature.get('d', decimals)
for poly in polygons:
point = Polygon(poly[0], poly[1:]).representative_point()
label = [point.x / factor, point.y / factor]
if label_is_filled(decoded, label):
feature['label'] = label
labelled += 1
changed = True
break
else:
raise ValueError(f'{feature["geoid"]} {feature["name"]}: no verified filled label')
if changed:
path.write_text(compact(data) + '\n', encoding='utf-8')
print(f'us_census_places: {repaired} topology repairs, {labelled} verified interior labels added')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('command', choices=['check', 'repair'])
parser.add_argument('pack', choices=['census', 'wof'])
args = parser.parse_args()
require_pinned_tooling()
if args.command == 'repair':
if args.pack != 'census':
parser.error('the WOF build repairs its own geometry')
repair_census()
sys.exit(0 if check('census') else 1)
sys.exit(0 if check(args.pack) else 1)
if __name__ == '__main__':
main()
File diff suppressed because it is too large. Load diff
+2
View File
@@ -19,6 +19,8 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
'LL2_API_TOKEN',
'GEV_RATELIMIT_OPENAI_PER_MIN',
'GEV_RATELIMIT_GOOGLE_PER_MIN',
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
'PINOKIO_SHARE_CLOUDFLARE',
'PINOKIO_SHARE_LOCAL',
'PINOKIO_SHARE_VAR',
+46 -35
View File
@@ -1603,7 +1603,7 @@ try {
cockpit.setVisionMode('optical');
current.focus({ preventScroll: true });
const states = [read()];
for (let index = 0; index < 7; index += 1) {
for (let index = 0; index < 6; index += 1) {
current.click();
states.push(read());
}
@@ -1637,36 +1637,36 @@ try {
reentered,
};
});
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'snow', 'optical'];
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'SNOW', 'NORMAL'];
const cycleModes = ['optical', 'crt', 'nvg', 'thermal', 'anime', 'noir', 'optical'];
const cycleLabels = ['NORMAL', 'CRT', 'NVG', 'FLIR', 'ANIME', 'NOIR', 'NORMAL'];
check(
'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime, Noir and Snow before wrapping',
'Cockpit vision control exposes Normal, CRT, NVG, FLIR, Anime and Noir before wrapping',
JSON.stringify(visionCycle.states.map((state) => state.mode)) === JSON.stringify(cycleModes)
&& JSON.stringify(visionCycle.states.map((state) => state.label)) === JSON.stringify(cycleLabels)
&& visionCycle.states[5]?.aria === 'Current cockpit vision style: Noir. Activate for next style.'
&& visionCycle.states[7]?.aria === 'Current cockpit vision style: Normal. Activate for next style.',
&& visionCycle.states[6]?.aria === 'Current cockpit vision style: Normal. Activate for next style.',
JSON.stringify(visionCycle.states.map(({ mode, label, aria }) => ({ mode, label, aria }))),
);
check(
'user vision changes open Display for every temporary treatment and retain selector focus',
visionCycle.states.slice(1, 7).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 7).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 7).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 7).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 7).every((state) => state.signalCollapsed)
visionCycle.states.slice(1, 6).every((state) => state.displayOpen === 'true')
&& visionCycle.states.slice(1, 6).every((state) => state.radioOpen === 'false')
&& visionCycle.states.slice(1, 6).every((state) => state.parametersActive)
&& visionCycle.states.slice(1, 6).every((state) => !state.parametersCollapsed)
&& visionCycle.states.slice(1, 6).every((state) => state.signalCollapsed)
&& visionCycle.states.every((state) => state.focusRetained),
JSON.stringify(visionCycle.states),
);
check(
'each Cockpit vision mode drives only its own shader and Normal clears them all',
visionCycle.states.slice(1, 7).every((state) => {
visionCycle.states.slice(1, 6).every((state) => {
const target = {
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir', snow: 'snow',
crt: 'retro', nvg: 'surveillance', thermal: 'thermal', anime: 'anime', noir: 'noir',
}[state.mode];
return Object.entries(state.intensities).every(([name, value]) =>
name === target ? value > 0 : value === 0);
})
&& Object.values(visionCycle.states[7].intensities).every((value) => value === 0),
&& Object.values(visionCycle.states[6].intensities).every((value) => value === 0),
JSON.stringify(visionCycle.states.map(({ mode, intensities }) => ({ mode, intensities }))),
);
check(
@@ -1720,6 +1720,9 @@ try {
const priorSignalMaxHeight = signal.style.maxHeight;
signal.style.maxHeight = '150px';
manager._setCockpitDisclosure(kind, true);
// Let Vue render the disclosure, but not the scheduled layout frame: the
// first syncSignalLayout below must be the first pass.
await Promise.resolve();
// One pass must be enough. Record the result of the FIRST layout call, then
// confirm a second changes nothing, so a convergence regression cannot hide
// behind a repeated call.
@@ -1751,7 +1754,7 @@ try {
return state;
}, { variantName: variant, kind: openKind });
for (const variant of ['off', 'minimal', 'tactical', 'operator']) {
for (const variant of ['off', 'minimal', 'tactical']) {
for (const kind of ['display', 'radio']) {
const state = await desktopState(variant, kind);
check(
@@ -1796,7 +1799,7 @@ try {
manager._setCockpitDisplayPortalActive(false);
document.body.classList.remove('cockpit-mode');
await waitFrames();
standard.classList.remove('collapsed');
manager._presentPanel(standard, { collapsed: false });
standard.style.height = '120px';
standard.style.maxHeight = '120px';
standard.style.overflowY = 'auto';
@@ -1831,7 +1834,7 @@ try {
cockpit.style.height = priorCockpitHeight;
cockpit.style.maxHeight = priorCockpitMaxHeight;
cockpit.style.overflowY = priorCockpitOverflow;
standard.classList.toggle('collapsed', standardWasCollapsed);
manager._presentPanel(standard, { collapsed: standardWasCollapsed });
return {
standardBefore,
standardAfter,
@@ -1851,7 +1854,7 @@ try {
JSON.stringify(portalScroll),
);
const boundary = await page.evaluate(() => {
const boundary = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const hud = document.getElementById('cockpit-hud');
const signal = document.getElementById('cockpit-signal-stream');
@@ -1866,6 +1869,7 @@ try {
signal.hidden = false;
manager._setCockpitDisclosure('radio', false);
manager._setCockpitDisclosure('display', true);
await new Promise(requestAnimationFrame);
const priorSignalMaxHeight = signal.style.maxHeight;
const priorSignalHeight = signal.style.height;
// The strip is pinned to the viewport ceiling once the briefing card grows
@@ -2081,9 +2085,9 @@ try {
await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) });
}
}
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator'));
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical'));
const signalTransition = await page.evaluate(() => {
const signalTransition = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const cockpit = manager.cockpitView;
const signalToggle = document.getElementById('cockpit-signal-toggle');
@@ -2110,6 +2114,8 @@ try {
manager._setCockpitDisclosure('radio', true);
manager._setCockpitDisclosure('radio', false);
const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed;
// The toggle renders the published collapse on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
afterExpansion,
afterRepeatedExpansion,
@@ -2136,7 +2142,7 @@ try {
&& signalTransition.signalAriaExpanded === 'false',
JSON.stringify(signalTransition),
);
const contextTransition = await page.evaluate(() => {
const contextTransition = await page.evaluate(async () => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
const priorCollapsed = cockpit.contextCollapsed;
const contextToggle = document.getElementById('cockpit-context-toggle');
@@ -2148,6 +2154,8 @@ try {
cockpit.setContextCollapsed(false);
const afterExpansion = expandedEvents;
cockpit.setContextCollapsed(false);
// The toggle renders the published collapse on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
afterExpansion,
afterRepeatedExpansion: expandedEvents,
@@ -2168,33 +2176,34 @@ try {
JSON.stringify(contextTransition),
);
// Use the real connected DOM to catch focus loss caused by moving live rows.
const signalFocus = await page.evaluate(() => {
const signalFocus = await page.evaluate(async () => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
// The rows render on Vue's next update; a frame is past it.
const show = async (items) => {
cockpit.signalItems = items;
cockpit.publishCockpitSignals();
await new Promise(requestAnimationFrame);
};
const originalItems = cockpit.signalItems;
const wasCollapsed = cockpit.signalCollapsed;
cockpit.setSignalCollapsed(false);
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
try {
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-a'), item('focus-b')]);
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
button.focus();
const acquired = document.activeElement === button;
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
cockpit.renderCockpitSignals();
await show([item('focus-c'), item('focus-b'), item('focus-a')]);
const retained = document.activeElement === button && button.isConnected;
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
cockpit.signalItems = [item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-b')]);
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
const continued = document.activeElement === continuation;
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-a'), item('focus-b')]);
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
} finally {
cockpit.signalItems = originalItems;
cockpit.renderCockpitSignals();
await show(originalItems);
cockpit.setSignalCollapsed(wasCollapsed);
}
});
@@ -2260,12 +2269,14 @@ try {
signal.hidden = false;
manager._setCockpitDisclosure('display', false);
manager._setCockpitDisclosure('radio', false);
await new Promise(requestAnimationFrame);
manager.cockpitView.syncSignalLayout();
const collapsed = {
display: displayControl.getBoundingClientRect().toJSON(),
radio: radioControl.getBoundingClientRect().toJSON(),
};
display.click();
await new Promise(requestAnimationFrame);
manager.cockpitView.syncSignalLayout();
return {
collapsed,
@@ -2317,13 +2328,13 @@ try {
const styleManager = window.__godsEyeView.styleManager;
const styleBefore = styleManager.activeStyle;
// A Cockpit-only choice that Reset must undo.
styleManager.cockpitView.setVisionMode('snow');
styleManager.cockpitView.setVisionMode('anime');
window.__qaCockpitReset = {
calls: 0,
original: manager.resetToGlobeView,
subjectId: awareness?.getContextSnapshot?.()?.subject?.id || null,
styleBefore,
snowDuringCockpit: styleManager.stages?.snow?.uniforms?.intensity,
animeDuringCockpit: styleManager.stages?.anime?.uniforms?.intensity,
};
manager.resetToGlobeView = function qaCountedCockpitReset(...args) {
window.__qaCockpitReset.calls += 1;
@@ -2360,7 +2371,7 @@ try {
height,
subjectPreserved: Boolean(qa.subjectId && subjectId === qa.subjectId),
styleRestored: gev.styleManager.activeStyle === qa.styleBefore,
snowDuringCockpit: qa.snowDuringCockpit,
animeDuringCockpit: qa.animeDuringCockpit,
// Cockpit-time intensities are Cockpit's own, so check against the map preset.
visionRestored: Object.entries(gev.styleManager.stages || {}).every(([name, stage]) =>
name === gev.styleManager.activeStyle
@@ -2376,7 +2387,7 @@ try {
&& resetState.resetHidden
&& Math.abs(resetState.height - 18_000_000) < 150_000
&& resetState.subjectPreserved
&& resetState.snowDuringCockpit > 0
&& resetState.animeDuringCockpit > 0
&& resetState.styleRestored
&& resetState.visionRestored,
JSON.stringify(resetState),
+12 -2
View File
@@ -474,12 +474,22 @@ async function contacts() {
// SEARCH NEARBY SITES, as the user presses it.
await page.evaluate(() => {
document.getElementById('toast').textContent = '';
const toast = document.getElementById('toast');
const before = toast.firstChild;
document.getElementById('installations-search-btn').click();
window.__qaToastBefore = before;
});
// Every toast show writes a new text node, even for a repeated message.
await page
.waitForFunction(
() => document.getElementById('toast').textContent.trim().length > 0,
() => {
const toast = document.getElementById('toast');
return (
toast.classList.contains('visible') &&
toast.firstChild !== window.__qaToastBefore &&
toast.textContent.trim().length > 0
);
},
{ timeout: 20_000 },
)
.catch(() => {});
+12 -10
View File
@@ -107,7 +107,7 @@ const layerState = () =>
const manager = gev.dataManager;
const module = manager.layers.get('directions').module;
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const controls = module.getRowControls();
const viewer = gev.viewer;
@@ -152,7 +152,7 @@ const layerState = () =>
const clickChip = async (chipId) => {
const clicked = await page.evaluate((id) => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
if (!button || button.disabled) return false;
@@ -232,24 +232,26 @@ try {
});
await sleep(3000);
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
const opened = await page.evaluate(() => {
const panel = document.getElementById('data-panel');
const panel = document.getElementById('utilities-panel');
if (!panel) return false;
if (panel.classList.contains('collapsed')) {
panel
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
}
return !document
.getElementById('data-panel')
.getElementById('utilities-panel')
.classList.contains('collapsed');
});
await sleep(700);
check('the DATA LAYERS panel opens from its own header control', opened);
check('the UTILITIES panel opens from its own header control', opened);
const toggled = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector('.data-toggle-btn');
if (!button) return false;
@@ -397,7 +399,7 @@ try {
);
const focused = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const items = row.querySelectorAll('.data-row-list-item');
items[2]?.focus();
@@ -776,7 +778,7 @@ try {
}
}
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
return {
entities: left.length,
+3
View File
@@ -427,6 +427,9 @@ try {
shot.interactions = [];
fields[0].value = JSON.stringify(scene);
fields[1].value = JSON.stringify(shot);
// Typing fires input; the dialog reads its fields from those events.
for (const field of fields)
field.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.click('[data-director-apply-details]');
await page.waitForFunction(
+64
View File
@@ -88,6 +88,70 @@ try {
};
});
for (const [name, passed] of Object.entries(result)) check(name, passed);
const visibilityRestored = await page.evaluate(async () => {
// QA layer registration exists only in Vite dev builds.
if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped';
const { sceneDirector: director, dataManager } = window.__godsEyeView;
const layerId = 'director-seek-visibility-qa';
window.__gevQaRegisterLayer(dataManager, {
id: layerId,
name: 'Director seek visibility QA',
updateInterval: -1,
init() {},
enable() {},
disable() {},
update() {},
destroy() {},
getStats: () => ({ count: 0 }),
});
const scene = {
id: 'visibility-qa',
title: 'Visibility QA',
shots: [
{
id: 'visibility-shot',
title: 'Visibility shot',
durationSec: 0.4,
holdSec: 0,
camera: director.styleManager.getCameraState(),
visual: director.styleManager.getVisualState(),
layers: { [layerId]: { enabled: true } },
},
],
};
director._project.scenes.push(scene);
try {
const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
flyDuration: 0.2,
});
const disabled =
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
!dataManager.layers.get(layerId).enabled;
const sought = await director.seekScene(scene.id, 0.5);
return (
loaded?.started &&
disabled &&
sought &&
dataManager.layers.get(layerId).enabled
);
} finally {
director.stopScene();
director._project.scenes = director._project.scenes.filter(
({ id }) => id !== scene.id,
);
await window.__gevQaUnregisterLayer(dataManager, layerId);
}
});
if (visibilityRestored === 'skipped') {
console.log(
'[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)',
);
} else {
check(
'same-shot seek restores manually changed layer visibility',
visibilityRestored,
);
}
const content = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes.find(({ title }) =>
+198 -25
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
/** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
@@ -58,6 +58,47 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A clip of the globe canvas, clear of the panels on every side. */
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
// Clear of the open UTILITIES panel (x 100–380), whose own amber swatch
// dims when its row's shape is hidden.
const LINE_CLIP = { x: 390, y: 200, width: 510, height: 380 };
/** The canvas clip as a PNG, for comparing two moments of the same view. */
const canvasShot = () =>
page.screenshot({ clip: LINE_CLIP, type: 'png', encoding: 'base64' });
/**
* Amber pixels in one shot that are not amber in another of the same view:
* a mark's own pixels, apart from imagery and panels that both shots share.
*/
function amberOnlyIn(shotA, shotB) {
return page.evaluate(
async (a, b) => {
const pixels = async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
};
const amber = (d, i) =>
d[i] > 150 &&
d[i + 1] > 90 &&
d[i + 1] < 220 &&
d[i + 2] < 130 &&
d[i] - d[i + 2] > 70 &&
d[i] - d[i + 1] > 20;
const [da, db] = [await pixels(a), await pixels(b)];
let only = 0;
for (let i = 0; i < da.length; i += 4)
if (amber(da, i) && !amber(db, i)) only += 1;
return only;
},
shotA,
shotB,
);
}
/**
* How many amber pixels the clip contains. The drawn area's stroke and fill are
* the only amber in this scene, so the count going UP after a shape is finished
@@ -196,12 +237,14 @@ try {
);
check('visible tile content settles before drawing', settled);
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
// UTILITIES starts collapsed on a first run: open it the way a user does.
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
@@ -210,7 +253,7 @@ try {
{ timeout: 10_000 },
);
check(
'the Draw control is on the DISPLAY rail and starts off',
'the Draw control is in the UTILITIES panel and starts off',
await page.evaluate(() => {
const toggle = document.getElementById('draw-toggle');
return (
@@ -298,8 +341,6 @@ try {
// ── a pin, finished with Enter ──────────────────────────────────────────
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="pin"]');
await page.click('#draw-label-input');
await page.keyboard.type('Pin one');
await clickWorld(520, 330);
check(
'the pointer is claimed while a session is open',
@@ -313,7 +354,8 @@ try {
// ── a line along a road, with Backspace ─────────────────────────────────
await page.click('.pp-mode-btn[data-shape="line"]');
await clickWorld(360, 300);
// Clear of the open UTILITIES panel, which sits beside the left rail.
await clickWorld(420, 300);
await clickWorld(470, 380);
await clickWorld(600, 470);
const beforeBackspace = (await drawState()).vertices;
@@ -326,16 +368,9 @@ try {
`${beforeBackspace} → ${afterBackspace}`,
);
await clickWorld(640, 500);
await page.click('#draw-label-input');
await page.keyboard.type('Market St');
await page.keyboard.press('Enter');
check(
'a line finished from the label field lands on the board',
await waitForMarks(2),
);
const lineMark = (await marks()).find((mark) =>
mark.label?.startsWith('Market St'),
);
check('a line finished with Enter lands on the board', await waitForMarks(2));
const lineMark = (await marks()).find((mark) => mark.type === 'route');
check(
'a drawn line reports its length and never a travel time',
Boolean(lineMark) &&
@@ -347,10 +382,8 @@ try {
// ── an area, coloured and labelled, finished with a double-click ────────
await page.click('.pp-mode-btn[data-shape="area"]');
await page.select('#draw-color-select', 'amber');
await page.click('.draw-color[data-color="amber"]');
const amberBefore = await amberPixels();
await page.click('#draw-label-input');
await page.keyboard.type('Civic Center block');
await clickWorld(400, 280);
await clickWorld(700, 290);
await clickWorld(720, 520);
@@ -373,14 +406,34 @@ try {
afterDoubleClick === 3,
`${afterDoubleClick} marks`,
);
// ── each drawn shape gets a row: named, recoloured, hidden from there ──
const rowIds = () =>
page.evaluate(() =>
[...document.querySelectorAll('#draw-mark-list [data-mark-id]')].map(
(row) => row.dataset.markId,
),
);
const ids = await rowIds();
check('every drawn shape has a row under Draw', ids.length === 3, ids);
const areaId = ids[2];
const rowControl = (id, control) =>
`#draw-mark-list [data-mark-id="${id}"] [data-control="${control}"]`;
await page.click(rowControl(areaId, 'name'));
await page.keyboard.type('Civic Center block');
await page.keyboard.press('Enter');
await wait(300);
const areaMark = (await marks()).find(
(mark) => mark.label === 'Civic Center block',
);
check(
'the label and colour chosen in the panel are what is drawn',
'the name typed in its row and the colour picked before drawing are what is drawn',
areaMark?.color === 'amber',
JSON.stringify(areaMark),
);
check(
'naming a shape keeps Draw on and the shape in progress untouched',
(await drawState()).active === true,
);
await wait(1200);
const amberAfter = await amberPixels();
check(
@@ -389,6 +442,118 @@ try {
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
);
await shot('01-area-with-label');
// ── the row's own controls: recolour, hide and show, fly to ─────────────
const markState = (id) =>
page.evaluate((id) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark && { color: mark.color, hidden: mark.hidden, alpha: mark.alpha }
);
}, id);
// Wait for a mark to reach a state rather than for a fixed time.
const markReaches = (id, want) =>
page
.waitForFunction(
(id, want) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark &&
Object.entries(want).every(([key, value]) => mark[key] === value)
);
},
{ timeout: 10_000 },
id,
want,
)
.then(
() => true,
() => false,
);
await page.click(rowControl(areaId, 'swatch'));
await page.click(rowControl(areaId, 'color-green'));
await markReaches(areaId, { color: 'green' });
check(
'a colour picked in the row recolours that shape',
(await markState(areaId))?.color === 'green',
);
await page.click(rowControl(areaId, 'toggle'));
await markReaches(areaId, { hidden: true, alpha: 0 });
const hidden = await markState(areaId);
check(
'hiding a shape keeps it on the board but draws it at zero alpha',
hidden?.hidden === true && hidden.alpha === 0,
JSON.stringify(hidden),
);
await page.click(rowControl(areaId, 'toggle'));
check(
'showing it again brings it straight back',
await markReaches(areaId, { hidden: false, alpha: 1 }),
);
// Fly-to frames the line by its whole path: once the flight settles, the
// middle of the line sits near the middle of the view.
await page.click(rowControl(ids[1], 'fly'));
await page
.waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, {
timeout: 15_000,
})
.catch(() => {});
await wait(300);
const framed = await page.evaluate((id) => {
const { viewer } = window.__godsEyeView;
// No Cesium global in the page: borrow Cartesian3 from a live object.
const Cartesian3 = viewer.camera.positionWC.constructor;
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
const lons = mark.path.map((p) => p.lon);
const lats = mark.path.map((p) => p.lat);
const middle = Cartesian3.fromDegrees(
(Math.min(...lons) + Math.max(...lons)) / 2,
(Math.min(...lats) + Math.max(...lats)) / 2,
);
const at = viewer.scene.cartesianToCanvasCoordinates(middle);
const canvas = viewer.scene.canvas;
return (
at && {
dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth,
dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight,
}
);
}, ids[1]);
check(
'fly to frames that shape: its middle is near the middle of the view',
Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2,
JSON.stringify(framed),
);
// ── a hidden Line leaves the globe: its flowing stroke keeps its own
// colour, so the check is the pixels, not the mark's state ─────────────
const lineId = ids[1];
await page.click(rowControl(lineId, 'swatch'));
await page.click(rowControl(lineId, 'color-amber'));
await markReaches(lineId, { color: 'amber', alpha: 1 });
await wait(800);
const shown = await canvasShot();
await page.click(rowControl(lineId, 'toggle'));
await markReaches(lineId, { hidden: true, alpha: 0 });
await wait(800);
const hiddenShot = await canvasShot();
// Amber the shown line had and the hidden one lost; amber that appears
// only once it is hidden is noise, and must stay small beside it.
const gone = await amberOnlyIn(shown, hiddenShot);
const noise = await amberOnlyIn(hiddenShot, shown);
check(
'a hidden Line is no longer painted',
gone > 40 && noise < gone * 0.2,
`${gone} amber pixels went with the line, ${noise} appeared`,
);
await page.click(rowControl(lineId, 'toggle'));
await wait(1200);
const back = await amberOnlyIn(await canvasShot(), hiddenShot);
check(
'and paints again when shown',
back > gone * 0.5,
`${back} of ${gone} line pixels back`,
);
await shot('03-pins-and-shapes');
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
@@ -419,6 +584,12 @@ try {
afterSecondEscape.marks === 3,
`${afterSecondEscape.marks} marks`,
);
await page.click(rowControl(ids[0], 'remove'));
await waitForMarks(2);
check(
'remove in a row takes that one shape off the board and out of the list',
(await rowIds()).join() === ids.slice(1).join(),
);
// ── a click that would select something selects nothing while drawing ───
const target = await findClickableTarget();
@@ -480,10 +651,12 @@ try {
// ── Clear wipes the board ───────────────────────────────────────────────
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
+100 -59
View File
@@ -25,12 +25,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
const TESTS = [
'src/firstRunExperience.test.mjs',
'src/standalone/startupChrome.test.mjs',
'src/firstRunLauncherView.test.mjs',
];
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
copy: path.join(ROOT, 'src', 'firstRunCopy.js'),
surfaces: path.join(ROOT, 'src', 'exclusiveSurfaces.js'),
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
voiceDescriptions: path.join(
@@ -53,7 +59,8 @@ const MUTATIONS = [
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
},
{
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
defect:
'the checkbox is ignored, so nothing can ever suppress the launcher',
file: 'module',
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
to: ' ? false',
@@ -77,7 +84,8 @@ const MUTATIONS = [
to: ' // durable check removed',
},
{
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
defect:
'?welcome=1 no longer outranks suppression, so demos cannot replay it',
file: 'module',
from: "if (params.get('welcome') === '1') return true;",
to: '// replay hatch removed',
@@ -139,8 +147,9 @@ const MUTATIONS = [
to: ' /* observer never attached */',
},
{
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
file: 'module',
defect:
'cockpit drops off the exclusive list and can stack with the launcher',
file: 'surfaces',
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
},
@@ -157,45 +166,52 @@ const MUTATIONS = [
to: 'dismiss();',
},
{
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
defect:
'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
file: 'module',
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
to: ' root.getClientRects().length > 0;',
},
{
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
defect:
'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
file: 'module',
from: ' return Boolean(hit) && !root.contains(hit);',
to: ' return !root.contains(hit);',
},
{
defect: 'the launcher stops honouring a key another surface already claimed',
defect:
'the launcher stops honouring a key another surface already claimed',
file: 'keyboard',
from: ' || event.defaultPrevented',
to: ' /* belt removed */',
},
{
// Anchored past the call so it cannot land on the cockpit disclosure's own
// stopImmediatePropagation() a few lines below and prove nothing about this.
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
// Anchored past the call so it lands on the Cockpit utility disclosure's
// Escape claim and nowhere else.
defect:
'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
file: 'radio',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
defect:
'the "no timer" decision is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
to: ' * (note removed)',
},
{
defect: 'a bounded reveal timer punches the card through a recording in progress',
defect:
'a bounded reveal timer punches the card through a recording in progress',
file: 'module',
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
},
{
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
defect:
'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
file: 'docs',
from: 'a surface class that never clears means no launcher for that page',
to: 'the launcher always turns up eventually for that page',
@@ -203,10 +219,11 @@ const MUTATIONS = [
// ── The checkbox may not promise what storage refused ─────────────────────
{
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
defect:
'a refused write leaves the box ticked, promising a suppression nobody stored',
file: 'module',
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
from: ' if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;',
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
},
{
defect: 'a missing storage area reports the write as saved',
@@ -215,14 +232,16 @@ const MUTATIONS = [
to: ' store?.setItem?.(key, value);\n return true;',
},
{
defect: 'the scroll fade promises more list on a card where everything fits',
defect:
'the scroll fade promises more list on a card where everything fits',
file: 'module',
from: 'choiceList.dataset.scrollable = String(overflows);',
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
from: 'present({ scrollable: String(overflows) });',
to: "present({ scrollable: 'true' }); void overflows;",
},
{
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
defect:
'the DISPLAY rail opens on first run again, stealing the impression',
file: 'ui',
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
@@ -236,13 +255,15 @@ const MUTATIONS = [
// ── Mission definitions ────────────────────────────────────────────────────
{
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
defect:
'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
file: 'module',
from: ' environmental: Object.freeze({',
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
},
{
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
defect:
'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
file: 'module',
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
to: "layerIds: Object.freeze(['local-firms']),",
@@ -256,11 +277,11 @@ const MUTATIONS = [
{
defect: 'the tile stops promising the fires it actually turns on',
file: 'html',
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
from: " 'Live earthquakes and active fires, from USGS and NASA',",
to: " 'Live earthquakes worldwide, straight from USGS',",
},
{
defect: "the owner-authored first-run line is quietly rewritten",
defect: 'the owner-authored first-run line is quietly rewritten',
file: 'html',
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
@@ -302,21 +323,24 @@ const MUTATIONS = [
to: 'if (!mission) return { ok: true, choice };',
},
{
defect: 'the fires/quakes tile name stops being switchable from one constant',
file: 'module',
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
defect:
'the fires/quakes tile name stops being switchable from one constant',
file: 'copy',
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
to: 'ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
},
// ── Defaults interplay (the ruling this whole integration turns on) ────────
{
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
defect:
'mission layer enables stop persisting, unlike the clicks they stand for',
file: 'module',
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
to: 'dataManager.setEnabled(layerId, true)',
},
{
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
defect:
'a mission hand-edits detection and kills the style auto-preset contract',
file: 'module',
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
@@ -328,13 +352,15 @@ const MUTATIONS = [
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
},
{
defect: 'the Context panel is left collapsed, hiding the mode that just started',
defect:
'the Context panel is left collapsed, hiding the mode that just started',
file: 'module',
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
to: ' // panel reveal removed',
},
{
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
defect:
'the decision table is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
to: ' * (notes removed)',
@@ -342,10 +368,11 @@ const MUTATIONS = [
// ── Accessibility and the transplant defects ──────────────────────────────
{
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
to: ' for (const button of buttons) button.disabled = next;',
defect:
'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'html',
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
@@ -384,19 +411,22 @@ const MUTATIONS = [
to: 'body.never-matches-anything #first-run-launcher,',
},
{
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
defect:
'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
file: 'css',
from: '#first-run-launcher[hidden] {\n display: none;\n}',
to: '/* [hidden] rule removed */',
},
{
defect: 'the card loses its viewport cap and clips on a short landscape phone',
defect:
'the card loses its viewport cap and clips on a short landscape phone',
file: 'css',
from: ' max-height: calc(100dvh - 1.5rem);',
to: ' /* cap removed */',
},
{
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
defect:
'the mission list stops scrolling, so tiles overflow the capped card',
file: 'css',
from: ' min-height: 0;\n overflow-y: auto;',
to: ' /* scroll removed */',
@@ -415,19 +445,19 @@ const MUTATIONS = [
{
defect: 'a mission tile is dropped from the menu',
file: 'html',
from: ' <button type="button" data-first-run-choice="space-missions">',
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
from: ' <button type="button" data-first-run-choice="space-missions"',
to: ' <button type="button" data-first-run-choice-disabled="space-missions"',
},
{
defect: 'the menu order stops matching the owner\'s',
defect: "the menu order stops matching the owner's",
file: 'html',
from: '<button type="button" data-first-run-choice="environmental">',
to: '<button type="button" data-first-run-choice="zzz-environmental">',
from: '<button type="button" data-first-run-choice="environmental"',
to: '<button type="button" data-first-run-choice="zzz-environmental"',
},
{
defect: 'the "don\'t show again" checkbox is removed',
file: 'html',
from: '<input type="checkbox" data-first-run-suppress />',
from: '<input type="checkbox" data-first-run-suppress @change="onSuppress" />',
to: '<span data-first-run-suppress-removed></span>',
},
{
@@ -439,8 +469,8 @@ const MUTATIONS = [
{
defect: 'the launcher is no longer announced as a dialog',
file: 'html',
from: '<aside id="first-run-launcher" role="dialog"',
to: '<aside id="first-run-launcher"',
from: 'id="first-run-launcher" role="dialog"',
to: 'id="first-run-launcher"',
},
{
defect: 'the launcher is revealed before the loading cover yields',
@@ -449,7 +479,8 @@ const MUTATIONS = [
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
},
{
defect: 'the globe missions lose their DataManager and cannot enable layers',
defect:
'the globe missions lose their DataManager and cannot enable layers',
file: 'main',
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
to: 'firstRun = initializeWelcome?.({ styleManager });',
@@ -463,7 +494,8 @@ const MUTATIONS = [
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
defect:
'the instruction mapping is dropped, so voice cannot reach the modes',
file: 'voiceInstructions',
from: " 'NAMED VIEWS are shorthand",
to: " // 'NAMED VIEWS are shorthand",
@@ -471,21 +503,25 @@ const MUTATIONS = [
];
const originals = new Map();
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
for (const [key, file] of Object.entries(FILES))
originals.set(key, fs.readFileSync(file, 'utf8'));
/** Write only on a real change: a no-op write still wakes every file watcher. */
const write = (file, next) => {
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
};
const restoreAll = () => {
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
for (const [key, file] of Object.entries(FILES))
write(file, originals.get(key));
};
process.on('exit', restoreAll);
let caught = 0;
const missed = [];
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
console.log(
`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`,
);
for (const { defect, file, from, to } of MUTATIONS) {
const original = originals.get(file);
if (!original.includes(from)) {
@@ -497,10 +533,15 @@ for (const { defect, file, from, to } of MUTATIONS) {
let red = false;
let by = '';
try {
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
execFileSync(
'node',
['--import=./build/registerVue.js', '--test', ...TESTS],
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' },
);
} catch (error) {
red = true;
const failed = String(error.stdout || '').split('\n')
const failed = String(error.stdout || '')
.split('\n')
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
.map((line) => line.trim().slice(2).split(' (')[0]);
by = [...new Set(failed)].slice(0, 2).join('; ');
-47
View File
@@ -357,53 +357,6 @@ async function runArbitrationSection(page, { shots, consoleErrors }) {
`classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`,
);
// ── Blocker repro: a control that claims only the KEY ───────────────────
// The compact Radio disclosure closes on ESC from a capture listener bound
// long before this module exists. It called stopPropagation(), which does
// NOT stop later listeners on the same document — so one key closed the
// disclosure AND dismissed the launcher. The card is not hiding behind this
// one and must not yield to it: the key is simply already spoken for.
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
const beforeRadio = await launcherState();
record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen);
const radioOpened = await page.evaluate(() => {
document.getElementById('context-radio-toggle-btn')?.click();
return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open');
});
await sleep(250);
record('the compact Radio disclosure opens', radioOpened);
const withRadio = await launcherState();
record(
'a small disclosure is a key contest, not a screen takeover — no yield',
withRadio.classVisible && withRadio.topmost,
`classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`,
);
await page.keyboard.press('Escape');
await sleep(400);
const radioAfterEsc = await page.evaluate(() => (
!!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')
));
const launcherAfterRadioEsc = await launcherState();
record('ESC closes the disclosure', radioOpened && !radioAfterEsc,
`disclosure still open=${radioAfterEsc}`);
record(
'that SAME ESC does not also dismiss the launcher',
beforeRadio.onScreen && launcherAfterRadioEsc.classVisible
&& launcherAfterRadioEsc.session !== 'dismissed',
`classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`,
);
// ...and the NEXT press is the launcher's, so nothing was permanently taken.
await page.keyboard.press('Escape');
await sleep(600);
const escAfterRadioClosed = await launcherState();
record(
'the next ESC belongs to the launcher again',
beforeRadio.onScreen && !escAfterRadioClosed.classVisible
&& escAfterRadioClosed.session === 'dismissed',
`classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`,
);
// ── Deferred reveal: a surface already up when the launcher would show ──
// The class goes on as soon as the app exists, which is well before the
// launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not
+3 -24
View File
@@ -39,14 +39,13 @@ try {
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
'radio-filter',
'sdr-gain-select',
].map((id) => document.getElementById(id));
return {
ids: selects.map((node) => node.id),
shared:
selects.length === 6 &&
selects.length === 5 &&
selects.every((node) => node.classList.contains('gev-field')),
native: selects.every(
(node) =>
@@ -58,7 +57,7 @@ try {
};
});
check(
'all six panel dropdowns retain native select semantics and shared surface',
'all five panel dropdowns retain native select semantics and shared surface',
inventory.shared && inventory.native,
inventory,
);
@@ -282,13 +281,6 @@ try {
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
for (const lang of ['en']) {
await page.evaluate((lang) => {
document.documentElement.lang = lang;
@@ -296,12 +288,7 @@ try {
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
const layout = await page.evaluate(() => {
const ids = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
];
const ids = ['scene-select', 'cctv-camera-select', 'hud-layout-select'];
return ids.map((id) => {
const node = document.getElementById(id),
rect = node.getBoundingClientRect(),
@@ -432,19 +419,11 @@ try {
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
const translated = await page.evaluate(() => {
const nodes = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
].map((id) => document.getElementById(id));
return (
document.documentElement.lang === 'ru' &&
+7 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
* Rendered acceptance for UTILITIES ▸ Import: real files through the real file
* input and a real drop, parsed by Cesium in the page.
*
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
@@ -269,10 +269,12 @@ try {
}, VIEW);
await wait(4000);
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
@@ -283,7 +285,7 @@ try {
);
const before = await state();
check(
'Import is on the DISPLAY rail with an empty, hidden list',
'Import is in the UTILITIES panel with an empty, hidden list',
before.rows === 0 &&
!(await page.evaluate(() =>
document
+645
View File
@@ -0,0 +1,645 @@
#!/usr/bin/env node
/**
* qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer
* (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from
* github.com/s2underground/GhostMaps.
*
* Drives the REAL app in headless Chromium against a dev server (keyless).
*
* (i) LIVE — enabling the layer loads the default products (Common
* Intelligence Picture and the 30-day kinetic pull) from the live
* repository through /api/ghostmaps, one data source per product,
* matching the proxy's catalog commit; the row shows one chip per
* catalog product with the defaults active, and the page holds an
* open event stream to /api/ghostmaps/events.
* (ii) PICK — a Common Intelligence Picture marker at the screen centre
* picks as this layer's entity; clicking it paints its report card
* and clicking empty ground clears it.
* (iii) PUSH (synthetic) — the event stream is replaced in the page and a
* catalog naming a new edition of one product is pushed: only that
* product reloads (a new data source replaces the old one, no
* duplicates), the other keeps its data source. A catalog without a
* product removes it.
* (iv) CHIPS — clicking an active product chip removes that product,
* clicking it again restores it; the choice survives a reload.
* (v) DISABLE — every GhostMaps data source leaves the viewer.
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips
* fit the panel width with no horizontal overflow.
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
* seen from over the Indian Ocean no pin clearly behind the planet is
* shown; back over North America the near-side pins show again.
*
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
*
* Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173
* Optional: --timeout-ms 180000 for a slow boot, --shots <dir>.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const HEADFUL = argv.includes('--headful');
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000'));
const SHOTS_DIR = path.resolve(
getOpt('--shots', path.join(__dirname, '..', 'qa-shots')),
);
const LAYER_ID = 's2-ghostmaps';
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function boot(page) {
await page.goto(`${APP_URL}/?welcome=0`, {
waitUntil: 'domcontentloaded',
timeout: TIMEOUT_MS,
});
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: TIMEOUT_MS },
);
await sleep(5000);
await page.keyboard.press('Escape');
}
/** GhostMaps data sources on the viewer: name → entity count. */
function ghostSources(prefix) {
const { dataSources } = window.__godsEyeView.viewer;
const out = [];
for (let i = 0; i < dataSources.length; i++) {
const ds = dataSources.get(i);
if (ds.name?.startsWith(prefix))
out.push({ name: ds.name, count: ds.entities.values.length });
}
return out;
}
function paintedOverlayPixels() {
const canvas = document.getElementById('world-overlay-canvas');
if (!canvas) return -1;
const pixels = canvas
.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
return count;
}
async function enableAndSettle(page) {
await page.evaluate((id) => {
const panel = document.getElementById('data-panel');
if (panel?.classList.contains('collapsed'))
panel.querySelector('[data-collapse-target="data-panel"]')?.click();
return window.__godsEyeView.dataManager.setEnabled(id, true);
}, LAYER_ID);
await page.waitForFunction(
(id) => {
const stats = window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats();
return !stats.loading && stats.count > 0;
},
{ timeout: TIMEOUT_MS, polling: 500 },
LAYER_ID,
);
}
const stats = (page) =>
page.evaluate(
(id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(),
LAYER_ID,
);
const sources = (page) =>
page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`);
async function showRow(page) {
await page.evaluate((id) => {
document
.querySelector(`[data-layer-id="${id}"]`)
?.scrollIntoView({ block: 'center' });
}, LAYER_ID);
await sleep(400);
}
async function main() {
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const browser = await puppeteer.launch({
headless: !HEADFUL,
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
defaultViewport: { width: 1440, height: 900 },
});
try {
// ---- (i) LIVE ---------------------------------------------------------
const page = await browser.newPage();
// Start clean once; a reload must keep what the page stored.
await page.evaluateOnNewDocument(() => {
try {
if (sessionStorage.getItem('qa-ghostmaps-clean')) return;
localStorage.clear();
sessionStorage.setItem('qa-ghostmaps-clean', '1');
} catch {
/* sandboxed frames have no storage */
}
});
page.on('pageerror', (error) =>
console.log(` [page error] ${error.message}`),
);
await boot(page);
const catalog = await page.evaluate(() =>
fetch('/api/ghostmaps/catalog').then((r) => r.json()),
);
record(
'LIVE: proxy catalog names a commit and the default products',
/^[0-9a-f]{40}$/.test(catalog.commit || '') &&
['cip', 'kinetic-30d'].every((id) =>
catalog.products.some((p) => p.id === id && p.defaultOn),
),
`${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`,
);
const t0 = Date.now();
await enableAndSettle(page);
const live = await stats(page);
const liveSources = await sources(page);
record(
'LIVE: default products load as one data source each',
liveSources.length === 2 &&
liveSources.every((s) => s.count > 0) &&
live.products.sort().join() === 'cip,kinetic-30d',
`${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`,
);
record(
'LIVE: layer reports the proxy commit and no error',
live.commit === catalog.commit && !live.error,
`commit ${live.commit?.slice(0, 7)}, error ${live.error}`,
);
await showRow(page);
const chips = await page.evaluate(
(id) =>
[
...document.querySelectorAll(
`[data-layer-id="${id}"] .data-toggle-chip`,
),
].map((chip) => ({
id: chip.dataset.chipId,
active: chip.getAttribute('aria-pressed') === 'true',
})),
LAYER_ID,
);
record(
'LIVE: one chip per product, defaults pressed',
chips.length === catalog.products.length &&
chips.every(
(chip) =>
chip.active ===
catalog.products.find((p) => p.id === chip.id)?.defaultOn,
),
chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '),
);
const stream = await page
.waitForFunction(
(id) =>
window.__godsEyeView.dataManager.layers.get(id).module.getStats()
.stream === 'open',
{ timeout: 15000, polling: 250 },
LAYER_ID,
)
.then(
() => true,
() => false,
);
record('LIVE: the /api/ghostmaps/events stream is open', stream);
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
// ---- (ii) PICK ----------------------------------------------------------
const target = await page.evaluate((id) => {
const gev = window.__godsEyeView;
const ds = [...Array(gev.viewer.dataSources.length).keys()]
.map((i) => gev.viewer.dataSources.get(i))
.find((item) => item.name === `${id}:cip`);
const entity = ds.entities.values.find((e) => e.billboard);
const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic(
entity.position.getValue(gev.viewer.clock.currentTime),
);
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight */
}
gev.viewer.camera.setView({
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: carto.longitude,
latitude: carto.latitude,
height: 40000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
return entity.id;
}, LAYER_ID);
await page
.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: 30000 },
)
.catch(() => {});
// The horizon cull reveals the pin on the frame after the camera moves.
await page
.waitForFunction(
() => {
const v = window.__godsEyeView.viewer;
v.scene.requestRender?.();
const c = v.scene.canvas;
return v.scene
.drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8)
.some((p) =>
String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'),
);
},
{ timeout: 15000, polling: 500 },
)
.catch(() => {});
const probe = await page.evaluate(() => {
const v = window.__godsEyeView.viewer;
const c = v.scene.canvas;
const box = c.getBoundingClientRect();
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
const picked = v.scene.drillPick(centre, 8);
// Empty ground: the first point below the marker (the card is drawn
// above it) that picks nothing at all; markers can be dense in a city.
let empty = null;
for (let r = 120; r < 420 && !empty; r += 60)
for (const a of [2, 1, 3, 0, 4]) {
const p = {
x: centre.x + r * Math.cos((a * Math.PI) / 4),
y: centre.y + r * Math.sin((a * Math.PI) / 4),
};
if (!v.scene.pick(p)) {
empty = p;
break;
}
}
empty ||= { x: centre.x + 400, y: centre.y + 300 };
return {
ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')),
centre: { x: centre.x + box.left, y: centre.y + box.top },
empty: { x: empty.x + box.left, y: empty.y + box.top },
};
});
record(
'PICK: the centred marker picks as a GhostMaps entity',
probe.ids.some((id) => id.startsWith('ghostmaps:cip:')),
`${target} → [${probe.ids.slice(0, 3).join(', ')}]`,
);
const before = await page.evaluate(paintedOverlayPixels);
await page.mouse.click(probe.centre.x, probe.centre.y);
const painted = await page
.waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, {
timeout: 10000,
polling: 250,
})
.then(
() => true,
() => false,
);
const after = await page.evaluate(paintedOverlayPixels);
record(
'PICK: clicking the marker paints its report card',
painted,
`${before} → ${after} pixels`,
);
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') });
const cardState = () =>
page.evaluate((id) => {
const d = window.__gevWorldOverlay?.getDiagnostics?.();
return {
entries: d?.entriesBySource?.[id] ?? null,
painted: d?.paintedBySource?.[id] ?? null,
};
}, LAYER_ID);
const selectedState = await cardState();
record(
'PICK: the overlay host holds and paints exactly one card',
selectedState.entries === 1 && selectedState.painted === 1,
JSON.stringify(selectedState),
);
await page.mouse.click(probe.empty.x, probe.empty.y);
// The overlay canvas is `desynchronized`, so headless read-back can
// return a stale buffer; the host's own model is the authority here.
const cleared = await page
.waitForFunction(
(id) => {
const d = window.__gevWorldOverlay.getDiagnostics();
return (
d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0)
);
},
{ timeout: 10000, polling: 250 },
LAYER_ID,
)
.then(
() => true,
() => false,
);
record(
'PICK: clicking empty ground clears the card',
cleared,
JSON.stringify(await cardState()),
);
// ---- (vii) FAR SIDE ---------------------------------------------------
// Pins skip the depth test; with Google 3D tiles nothing else hides the
// far side. A pin clearly behind the planet (sphere test with margin) must
// be hidden, and pins facing the camera must still show.
const horizonCounts = (lon, lat) =>
page.evaluate(
async (id, lo, la) => {
const gev = window.__godsEyeView;
const d2r = Math.PI / 180;
try {
gev.viewer.camera.cancelFlight();
} catch {
/* no flight */
}
gev.viewer.camera.setView({
destination:
gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: lo * d2r,
latitude: la * d2r,
height: 18_000_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
gev.viewer.scene.requestRender?.();
await new Promise((r) => setTimeout(r, 1200));
const c = gev.viewer.camera.positionWC;
const norm = (v) => Math.hypot(v.x, v.y, v.z);
let far = 0;
let nearShown = 0;
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
const ds = gev.viewer.dataSources.get(i);
if (!ds.name?.startsWith(`${id}:`)) continue;
for (const e of ds.entities.values) {
if (!e.billboard) continue;
const p = e.position.getValue(gev.viewer.clock.currentTime);
// Angle from the camera's nadir to the pin, against the
// geometric horizon angle; the cull's 1 km margin shows pins up
// to about 1° past it, so "clearly behind" is beyond 2°.
const angle = Math.acos(
(p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)),
);
const horizon = Math.acos(norm(p) / norm(c));
const margin = (2 * Math.PI) / 180;
if (e.show && angle > horizon + margin) far++;
if (e.show && angle < horizon - margin) nearShown++;
}
}
return { far, nearShown };
},
LAYER_ID,
lon,
lat,
);
const fromIndianOcean = await horizonCounts(80, -30);
record(
'FAR SIDE: pins behind the planet are hidden',
fromIndianOcean.far === 0,
JSON.stringify(fromIndianOcean),
);
await page.screenshot({
path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'),
});
const fromAmerica = await horizonCounts(-98, 39);
record(
'FAR SIDE: pins facing the camera show again',
fromAmerica.far === 0 && fromAmerica.nearShown > 100,
JSON.stringify(fromAmerica),
);
// ---- (iv) CHIPS -------------------------------------------------------
const clickChip = async (id) => {
// Row controls render on the next frame after the layer settles.
await page.waitForSelector(
`[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`,
{ timeout: 15000 },
);
return page.evaluate(
(layer, chip) =>
document
.querySelector(
`[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`,
)
?.click(),
LAYER_ID,
id,
);
};
await clickChip('kinetic-30d');
await page.waitForFunction(
(id) =>
!window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats()
.products.includes('kinetic-30d'),
{ timeout: 15000 },
LAYER_ID,
);
const without = await sources(page);
record(
'CHIPS: turning a product off removes only its data source',
without.length === 1 && without[0].name === `${LAYER_ID}:cip`,
without.map((s) => s.name).join(', '),
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: TIMEOUT_MS,
});
await sleep(3000);
await enableAndSettle(page);
const kept = await stats(page);
record(
'CHIPS: the product choice survives a reload',
kept.products.join() === 'cip',
kept.products.join(', '),
);
await showRow(page);
await clickChip('kinetic-30d');
await page.waitForFunction(
(id) =>
window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats()
.products.includes('kinetic-30d'),
{ timeout: 30000 },
LAYER_ID,
);
record(
'CHIPS: turning it back on restores it',
(await sources(page)).length === 2,
);
// ---- (v) DISABLE ------------------------------------------------------
await page.evaluate(
(id) => window.__godsEyeView.dataManager.setEnabled(id, false),
LAYER_ID,
);
await sleep(500);
record(
'DISABLE: every GhostMaps data source leaves the viewer',
(await sources(page)).length === 0,
);
await page.close();
// ---- (iii) PUSH (synthetic) ----------------------------------------------
const push = await browser.newPage();
await push.evaluateOnNewDocument(() => {
localStorage.clear();
// A controllable stand-in for the real stream.
class QaEventSource {
constructor(url) {
this.url = url;
this.listeners = new Set();
(window.__qaStreams ||= []).push(this);
}
addEventListener(type, listener) {
if (type === 'catalog') this.listeners.add(listener);
}
removeEventListener(type, listener) {
this.listeners.delete(listener);
}
close() {
this.closed = true;
}
emit(catalog) {
for (const listener of this.listeners)
listener({ data: JSON.stringify(catalog) });
}
}
window.EventSource = QaEventSource;
});
await boot(push);
await enableAndSettle(push);
const identity = () =>
push.evaluate((id) => {
const { dataSources } = window.__godsEyeView.viewer;
window.__qaIds ||= new WeakMap();
window.__qaNext ||= 1;
const out = {};
for (let i = 0; i < dataSources.length; i++) {
const ds = dataSources.get(i);
if (!ds.name?.startsWith(`${id}:`)) continue;
if (!window.__qaIds.has(ds))
window.__qaIds.set(ds, window.__qaNext++);
out[ds.name] = window.__qaIds.get(ds);
}
return out;
}, LAYER_ID);
const first = await identity();
const pushed = Date.now();
await push.evaluate(async (id) => {
const catalog = await fetch('/api/ghostmaps/catalog').then((r) =>
r.json(),
);
const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d');
kinetic.version = 'f'.repeat(40);
catalog.message = 'QA push';
window.__qaStreams
.filter((s) => !s.closed)
.forEach((s) => s.emit(catalog));
}, LAYER_ID);
await push.waitForFunction(
(id) => {
const layer = window.__godsEyeView.dataManager.layers.get(id).module;
return (
!layer.getStats().loading &&
layer.getRowControls().info.includes('QA push')
);
},
{ timeout: 20000, polling: 100 },
LAYER_ID,
);
await sleep(500);
const second = await identity();
record(
'PUSH: a new edition reloads only that product',
Object.keys(second).length === 2 &&
second[`${LAYER_ID}:kinetic-30d`] !==
first[`${LAYER_ID}:kinetic-30d`] &&
second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`],
`${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`,
);
await push.evaluate(async () => {
const catalog = await fetch('/api/ghostmaps/catalog').then((r) =>
r.json(),
);
catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d');
window.__qaStreams
.filter((s) => !s.closed)
.forEach((s) => s.emit(catalog));
});
await sleep(1000);
const third = await identity();
record(
'PUSH: a product leaving the catalog leaves the globe',
Object.keys(third).join() === `${LAYER_ID}:cip`,
Object.keys(third).join(', '),
);
await push.close();
// ---- (vi) VIEWPORT ----------------------------------------------------
const phone = await browser.newPage();
await phone.setViewport({ width: 390, height: 844, isMobile: true });
await phone.evaluateOnNewDocument(() => localStorage.clear());
await boot(phone);
await enableAndSettle(phone);
await showRow(phone);
const fit = await phone.evaluate((id) => {
const row = document.querySelector(`[data-layer-id="${id}"]`);
const panel = document.getElementById('data-panel');
const controls = row?.querySelector('.data-toggle-controls');
return {
row: Boolean(row),
overflow: controls ? controls.scrollWidth - controls.clientWidth : -1,
pageOverflow:
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
panelRight: panel?.getBoundingClientRect().right,
width: window.innerWidth,
};
}, LAYER_ID);
record(
'VIEWPORT: chips fit the phone-width panel',
fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0,
JSON.stringify(fit),
);
await phone.screenshot({
path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'),
});
await phone.close();
} finally {
await browser.close();
}
const failed = results.filter((r) => !r.ok);
console.log(
`\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`,
);
process.exit(failed.length ? 1 : 0);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+15 -3
View File
@@ -87,17 +87,21 @@ try {
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
]);
const ui = window.__godsEyeView.styleManager;
ui._clearSelectedLayersBtn.click();
const clearButton = document.getElementById('clear-selected-layers');
clearButton.click();
// The bar renders the busy state on Vue's next flush.
await Promise.resolve();
result.push([
'native clear activation presents busy state',
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
clearButton.getAttribute('aria-busy') === 'true' &&
!!ui._contextControls._clearSelectedLayersPromise,
]);
await ui._contextControls._clearSelectedLayersPromise;
await Promise.resolve();
result.push([
'clear settles through the existing layer transaction',
!manager.isEnabled(id) &&
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
clearButton.getAttribute('aria-busy') === 'false',
]);
await manager.setEnabled(id, true);
const final = row().querySelector('.data-toggle-btn');
@@ -122,6 +126,14 @@ try {
'ordinary layer rows return after reassembly',
container.querySelectorAll('.data-toggle-row').length > 10,
]);
result.push([
'Directions returns to the Utilities list after reassembly',
Boolean(
document.querySelector(
'#utilities-toggles [data-layer-id="directions"]',
) && !container.querySelector('[data-layer-id="directions"]'),
),
]);
return result;
});
for (const [name, passed] of results) check(name, passed);
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Real-browser proof for manager-owned periodic work and document visibility.
* Uses the app's development-only layer registration seam; no provider keys.
* Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173
* Hiding uses a real background tab, never a patched document.hidden getter.
*/
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = new URL(
(urlIndex >= 0 ? args[urlIndex + 1] : undefined) ||
process.env.QA_BASE_URL ||
'http://localhost:4173',
);
url.searchParams.set('welcome', '0');
const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
headless: true,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const check = (name, actual, expected) => {
assert.equal(actual, expected, name);
console.log(`PASS ${name}: ${actual}`);
};
try {
const page = await browser.newPage();
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager,
);
await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
window.__qaVisibilityUpdates = 0;
window.__gevQaRegisterLayer(manager, {
id: 'qa-document-visibility',
name: 'Visibility QA',
updateInterval: 750,
init() {},
enable() {},
disable() {},
destroy() {},
async update() {
window.__qaVisibilityUpdates++;
return true;
},
getStats() {
return { count: window.__qaVisibilityUpdates };
},
});
await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' });
});
const count = () => page.evaluate(() => window.__qaVisibilityUpdates);
await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3);
check('periodic updates run while visible', (await count()) >= 3, true);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
const cover = await browser.newPage();
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
const hiddenCount = await count();
await pause(2200);
check('hidden tab makes no periodic updates', await count(), hiddenCount);
check(
'explicit refresh remains available while hidden',
await page.evaluate(() =>
window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'),
),
true,
);
check('explicit hidden refresh runs once', await count(), hiddenCount + 1);
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.waitForFunction(
() =>
document.visibilityState === 'visible' &&
window.__qaVisibilityUpdates > 0,
);
await pause(200);
check(
'resume performs one overdue refresh without a catch-up burst',
await count(),
hiddenCount + 2,
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled(
'qa-document-visibility',
false,
{ origin: 'qa' },
),
);
const stopped = await count();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false });
await cover.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'hidden');
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await pause(1100);
check(
'disabled layer does not resume on visibility change',
await count(),
stopped,
);
check(
'QA layer destroys cleanly',
await page.evaluate(() =>
window.__gevQaUnregisterLayer(
window.__godsEyeView.dataManager,
'qa-document-visibility',
),
),
true,
);
console.log('RESULT: 7 passed');
} finally {
await browser.close();
}
+25 -10
View File
@@ -62,13 +62,17 @@ try {
);
check(
'orbit controls and indicator agree',
await page.evaluate(() => {
await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
// Vue renders on a microtask; a task later the indicator shows it.
const rendered = () => new Promise((resolve) => setTimeout(resolve));
ui.setOrbit(true);
await rendered();
const active =
ui.orbitController.active &&
document.getElementById('orbit-indicator').classList.contains('active');
ui.setOrbit(false);
await rendered();
return (
active &&
!ui.orbitController.active &&
@@ -120,8 +124,11 @@ try {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
ui._locationMiniCity.textContent.includes('39.7392° N') &&
ui._locationMiniPoi.textContent === '104.9903° W'
document
.getElementById('location-mini-city')
.textContent.includes('39.7392° N') &&
document.getElementById('location-mini-poi').textContent ===
'104.9903° W'
);
}),
);
@@ -194,8 +201,11 @@ try {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === 'Second landmark, Test city' &&
ui._locationMiniCity.textContent.includes('Second landmark') &&
ui._locationMiniPoi.textContent === 'Test city' &&
document
.getElementById('location-mini-city')
.textContent.includes('Second landmark') &&
document.getElementById('location-mini-poi').textContent ===
'Test city' &&
ui.activeStyle === 'normal' &&
!ui._locationSearch.classList.contains('searching')
);
@@ -227,8 +237,10 @@ try {
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return (
!ui._poiRow.classList.contains('expanded') &&
!ui._locationBarDivider.classList.contains('visible')
!document.getElementById('poi-row').classList.contains('expanded') &&
!document
.getElementById('location-bar-divider')
.classList.contains('visible')
);
}, cityId),
);
@@ -284,7 +296,7 @@ try {
'native reset returns through the existing camera handoff',
await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
ui._resetGlobeBtn.click();
document.getElementById('reset-globe-view').click();
const pending = ui._globeResetPromise;
if (!pending) return false;
await pending;
@@ -301,8 +313,11 @@ try {
ui._locationControls.destroy();
ui._locationLookup.destroy();
const before = ui._navigationGeneration;
ui._locationPills.querySelector('button').click();
ui._resetGlobeBtn.click();
document
.getElementById('location-pills')
.querySelector('button')
?.click();
document.getElementById('reset-globe-view').click();
ui._locationSearch.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
+24 -14
View File
@@ -69,9 +69,10 @@ try {
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
return (
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === 'osm' &&
manager._mapStackStatus.textContent ===
document
.getElementById('map-stack-chips')
.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' &&
document.getElementById('map-stack-status').textContent ===
manager.mapStackController.getState().activeStack.shortLabel
);
}),
@@ -81,32 +82,38 @@ try {
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const result = await manager.setMapStack('esri-imagery');
// The dock renders the published row on Vue's next flush.
await new Promise((resolve) => setTimeout(resolve, 0));
const active = manager.mapStackController.getActiveId();
return (
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === active &&
document
.getElementById('map-stack-chips')
.querySelector('[aria-pressed="true"]')?.dataset.stackId === active &&
(result.ok || !!manager.mapStackController.getState().lastError)
);
}),
);
check(
'refresh revokes retained old chips',
await page.evaluate(() => {
'refresh re-presents the chips without duplicating them',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
manager._mapSourceControls.refresh();
const before = window.__qaMapSelections.length;
old.click();
return window.__qaMapSelections.length === before;
await new Promise((resolve) => setTimeout(resolve, 0));
return (
document.querySelectorAll('#map-stack-chips [data-stack-id="osm"]')
.length === 1
);
}),
);
check(
'refreshed chips still select once',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
document
.getElementById('map-stack-chips')
.querySelector('[data-stack-id="osm"]')
.click();
await Promise.resolve();
return window.__qaMapSelections.length === before + 1;
}),
@@ -188,7 +195,10 @@ try {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapSourceControls.destroy();
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
document
.getElementById('map-stack-chips')
.querySelector('[data-stack-id="osm"]')
.click();
const result = await manager._mapSourceControls.select('esri-imagery');
return result === null && window.__qaMapSelections.length === before;
}),
+12 -8
View File
@@ -200,8 +200,8 @@ const checkResponsiveStyleFocus = async (width) => {
await page.focus('#control-panel-toggle');
await pressTabs(2); // disclosure -> pin -> Normal
}
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
if (index) await pressTabs(3);
for (const [tabs, style] of [[0, 'normal'], [3, 'thermal'], [2, 'noir']]) {
if (tabs) await pressTabs(tabs);
const state = await styleFocusMetrics();
check(`${width} px ${style} keyboard ring is visible and unclipped`,
hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
@@ -567,13 +567,13 @@ try {
// Visual Styles precede the five Map Source tiles. Keep the existing opening
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
await pressTabs(5, true);
const snowFocus = await styleFocusMetrics();
check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected',
hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected
&& snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm',
JSON.stringify(snowFocus));
const noirFocus = await styleFocusMetrics();
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
&& noirFocus.selectedStyle === 'normal' && noirFocus.selectedMap === 'osm',
JSON.stringify(noirFocus));
await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
await pressTabs(3, true);
await pressTabs(2, true);
const middleFocus = await styleFocusMetrics();
check('Shift+Tab traverses the middle FLIR style without activating it',
hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
@@ -871,6 +871,9 @@ try {
return originalSetStack(stackId);
};
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
// The dock renders the published row on Vue's next flush.
const rendered = () => new Promise((resolve) => setTimeout(resolve, 0));
await rendered();
const during = {
status: document.getElementById('map-stack-status').textContent,
active: [...document.querySelectorAll('.map-stack-chip')]
@@ -879,6 +882,7 @@ try {
};
release();
await switchPromise;
await rendered();
const after = {
status: document.getElementById('map-stack-status').textContent,
active: [...document.querySelectorAll('.map-stack-chip')]
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env node
/**
* Traffic-warm OpenFreeMap outline acceptance.
*
* Each sample opens a fresh application page, loads the Texas Capitol z14 tile
* through Street Traffic, then circles a building from that same tile. It
* records application fetches separately from browser transfer bytes,
* body-read/decode/extraction/install measures, and Long Tasks. A second ask
* on the page proves the outline source's own consumer-cache path. A separate
* page holds traffic's target response while the outline consumer subscribes,
* proving that overlap still produces one application fetch and one decode.
*
* Acceptance records actual owned synchronous segments (16.7 ms each), full
* eight-probe completion within 2 s, and separate inclusive latency. No sample may create a
* >=50 ms Long Task, and neither the first nor second outline ask may fetch or
* decode the traffic-warm tile again. The first explicit cold ask's target
* projection must fit one frame; Traffic does no outline projection and the
* warm ask reuses it. Traffic activation Long Tasks are reported separately.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const valueAfter = (flag, fallback) => {
const index = args.indexOf(flag);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
if (args.includes('--help') || args.includes('-h')) {
console.log(
'Usage: node scripts/qa-openfreemap-same-tile.mjs [--url http://localhost:4173] [--runs 5] [--headful] [--out qa-shots/openfreemap-same-tile.json]',
);
process.exit(0);
}
const APP_URL = valueAfter('--url', 'http://localhost:4173');
const RUNS = Number(valueAfter('--runs', '5'));
const OUTPUT = path.resolve(
valueAfter('--out', 'qa-shots/openfreemap-same-tile.json'),
);
const FRAME_BUDGET_MS = 16.7;
const LONG_TASK_MS = 50;
const TARGET = Object.freeze({
latitude: 30.27472,
longitude: -97.74035,
z: 14,
x: 3743,
y: 6745,
});
const TARGET_PATH = `/${TARGET.z}/${TARGET.x}/${TARGET.y}.pbf`;
if (!Number.isInteger(RUNS) || RUNS < 3 || RUNS > 20)
throw new Error('--runs must be an integer from 3 through 20');
const targetUrl = new URL(APP_URL);
if (!['http:', 'https:'].includes(targetUrl.protocol))
throw new Error('--url must be HTTP(S)');
const percentile = (values, p) => {
if (!values.length) return null;
const sorted = values.slice().sort((a, b) => a - b);
return sorted[Math.ceil(p * sorted.length) - 1];
};
const round = (value) =>
value == null ? null : Number(Number(value).toFixed(3));
const measures = (entries, name) =>
entries.filter((entry) => entry.name === name).map((entry) => entry.ms);
const sum = (values) => values.reduce((total, value) => total + value, 0);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Numeric DEV evidence from the actual owners; waited frames are latency, not CPU.
function workEvidence(entries, label) {
const work = entries.filter(
(entry) => entry.name === 'outlines:install-work',
);
assert.equal(work.length, 1, `${label}: one completed owned-work trace`);
const trace = work[0].detail;
assert.equal(
trace?.status,
'installed',
`${label}: processed install required`,
);
assert.equal(trace.samplerStatus, 'installed');
assert.equal(trace.rendererStatus, 'installed');
assert.equal(trace.dropped, 0, `${label}: no lost work intervals`);
assert.equal(trace.probes, 8, `${label}: all original probes required`);
assert.ok(Number.isFinite(trace.timeOrigin));
assert.ok(
Number.isFinite(trace.samplerStart) && Number.isFinite(trace.completedAt),
);
assert.ok(
Number.isFinite(trace.callerStart) &&
trace.callerStart <= trace.samplerStart,
);
const phases = {
'engine-schedule': 1,
'ground-step': 8,
'hybrid-complete': 1,
'engine-process': 1,
'engine-finalize': 1,
'engine-settle': 1,
};
assert.equal(
trace.slices?.length,
13,
`${label}: complete disjoint owner intervals`,
);
for (const [phase, count] of Object.entries(phases))
assert.equal(
trace.slices.filter((slice) => slice.phase === phase).length,
count,
`${label}: ${phase}`,
);
let end = -Infinity;
const durations = trace.slices.map((slice) => {
assert.ok(
Number.isFinite(slice.start) &&
Number.isFinite(slice.end) &&
slice.end >= slice.start,
);
assert.ok(
slice.start >= end,
`${label}: no nested or double-counted owner intervals`,
);
end = slice.end;
const duration = slice.end - slice.start;
assert.ok(
duration <= FRAME_BUDGET_MS,
`${label}: ${slice.phase} work slice ${duration} exceeds ${FRAME_BUDGET_MS} ms`,
);
return duration;
});
const completionMs = trace.completedAt - trace.samplerStart;
assert.ok(
completionMs >= 0 && completionMs <= 2000,
`${label}: processed completion ${completionMs} ms exceeds 2000 ms`,
);
// This sum describes owned tail work, never the complete browser task/frame.
const tailStart = trace.slices
.filter((slice) => slice.phase === 'ground-step')
.at(-1).start;
return {
timeOrigin: trace.timeOrigin,
slices: trace.slices,
maxSliceMs: Math.max(...durations),
ownedTailSumMs: trace.slices
.filter((slice) => slice.start >= tailStart)
.reduce((total, slice) => total + slice.end - slice.start, 0),
completionMs,
callerCompletionMs: trace.completedAt - trace.callerStart,
probes: trace.probes,
dropped: trace.dropped,
};
}
await fs.mkdir(path.dirname(OUTPUT), { recursive: true });
const browser = await puppeteer.launch({
headless: args.includes('--headful') ? false : 'new',
protocolTimeout: 240_000,
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
...(args.includes('--headful')
? []
: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']),
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,1000',
],
});
const report = {
appUrl: APP_URL,
target: TARGET,
targetPath: TARGET_PATH,
acceptance: {
maxOwnedSliceMs: FRAME_BUDGET_MS,
samplerCompletionMaxMs: 2000,
legacyInclusive: 'reported-not-gated',
maxLongTaskMs: LONG_TASK_MS,
coldProjectionMaxMs: FRAME_BUDGET_MS,
coldApplicationFetches: 0,
coldOutlineTransferBytes: 0,
coldDecodes: 0,
warmApplicationFetches: 0,
warmDecodes: 0,
overlapApplicationFetches: 1,
overlapDecodes: 1,
},
renderer: null,
samples: [],
errors: [],
};
function relevantMeasures(raw) {
return raw
.filter(
(entry) =>
entry.name.startsWith('roads:') || entry.name.startsWith('outlines:'),
)
.map((entry) => ({
name: entry.name,
ms: entry.duration,
at: entry.startTime,
detail: entry.detail || {},
}));
}
async function beginOutline(page, label) {
await page.evaluate(
({ label, target }) => {
performance.clearMeasures();
window.__qaOutlineLongTasks.length = 0;
window.__qaOutlineStarted = performance.now();
window.__qaOutlinePromise = Promise.resolve(
window.__gevVoiceCommands.runner('annotate_map', {
annotations: [
{
type: 'area',
target: 'this building',
label,
entityKind: 'building',
latitude: target.latitude,
longitude: target.longitude,
},
],
}),
);
},
{ label, target: TARGET },
);
}
async function settleOutline(page, label) {
return page.evaluate(async (label) => {
const result = await window.__qaOutlinePromise;
let annotation = null;
const deadline = performance.now() + 30_000;
while (performance.now() < deadline) {
annotation = (window.__gevAnnotations?.list?.() || []).find(
(item) => item.label === label,
);
if (annotation && !annotation.pendingOutline) break;
await new Promise((resolve) => setTimeout(resolve, 25));
}
if (!annotation || annotation.pendingOutline)
throw new Error(`${label}: outline did not settle`);
const { scene } = window.__godsEyeView.viewer;
await new Promise((resolve) => {
const timer = setTimeout(resolve, 1500);
const remove = scene.postRender.addEventListener(() => {
clearTimeout(timer);
remove();
resolve();
});
scene.requestRender();
});
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return {
commandToRenderMs: performance.now() - window.__qaOutlineStarted,
result,
annotation: {
pending: Boolean(annotation.pendingOutline),
unavailable: Boolean(annotation.outlineUnavailable),
synthesized: Boolean(annotation.synthesized),
footprintKind: annotation.footprintKind || null,
ringPoints: annotation.ring?.length || 0,
parts: annotation.polygons?.length || (annotation.ring ? 1 : 0),
},
measures: performance.getEntriesByType('measure').map((entry) => ({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
detail: entry.detail,
})),
longTasks: window.__qaOutlineLongTasks.slice(),
};
}, label);
}
async function driveOutline(page, label) {
await beginOutline(page, label);
return settleOutline(page, label);
}
try {
for (let run = 0; run <= RUNS; run += 1) {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 1000 });
let overlapMode = false;
let overlapApplicationRequests = 0;
let heldTargetRequest = null;
let resolveHeldTarget;
const heldTarget = new Promise((resolve) => {
resolveHeldTarget = resolve;
});
if (run === 0) {
await page.setRequestInterception(true);
page.on('request', (request) => {
let isTarget = false;
try {
isTarget = new URL(request.url()).pathname.endsWith(TARGET_PATH);
} catch {
// Invalid URLs are passed through for the browser to handle.
}
if (overlapMode && isTarget) {
overlapApplicationRequests += 1;
if (!heldTargetRequest) {
heldTargetRequest = request;
resolveHeldTarget();
return;
}
}
void request.continue();
});
}
await page.evaluateOnNewDocument(() => {
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
window.__qaOutlineLongTasks = [];
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
window.__qaOutlineLongTasks.push({
at: entry.startTime,
ms: entry.duration,
});
}).observe({ type: 'longtask', buffered: false });
window.__qaLongTaskSupported = true;
} catch {
window.__qaLongTaskSupported = false;
}
});
let phase = 'startup';
const network = [];
const pending = new Map();
const client = await page.createCDPSession();
await client.send('Network.enable');
client.on('Network.requestWillBeSent', (event) => {
let url;
try {
url = new URL(event.request.url);
} catch {
return;
}
if (
url.hostname !== 'tiles.openfreemap.org' ||
!url.pathname.endsWith('.pbf')
)
return;
const entry = {
requestId: event.requestId,
phase,
url: url.origin + url.pathname,
targetTile: url.pathname.endsWith(TARGET_PATH),
cached: false,
transferBytes: 0,
responseBytes: 0,
finished: false,
};
pending.set(event.requestId, entry);
network.push(entry);
});
client.on('Network.requestServedFromCache', ({ requestId }) => {
const entry = pending.get(requestId);
if (entry) entry.cached = true;
});
client.on('Network.responseReceived', ({ requestId, response }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.status = response.status;
entry.cached ||= Boolean(
response.fromDiskCache ||
response.fromServiceWorker ||
response.fromPrefetchCache,
);
});
client.on('Network.dataReceived', ({ requestId, dataLength }) => {
const entry = pending.get(requestId);
if (entry) entry.responseBytes += dataLength;
});
client.on('Network.loadingFinished', ({ requestId, encodedDataLength }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.transferBytes = entry.cached ? 0 : encodedDataLength;
entry.finished = true;
pending.delete(requestId);
});
client.on('Network.loadingFailed', ({ requestId, errorText }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.failure = errorText;
entry.finished = true;
pending.delete(requestId);
});
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(String(error.message)));
const navigation = new URL(APP_URL);
navigation.searchParams.set('welcome', '0');
navigation.searchParams.set('trafficDebug', '1');
navigation.searchParams.set('trafficRoads', 'osm');
await page.goto(navigation.href, {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await page.waitForFunction(
() =>
window.__godsEyeView?.viewer &&
window.__godsEyeView?.dataManager &&
window.__gevVoiceCommands?.runner &&
window.__gevAnnotations &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 120_000, polling: 250 },
);
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setDetection({ enabled: false }),
);
await page.evaluate(async (target) => {
const { viewer, dataManager } = window.__godsEyeView;
await dataManager.setEnabled('traffic', false);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
target.longitude,
target.latitude,
700,
),
orientation: {
heading: 0,
pitch: (-65 * Math.PI) / 180,
roll: 0,
},
});
viewer.camera.moveEnd.raiseEvent();
window.__gevAnnotations.clear();
performance.clearMeasures();
}, TARGET);
await sleep(750);
if (run === 0) {
phase = 'overlap';
overlapMode = true;
await page.evaluate(() => {
window.__qaOutlineLongTasks.length = 0;
performance.clearMeasures();
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', true),
);
await Promise.race([
heldTarget,
sleep(30_000).then(() => {
throw new Error('overlap phase did not intercept the target tile');
}),
]);
await beginOutline(page, 'QA Capitol overlap');
await sleep(100);
await heldTargetRequest.continue();
const overlap = await settleOutline(page, 'QA Capitol overlap');
await sleep(750);
const trafficStats = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats(),
);
assert.equal(trafficStats.error, null, 'overlap traffic source error');
overlap.measures = relevantMeasures(overlap.measures);
overlap.work = workEvidence(overlap.measures, 'overlap');
overlap.network = network.filter(
(entry) => entry.phase === 'overlap' && entry.targetTile,
);
const overlapDecodes = overlap.measures
.filter(
(entry) =>
entry.name === 'roads:decode' &&
String(entry.detail?.key).endsWith(
`:${TARGET.z}/${TARGET.x}/${TARGET.y}`,
),
)
.map((entry) => entry.ms);
assert.equal(
overlapApplicationRequests,
1,
'overlapping traffic and outline consumers issue one application request',
);
assert.equal(
overlap.network.length,
1,
'overlapping traffic and outline consumers emit one target network request',
);
assert.equal(
overlapDecodes.length,
1,
'overlapping traffic and outline consumers decode once',
);
assert.equal(overlap.annotation.unavailable, false);
assert.equal(overlap.annotation.footprintKind, 'building');
report.overlap = {
applicationFetches: overlapApplicationRequests,
targetNetworkRequests: overlap.network.length,
transferBytes: sum(overlap.network.map((entry) => entry.transferBytes)),
decodes: overlapDecodes.length,
decodeMs: round(sum(overlapDecodes)),
longTasks: overlap.longTasks.length,
longTaskEntries: overlap.longTasks,
work: overlap.work,
};
await page.close();
continue;
}
phase = 'traffic';
await page.evaluate(() => {
window.__qaOutlineLongTasks.length = 0;
performance.clearMeasures();
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', true),
);
await page.waitForFunction(
() => {
const stats = window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats();
return stats.count > 0 && !stats.loading && !stats.error;
},
{ timeout: 180_000, polling: 250 },
);
await sleep(1500);
const trafficMeasures = relevantMeasures(
await page.evaluate(() =>
performance.getEntriesByType('measure').map((entry) => ({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
detail: entry.detail,
})),
),
);
const trafficLongTasks = await page.evaluate(() =>
window.__qaOutlineLongTasks.slice(),
);
const trafficTarget = network.filter(
(entry) => entry.phase === 'traffic' && entry.targetTile,
);
const targetTile = `${TARGET.z}/${TARGET.x}/${TARGET.y}`;
const targetProjectionMeasures = trafficMeasures.filter(
(entry) =>
entry.detail?.tile === targetTile &&
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
);
const targetProjectionMs = sum(
targetProjectionMeasures.map((entry) => entry.ms),
);
assert.ok(
trafficTarget.length >= 1,
`sample ${run}: traffic did not load ${TARGET_PATH}`,
);
assert.equal(
trafficMeasures.filter((entry) =>
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
).length,
0,
`sample ${run}: traffic must leave outline projection to the explicit ask`,
);
phase = 'outline-cold';
const cold = await driveOutline(page, `QA Capitol cold ${run}`);
await sleep(750);
cold.measures = relevantMeasures(cold.measures);
cold.work = workEvidence(cold.measures, 'cold');
cold.network = network.filter(
(entry) => entry.phase === 'outline-cold' && entry.targetTile,
);
cold.longTasks = cold.longTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
}));
await page.evaluate(() => window.__gevAnnotations.clear());
phase = 'outline-warm';
const warm = await driveOutline(page, `QA Capitol warm ${run}`);
await sleep(750);
warm.measures = relevantMeasures(warm.measures);
warm.work = workEvidence(warm.measures, 'warm');
warm.network = network.filter(
(entry) => entry.phase === 'outline-warm' && entry.targetTile,
);
warm.longTasks = warm.longTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
}));
const supported = await page.evaluate(() => window.__qaLongTaskSupported);
assert.equal(supported, true, `sample ${run}: Long Tasks unsupported`);
assert.deepEqual(pageErrors, [], `sample ${run}: browser errors`);
assert.equal(cold.annotation.unavailable, false);
assert.equal(cold.annotation.synthesized, false);
assert.equal(cold.annotation.footprintKind, 'building');
assert.ok(cold.annotation.ringPoints >= 4);
assert.equal(warm.annotation.footprintKind, 'building');
assert.ok(warm.annotation.ringPoints >= 4);
const coldDecodes = measures(cold.measures, 'roads:decode');
const coldInstalls = measures(cold.measures, 'outlines:install');
const warmDecodes = measures(warm.measures, 'roads:decode');
const coldProjectionMeasures = cold.measures.filter(
(entry) =>
entry.detail?.tile === targetTile &&
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
);
cold.targetProjectionMs = sum(
coldProjectionMeasures.map((entry) => entry.ms),
);
assert.equal(
cold.network.length,
0,
'shared decoded tile performs no second application fetch',
);
assert.equal(sum(cold.network.map((entry) => entry.transferBytes)), 0);
assert.equal(
coldDecodes.length,
0,
'shared decoded tile is not parsed again',
);
assert.equal(
coldProjectionMeasures.filter(
(entry) => entry.name === 'outlines:street-extract',
).length,
1,
'the first explicit cold ask owns target street extraction exactly once',
);
assert.equal(
coldProjectionMeasures.filter(
(entry) => entry.name === 'outlines:polygon-extract',
).length,
1,
'the first explicit cold ask owns target polygon extraction exactly once',
);
assert.ok(
cold.targetProjectionMs <= FRAME_BUDGET_MS,
`sample ${run}: cold target projection ${cold.targetProjectionMs} ms exceeds ${FRAME_BUDGET_MS} ms`,
);
assert.equal(
measures(warm.measures, 'outlines:street-extract').length,
0,
'warm reuse performs no street extraction',
);
assert.equal(
measures(warm.measures, 'outlines:polygon-extract').length,
0,
'warm reuse performs no polygon extraction',
);
assert.ok(coldInstalls.length >= 1, 'cold outline installs geometry');
assert.equal(warm.network.length, 0, 'decoded-cache hit performs no fetch');
assert.equal(warmDecodes.length, 0, 'decoded-cache hit performs no decode');
report.renderer ||= await page.evaluate(() => {
const gl = window.__godsEyeView.viewer.scene.context._gl;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return debug
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
: gl.getParameter(gl.RENDERER);
});
if (args.includes('--headful')) {
assert.doesNotMatch(
String(report.renderer),
/swiftshader|software rasterizer|llvmpipe/i,
`sample ${run}: headful evidence requires a hardware renderer`,
);
}
report.samples.push({
run,
pageErrors,
traffic: {
applicationFetches: trafficTarget.length,
transferBytes: sum(trafficTarget.map((entry) => entry.transferBytes)),
decodes: measures(trafficMeasures, 'roads:decode').length,
targetProjectionMs: round(targetProjectionMs),
longTasks: trafficLongTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
})),
measures: trafficMeasures,
network: trafficTarget,
},
cold: {
...cold,
applicationFetches: cold.network.length,
transferBytes: sum(cold.network.map((entry) => entry.transferBytes)),
decodeMs: round(sum(coldDecodes)),
bodyReadMs: round(sum(measures(cold.measures, 'roads:tile-body-read'))),
streetExtractMs: round(
sum(measures(cold.measures, 'outlines:street-extract')),
),
polygonExtractMs: round(
sum(measures(cold.measures, 'outlines:polygon-extract')),
),
installMs: round(sum(coldInstalls)),
legacyInclusiveMs: round(
sum(coldDecodes) +
sum(measures(cold.measures, 'outlines:street-extract')) +
sum(measures(cold.measures, 'outlines:polygon-extract')) +
sum(coldInstalls),
),
},
warm: {
...warm,
applicationFetches: warm.network.length,
transferBytes: sum(warm.network.map((entry) => entry.transferBytes)),
decodeMs: round(sum(warmDecodes)),
installMs: round(sum(measures(warm.measures, 'outlines:install'))),
},
});
await page.close();
}
const coldInclusive = report.samples.map(
(sample) => sample.cold.legacyInclusiveMs,
);
const outlineLongTasks = report.samples.flatMap((sample) => [
...sample.cold.longTasks,
...sample.warm.longTasks,
]);
const trafficLongTasks = report.samples.flatMap(
(sample) => sample.traffic.longTasks,
);
report.summary = {
samples: report.samples.length,
overlapApplicationFetches: report.overlap?.applicationFetches ?? null,
overlapDecodes: report.overlap?.decodes ?? null,
legacyInclusiveP50Ms: round(percentile(coldInclusive, 0.5)),
legacyInclusiveP95Ms: round(percentile(coldInclusive, 0.95)),
legacyInclusiveMaxMs: round(Math.max(...coldInclusive)),
coldCommandToRenderP95Ms: round(
percentile(
report.samples.map((sample) => sample.cold.commandToRenderMs),
0.95,
),
),
coldApplicationFetches: sum(
report.samples.map((sample) => sample.cold.applicationFetches),
),
coldTransferBytes: sum(
report.samples.map((sample) => sample.cold.transferBytes),
),
coldDecodes: sum(
report.samples.map(
(sample) => measures(sample.cold.measures, 'roads:decode').length,
),
),
warmApplicationFetches: sum(
report.samples.map((sample) => sample.warm.applicationFetches),
),
warmDecodes: sum(
report.samples.map(
(sample) => measures(sample.warm.measures, 'roads:decode').length,
),
),
coldProjectionMaxMs: Math.max(
...report.samples.map((sample) => sample.cold.targetProjectionMs),
),
trafficBaselineLongTasks: trafficLongTasks.length,
trafficBaselineMaxLongTaskMs: round(
trafficLongTasks.length
? Math.max(...trafficLongTasks.map((entry) => entry.ms))
: 0,
),
longTasks: outlineLongTasks.length,
maxLongTaskMs: round(
outlineLongTasks.length
? Math.max(...outlineLongTasks.map((entry) => entry.ms))
: 0,
),
};
const coldSlices = report.samples.map(
(sample) => sample.cold.work.maxSliceMs,
);
report.summary.coldOwnedSliceP95Ms = round(percentile(coldSlices, 0.95));
report.summary.maxOwnedSliceMs = Math.max(
report.overlap.work.maxSliceMs,
...report.samples.flatMap((sample) => [
sample.cold.work.maxSliceMs,
sample.warm.work.maxSliceMs,
]),
);
report.summary.maxSamplerCompletionMs = Math.max(
report.overlap.work.completionMs,
...report.samples.flatMap((sample) => [
sample.cold.work.completionMs,
sample.warm.work.completionMs,
]),
);
assert.ok(report.summary.maxOwnedSliceMs <= FRAME_BUDGET_MS);
assert.ok(report.summary.maxSamplerCompletionMs <= 2000);
assert.ok(
report.summary.coldProjectionMaxMs <= FRAME_BUDGET_MS,
'cold outline projection exceeded one frame',
);
assert.equal(report.summary.longTasks, 0, 'outline ask created a Long Task');
assert.equal(report.summary.coldTransferBytes, 0);
assert.equal(report.summary.coldApplicationFetches, 0);
assert.equal(report.summary.coldDecodes, 0);
assert.equal(report.summary.warmApplicationFetches, 0);
assert.equal(report.summary.warmDecodes, 0);
assert.equal(report.summary.overlapApplicationFetches, 1);
assert.equal(report.summary.overlapDecodes, 1);
report.passed = true;
console.table(
report.samples.map((sample) => ({
run: sample.run,
coldProjectionMs: sample.cold.targetProjectionMs,
appFetches: sample.cold.applicationFetches,
transferBytes: sample.cold.transferBytes,
bodyReadMs: sample.cold.bodyReadMs,
decodeMs: sample.cold.decodeMs,
streetMs: sample.cold.streetExtractMs,
polygonMs: sample.cold.polygonExtractMs,
installMs: sample.cold.installMs,
legacyInclusiveMs: sample.cold.legacyInclusiveMs,
maxOwnedSliceMs: sample.cold.work.maxSliceMs,
renderMs: round(sample.cold.commandToRenderMs),
longTasks: sample.cold.longTasks.length,
})),
);
console.log('OpenFreeMap same-tile acceptance passed:', report.summary);
} catch (error) {
report.passed = false;
report.failure = String(error?.stack || error);
console.error(error);
process.exitCode = 1;
} finally {
await fs.writeFile(OUTPUT, `${JSON.stringify(report, null, 2)}\n`);
await browser.close();
}
+1 -1
View File
@@ -310,7 +310,7 @@ try {
countFrames(2_500),
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
// Drive the actual sharpen slider: input event → handler →
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
// _setSharpenIntensity → governorRequestRender. Proves the wiring,
// not just the facade.
const slider = document.getElementById('sharpen-intensity-slider');
if (!slider) { resolve({ ok: false }); return; }
+140 -345
View File
@@ -81,6 +81,32 @@ function check(name, ok, detail = '') {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Radio lives only inside Context; a user reaches its controls by expanding
// Context and then Radio. Returns the prior collapse state so a step can put
// the panels back for the scenarios that follow.
async function revealRadioControls(page, targetId) {
const prior = await page.evaluate(async (id) => {
const manager = window.__godsEyeView.styleManager;
const collapsed = (panelId) => document.getElementById(panelId).classList.contains('collapsed');
const state = { context: collapsed('global-context-panel'), radio: collapsed('radio-panel') };
manager.setPanelCollapsed('global-context-panel', false, { explicit: true });
manager.setPanelCollapsed('radio-panel', false, { explicit: true });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
document.getElementById(id).scrollIntoView({ block: 'nearest' });
return state;
}, targetId);
await page.waitForSelector(`#${targetId}`, { visible: true });
return prior;
}
async function restoreRadioPanels(page, prior) {
await page.evaluate(({ context, radio }) => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('radio-panel', radio);
manager.setPanelCollapsed('global-context-panel', context);
}, prior);
}
// Native pick buffers and clustered overlay entries settle on rendered frames,
// not on wall-clock sleeps, particularly with software rendering.
async function settleRadioFrames(page) {
@@ -285,31 +311,16 @@ async function main() {
window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true);
});
const enablePanels = await revealRadioControls(page, 'radio-enable-btn');
const enableStartedAt = Date.now();
const initialDisclosure = await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
const mini = document.getElementById('context-radio-mini');
const enabledBefore = window.__godsEyeView.dataManager.isEnabled('radio');
button.click();
return {
enabledBefore,
enabledAfterDisclosure: window.__godsEyeView.dataManager.isEnabled('radio'),
expanded: button.getAttribute('aria-expanded'),
miniHidden: mini.hidden,
};
});
check(
'Radio launcher discloses compact controls without changing power',
!initialDisclosure.enabledBefore && !initialDisclosure.enabledAfterDisclosure
&& initialDisclosure.expanded === 'true' && !initialDisclosure.miniHidden,
JSON.stringify(initialDisclosure),
);
await page.$eval('#context-radio-mini-enable-btn', (button) => button.click());
await page.click('#radio-enable-btn');
await page.waitForFunction(() => {
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
return state.enabled && !state.loading && state.stationCount === 750;
});
const enableElapsedMs = Date.now() - enableStartedAt;
// Later scenarios start from the collapsed rail this step began with.
await restoreRadioPanels(page, enablePanels);
const initial = await page.evaluate(() => {
const module = window.__godsEyeView.dataManager.layers.get('radio').module;
const state = module.getUIState();
@@ -328,9 +339,6 @@ async function main() {
color: entry.dataset.radioColor,
})),
enablePressed: document.getElementById('radio-enable-btn').getAttribute('aria-pressed'),
launcherExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
launcherActive: document.getElementById('context-radio-toggle-btn').classList.contains('active'),
compactEnablePressed: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-pressed'),
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
tunerVisible: !document.getElementById('radio-tuner').hidden,
@@ -341,9 +349,8 @@ async function main() {
check('750-station catalog renders within the browser budget', initial.state.stationCount === 750 && enableElapsedMs < 5000, `${enableElapsedMs}ms`);
check('enable does not autoplay', initial.plays === 0 && initial.state.audioState === 'stopped');
check(
'compact and detailed Radio power controls reflect the layer manager independently of disclosure',
initial.enablePressed === 'true' && initial.compactEnablePressed === 'true'
&& initial.launcherExpanded === 'true' && initial.launcherActive,
'Radio power control reflects the layer manager after the panels collapse again',
initial.enablePressed === 'true',
);
check('dynamic canonical and genre categories reach the UI', initial.filterOptions >= 10, `${initial.filterOptions} options`);
const musicOption = initial.filterPalette.find((entry) => entry.id === 'music');
@@ -419,14 +426,10 @@ async function main() {
prevDisabled: document.getElementById('radio-prev-btn').disabled,
playDisabled: document.getElementById('radio-play-btn').disabled,
volumeDisabled: document.getElementById('radio-volume').disabled,
disclosureLabel: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
compactPowerLabel: document.getElementById('context-radio-mini-enable-btn').getAttribute('aria-label'),
compactPowerText: document.getElementById('context-radio-mini-enable-btn').textContent,
fullLabel: document.getElementById('radio-enable-btn').getAttribute('aria-label'),
fullText: document.getElementById('radio-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent,
playbackMessage: document.getElementById('radio-playback-state').textContent,
miniStation: document.getElementById('context-radio-mini-station').textContent,
dataRailText: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.textContent,
dataRailState: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.dataset.feedState,
dataRailLabel: document.querySelector('[data-layer-id="radio"] .data-toggle-btn')?.getAttribute('aria-label'),
@@ -468,14 +471,10 @@ async function main() {
&& uncertainPresentation.prevDisabled
&& uncertainPresentation.playDisabled
&& uncertainPresentation.volumeDisabled
&& /compact Radio controls/i.test(uncertainPresentation.disclosureLabel)
&& /uncertain/i.test(uncertainPresentation.compactPowerLabel)
&& uncertainPresentation.compactPowerText === 'RECONCILE'
&& /uncertain/i.test(uncertainPresentation.fullLabel)
&& uncertainPresentation.fullText === 'RECONCILE'
&& uncertainPresentation.layerState === 'UNCERTAIN'
&& /uncertain/i.test(uncertainPresentation.playbackMessage)
&& uncertainPresentation.miniStation === 'RADIO STATE UNCERTAIN'
&& uncertainPresentation.dataRailText === 'UNCERTAIN'
&& uncertainPresentation.dataRailState === 'uncertain'
&& /uncertain/i.test(uncertainPresentation.dataRailLabel)
@@ -517,10 +516,6 @@ async function main() {
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
full: document.getElementById('radio-enable-btn').textContent,
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent,
message: document.getElementById('radio-playback-state').textContent,
stopDisabled: document.getElementById('radio-stop-btn').disabled,
@@ -534,8 +529,6 @@ async function main() {
&& disablingPresentation.presentationActive === false
&& disablingPresentation.markerSourceVisible === false
&& disablingPresentation.full === 'DISABLING'
&& /compact Radio controls/i.test(disablingPresentation.disclosure)
&& disablingPresentation.compactPower === 'DISABLING'
&& disablingPresentation.layerState === 'DISABLING'
&& /disabling/i.test(disablingPresentation.message)
&& disablingPresentation.stopDisabled
@@ -592,10 +585,6 @@ async function main() {
presentationActive: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().presentationActive,
markerSourceVisible: window.__godsEyeView.viewer.dataSources.getByName('Radio stations')[0]?.show,
full: document.getElementById('radio-enable-btn').textContent,
disclosure: document.getElementById('context-radio-toggle-btn').getAttribute('aria-label'),
disclosureControls: document.getElementById('context-radio-toggle-btn').getAttribute('aria-controls'),
disclosureExpanded: document.getElementById('context-radio-toggle-btn').getAttribute('aria-expanded'),
compactPower: document.getElementById('context-radio-mini-enable-btn').textContent,
layerState: document.getElementById('radio-layer-state').textContent,
message: document.getElementById('radio-playback-state').textContent,
tunerHidden: document.getElementById('radio-tuner').hidden,
@@ -610,10 +599,6 @@ async function main() {
&& enablingPresentation.presentationActive === false
&& enablingPresentation.markerSourceVisible === false
&& enablingPresentation.full === 'ENABLING'
&& enablingPresentation.disclosure === 'Go to expanded Radio section'
&& enablingPresentation.disclosureControls === 'radio-panel'
&& enablingPresentation.disclosureExpanded === 'true'
&& enablingPresentation.compactPower === 'ENABLING'
&& enablingPresentation.layerState === 'ENABLING'
&& /enabling/i.test(enablingPresentation.message)
&& enablingPresentation.tunerHidden
@@ -1115,7 +1100,7 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationFalseMessage = document.getElementById('toast').textContent;
const installationFalseReleased = !document.getElementById('installations-search-btn').disabled;
const installationFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) {
@@ -1126,7 +1111,7 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationEnableRejectionMessage = document.getElementById('toast').textContent;
const installationEnableRejectionReleased = !document.getElementById('installations-search-btn').disabled;
const installationEnableRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
@@ -1138,13 +1123,13 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationRejectionMessage = document.getElementById('toast').textContent;
const installationRejectionReleased = !document.getElementById('installations-search-btn').disabled;
const installationRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
if (installations) installations.searchNearby = async () => false;
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationSearchFalseMessage = document.getElementById('toast').textContent;
const installationSearchFalseReleased = !document.getElementById('installations-search-btn').disabled;
const installationSearchFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
await new Promise((resolve) => setTimeout(resolve, 0));
return {
semanticFalseMessage,
@@ -1581,112 +1566,9 @@ async function main() {
JSON.stringify(directoryRefreshContinuity),
);
await page.select('#radio-filter', 'news');
check('launcher enables Radio without forcing Context open', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext);
check('enabled Radio stays nested in Context and leaves both panels collapsed', !initial.panelExpanded && !initial.contextExpanded && initial.nestedInContext);
check('full Play is available before a station is selected', !initial.state.selected && initial.fullPlayEnabled && initial.fullPlayLabel === 'Play nearest radio station');
const hoverOnlyDisclosure = await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
if (button.getAttribute('aria-expanded') === 'true') button.click();
return {
expanded: button.getAttribute('aria-expanded'),
hidden: document.getElementById('context-radio-mini').hidden,
};
});
await page.hover('#context-radio-toggle-btn');
await sleep(100);
const hoverOnlyVisible = await page.$eval('#context-radio-mini', (mini) => (
!mini.hidden && getComputedStyle(mini).display !== 'none'
));
check(
'hover alone does not disclose compact Radio controls',
hoverOnlyDisclosure.expanded === 'false' && hoverOnlyDisclosure.hidden && !hoverOnlyVisible,
JSON.stringify({ hoverOnlyDisclosure, hoverOnlyVisible }),
);
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => {
const button = document.getElementById('context-radio-toggle-btn');
const mini = document.getElementById('context-radio-mini');
return button.getAttribute('aria-expanded') === 'true'
&& !mini.hidden && getComputedStyle(mini).display !== 'none';
});
const compact = await page.evaluate(() => {
const mini = document.getElementById('context-radio-mini');
const toggle = document.getElementById('context-radio-toggle-btn');
const dock = document.getElementById('context-radio-dock');
const miniRect = mini.getBoundingClientRect();
const toggleRect = toggle.getBoundingClientRect();
const miniStyle = getComputedStyle(mini);
return {
visible: miniStyle.display !== 'none' && miniRect.width > 0 && miniRect.height > 0,
display: miniStyle.display,
visibility: miniStyle.visibility,
opacity: miniStyle.opacity,
miniRect: { top: miniRect.top, left: miniRect.left, width: miniRect.width, height: miniRect.height },
toggleRect: { top: toggleRect.top, left: toggleRect.left, width: toggleRect.width, height: toggleRect.height },
dockRect: (() => {
const rect = dock.getBoundingClientRect();
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
})(),
offsetParent: mini.offsetParent?.id || mini.offsetParent?.className || null,
prevEnabled: !document.getElementById('context-radio-mini-prev-btn').disabled,
playEnabled: !document.getElementById('context-radio-mini-play-btn').disabled,
playLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
nextEnabled: !document.getElementById('context-radio-mini-next-btn').disabled,
volumeEnabled: !document.getElementById('context-radio-mini-volume').disabled,
offsetBelowToggle: Math.round(miniRect.top - toggleRect.bottom),
};
});
check(
'explicit launcher disclosure reveals enabled compact Radio controls',
compact.visible && compact.prevEnabled && compact.playEnabled && compact.nextEnabled && compact.volumeEnabled,
JSON.stringify(compact),
);
check('compact Play is available before a station is selected', compact.playLabel === 'Play nearest radio station');
check('compact Radio controls sit 20px below the launcher', compact.offsetBelowToggle === 20, `${compact.offsetBelowToggle}px`);
const compactCloseDisclosure = await page.evaluate(() => {
const launcher = document.getElementById('context-radio-toggle-btn');
const close = document.getElementById('context-radio-mini-close-btn');
close.focus();
close.click();
const result = {
launcherExpanded: launcher.getAttribute('aria-expanded'),
compactHidden: document.getElementById('context-radio-mini').hidden,
focusRestored: document.activeElement === launcher,
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
};
launcher.click();
return result;
});
check(
'dedicated compact close hides only the compact Radio disclosure and restores launcher focus',
compactCloseDisclosure.launcherExpanded === 'false'
&& compactCloseDisclosure.compactHidden
&& compactCloseDisclosure.focusRestored
&& compactCloseDisclosure.radioCollapsed,
JSON.stringify(compactCloseDisclosure),
);
const compactTouchDisclosure = await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
button.click();
const closed = button.getAttribute('aria-expanded') === 'false'
&& document.getElementById('context-radio-mini').hidden;
button.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
pointerType: 'touch',
isPrimary: true,
}));
button.click();
return {
closed,
reopened: button.getAttribute('aria-expanded') === 'true'
&& !document.getElementById('context-radio-mini').hidden,
};
});
check(
'touch/coarse-pointer activation can close and reopen compact Radio controls',
compactTouchDisclosure.closed && compactTouchDisclosure.reopened,
JSON.stringify(compactTouchDisclosure),
);
const multiExpandedPanelLayout = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const ids = ['data-panel', 'scene-panel', 'pp-toggles', 'cctv-panel', 'global-context-panel'];
@@ -1834,7 +1716,14 @@ async function main() {
else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel);
// The restored classes are the panel's state again.
const has = (name) => panel.classList.contains(name);
manager._presentPanel(panel, {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
});
}
for (const [id, state] of Object.entries(prior.stacks)) {
const stack = document.getElementById(id);
@@ -2062,7 +1951,6 @@ async function main() {
hudVariant: manager.hud.getVariant(),
displayExpanded: display.getAttribute('aria-expanded') === 'true',
radioExpanded: radio.getAttribute('aria-expanded') === 'true',
contextRadioExpanded: document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === 'true',
focusId: document.activeElement?.id || null,
detectionMode: manager.getDetectionState().detectionMode,
models3dEnabled: manager._models3dEnabled,
@@ -2093,7 +1981,7 @@ async function main() {
manager._setCockpitDisplayPortalActive(true);
hud.hidden = false;
signal.hidden = false;
dataPanel.classList.remove('collapsed');
manager._presentPanel(dataPanel, { collapsed: false });
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
if (display.getAttribute('aria-expanded') === 'true') display.click();
@@ -2158,11 +2046,13 @@ async function main() {
for (let index = 0; index < 5; index += 1) {
recordLayoutStep();
if (index < 4) {
if (index < 2) {
hudLayout.value = index === 0 ? 'operator' : 'minimal';
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
} else {
// Off and back on in Minimal, so the toggle must restore a
// non-default layout before the cycle returns to Tactical.
if (index === 1 || index === 2) {
hudToggle.click();
} else {
hudLayout.value = index === 0 ? 'minimal' : 'tactical';
hudLayout.dispatchEvent(new Event('change', { bubbles: true }));
}
await waitForHudSettle();
}
@@ -2176,8 +2066,7 @@ async function main() {
contextCard.hidden = false;
// Expand the panel the same way this block already does at entry, so the
// portaled Display groups measured further down keep their DOM home.
dataPanel.classList.remove('collapsed', 'layout-auto-collapsed');
manager._syncPanelCollapseButton(dataPanel);
manager._presentPanel(dataPanel, { collapsed: false, auto: null });
manager._syncLeftPanelAdaptiveLayout();
await waitForLayout();
const panelRect = dataPanel.getBoundingClientRect();
@@ -2271,24 +2160,27 @@ async function main() {
.every((control) => control.closest('.pp-toggle-group')?.parentElement
?.closest('#cockpit-display-panel'));
display.click();
await waitForLayout();
manager.cockpitView.syncSignalLayout();
radio.click();
await waitForLayout();
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
&& !document.getElementById('cockpit-radio-panel').hidden;
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
radio.click();
await waitForLayout();
manager.cockpitView.syncSignalLayout();
radio.click();
await waitForLayout();
const radioNavigationAvailable = [
document.getElementById('cockpit-radio-prev-btn'),
document.getElementById('cockpit-radio-next-btn'),
].every((button) => button?.isConnected && !button.disabled);
const detailsExcluded = !document.querySelector(
'#cockpit-radio-panel #context-radio-details-btn, '
+ '#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
'#cockpit-radio-panel #radio-filter, #cockpit-radio-panel #radio-tuner',
);
const scenesHidden = getComputedStyle(scene).display === 'none';
radio.click();
@@ -2331,7 +2223,7 @@ async function main() {
scenesHidden,
mapProviderUtilityStable,
hudInteractionsExact: layoutSteps.map((step) => step.hud).join('>')
=== 'TACTICAL>OPERATOR>MINIMAL>OFF>MINIMAL',
=== 'TACTICAL>MINIMAL>OFF>MINIMAL>TACTICAL',
// The accordion clears the topline, Cockpit cards, and Cesium credits
// across every HUD layout. Its list owns any overflow internally.
leftLaneClearAcrossHudCycle: layoutSteps.every((step) => (
@@ -2381,7 +2273,6 @@ async function main() {
manager.setPanelCollapsed('global-context-panel', prior.contextCollapsed);
if (display.getAttribute('aria-expanded') !== String(prior.displayExpanded)) display.click();
if (radio.getAttribute('aria-expanded') !== String(prior.radioExpanded)) radio.click();
manager._setRadioDisclosure(prior.contextRadioExpanded);
hud.hidden = prior.hudHidden;
signal.hidden = prior.signalHidden;
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
@@ -2402,7 +2293,14 @@ async function main() {
else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel);
// The restored classes are the panel's state again.
const has = (name) => panel.classList.contains(name);
manager._presentPanel(panel, {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
});
}
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
localStorage.clear();
@@ -2416,7 +2314,6 @@ async function main() {
&& document.getElementById('global-context-panel').classList.contains('collapsed') === prior.contextCollapsed
&& display.getAttribute('aria-expanded') === String(prior.displayExpanded)
&& radio.getAttribute('aria-expanded') === String(prior.radioExpanded)
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
&& hud.hidden === prior.hudHidden
&& signal.hidden === prior.signalHidden
&& intelHud.style.getPropertyValue('transition') === priorHudTransition
@@ -2472,21 +2369,20 @@ async function main() {
check('first Play scenario starts without a leaked Radio selection', firstPlayPrecondition === null,
`selected=${firstPlayPrecondition}`);
await page.select('#radio-filter', 'all');
await page.$eval('#context-radio-toggle-btn', (button) => {
if (button.getAttribute('aria-expanded') !== 'true') button.click();
});
await page.evaluate(() => {
const camera = window.__godsEyeView.viewer.camera;
window.__qaFirstPlayFlyToCalls = [];
window.__qaFirstPlayOriginalFlyTo = camera.flyTo;
camera.flyTo = (options) => window.__qaFirstPlayFlyToCalls.push(options);
document.getElementById('context-radio-toggle-btn').focus();
});
await page.$eval('#context-radio-mini-play-btn', (button) => button.click());
const firstPlayPanels = await revealRadioControls(page, 'radio-play-btn');
await page.focus('#radio-play-btn');
await page.click('#radio-play-btn');
await page.waitForFunction(() => {
const state = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState();
return state.audioState === 'playing' || state.audioState === 'error';
});
await restoreRadioPanels(page, firstPlayPanels);
const postMicroDragPlayback = await page.evaluate(async () => {
const mod = window.__godsEyeView.dataManager.layers.get('radio').module;
let state = mod.getUIState();
@@ -2593,59 +2489,19 @@ async function main() {
);
await page.select('#radio-filter', 'news');
await page.$eval('#context-radio-mini-volume', (input) => {
await page.$eval('#radio-volume', (input) => {
input.value = '35';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const compactVolume = await page.evaluate(() => ({
const radioVolume = await page.evaluate(() => ({
volume: window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().volume,
full: document.getElementById('radio-volume').value,
mini: document.getElementById('context-radio-mini-volume').value,
label: document.getElementById('radio-volume-value').textContent,
}));
check('compact volume shares the full Radio player state', Math.abs(compactVolume.volume - 0.35) < 0.001 && compactVolume.full === '35' && compactVolume.mini === '35');
const compactDisclosureContinuity = await page.evaluate(async () => {
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true);
manager._setRadioDisclosure(true);
await radio.play({ origin: 'user' });
const before = radio.getUIState();
const disclosure = document.getElementById('context-radio-toggle-btn');
if (disclosure.getAttribute('aria-expanded') !== 'true') disclosure.click();
disclosure.click();
const closed = {
expanded: disclosure.getAttribute('aria-expanded'),
hidden: document.getElementById('context-radio-mini').hidden,
state: radio.getUIState(),
};
disclosure.click();
return {
before,
closed,
reopened: disclosure.getAttribute('aria-expanded'),
miniHidden: document.getElementById('context-radio-mini').hidden,
after: radio.getUIState(),
};
});
check(
'compact disclosure preserves playback, station, filter, and volume continuity',
compactDisclosureContinuity.closed.expanded === 'false'
&& compactDisclosureContinuity.closed.hidden
&& compactDisclosureContinuity.reopened === 'true'
&& !compactDisclosureContinuity.miniHidden
&& compactDisclosureContinuity.before.audioState === 'playing'
&& compactDisclosureContinuity.closed.state.audioState === 'playing'
&& compactDisclosureContinuity.after.audioState === 'playing'
&& compactDisclosureContinuity.after.selected?.id === compactDisclosureContinuity.before.selected?.id
&& compactDisclosureContinuity.after.filter === compactDisclosureContinuity.before.filter
&& compactDisclosureContinuity.after.volume === compactDisclosureContinuity.before.volume,
JSON.stringify({
before: compactDisclosureContinuity.before,
closed: compactDisclosureContinuity.closed,
reopened: compactDisclosureContinuity.reopened,
after: compactDisclosureContinuity.after,
}),
'full Radio volume drives the Radio player state',
Math.abs(radioVolume.volume - 0.35) < 0.001 && radioVolume.full === '35' && radioVolume.label === '35%',
JSON.stringify(radioVolume),
);
const voicePause = await page.evaluate(async () => {
@@ -2709,11 +2565,13 @@ async function main() {
idleVoice.audioState === 'paused',
idleVoice.audioState,
);
const manualPlayTarget = await page.evaluate(() => {
const manualPlayTarget = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true);
manager._setRadioDisclosure(true);
const button = document.getElementById('context-radio-mini-play-btn');
manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const button = document.getElementById('radio-play-btn');
button.scrollIntoView({ block: 'nearest' });
const rect = button.getBoundingClientRect();
const style = getComputedStyle(button);
return {
@@ -2728,12 +2586,12 @@ async function main() {
};
});
check(
'manual voice takeover targets the visible compact Radio Play control',
'manual voice takeover targets the visible full Radio Play control',
manualPlayTarget.visible && manualPlayTarget.enabled,
JSON.stringify(manualPlayTarget),
);
if (manualPlayTarget.visible && manualPlayTarget.enabled) {
await page.click('#context-radio-mini-play-btn');
await page.click('#radio-play-btn');
await page.waitForFunction(() => (
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'
&& window.__godsEyeView.voiceCommands.status === 'idle'
@@ -2781,7 +2639,6 @@ async function main() {
await page.evaluate(() => window.__godsEyeView.voiceCommands.setStatus('idle', 'QA complete'));
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
manager._setRadioDisclosure(false);
manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -2860,14 +2717,20 @@ async function main() {
};
const playsBefore = window.__qaRadioPlayCalls.length;
const frozenSignature = style._radioControls._radioTunerBandSignature;
// The tuner renders from the Radio store; let Vue patch before reading.
const rendered = () => Promise.resolve();
pointer('pointerdown', xFor(0));
await rendered();
const left = snapshot();
const centerCoordinate = (count - 1) / 2;
pointer('pointermove', xFor(centerCoordinate));
await rendered();
const center = snapshot();
pointer('pointermove', xFor(centerCoordinate + 2));
await rendered();
const shifted = snapshot();
pointer('pointermove', xFor(count - 1));
await rendered();
const right = snapshot();
const shiftedByIndex = new Map(shifted.ticks.map((tick) => [tick.index, tick.x]));
const sharedTick = center.ticks.find((tick) => shiftedByIndex.has(tick.index));
@@ -2924,7 +2787,7 @@ async function main() {
tapeDelta: absoluteTuner.tapeDelta,
}),
);
const tunerCancelRestore = await page.evaluate(() => {
const tunerCancelRestore = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const style = gev.styleManager;
const radio = gev.dataManager.layers.get('radio').module;
@@ -2957,11 +2820,14 @@ async function main() {
const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2;
pointer('pointerdown', before.slot);
pointer('pointermove', targetIndex);
// The tuner renders from the Radio store; let Vue patch before reading.
await Promise.resolve();
const shiftedSlot = Number(slider.value);
const previewBeforeCancel = gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const flyToCallsBeforeCancel = flyToCalls.length;
pointer('pointercancel', targetIndex);
await Promise.resolve();
const afterStations = style._radioControls._radioTunerStations;
const afterState = radio.getUIState();
const after = {
@@ -3117,7 +2983,7 @@ async function main() {
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
});
const tunerRefreshRelease = await page.evaluate((target) => {
const tunerRefreshRelease = await page.evaluate(async (target) => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
@@ -3130,6 +2996,10 @@ async function main() {
clientY: target.clientY,
}));
const state = gev.dataManager.layers.get('radio').module.getUIState();
// The playback line renders from the Radio store once Vue flushes.
const message = await Promise.resolve().then(
() => document.getElementById('radio-playback-state').textContent,
);
return {
displayedBeforeRelease,
frozenGeneration,
@@ -3138,7 +3008,7 @@ async function main() {
plays: window.__qaRadioPlayCalls.length,
value: document.getElementById('radio-tuner-value').textContent,
stationText: document.getElementById('radio-tuner-station').textContent,
message: document.getElementById('radio-playback-state').textContent,
message,
targetId: target.id,
};
}, tunerRefreshTarget);
@@ -3185,6 +3055,8 @@ async function main() {
pointer('pointermove', targetIndex);
const previewPlays = window.__qaRadioPlayCalls.length;
pointer('pointerup', targetIndex);
// The tuner renders from the Radio store; sample once Vue has patched.
await Promise.resolve();
const samples = [needle.getBoundingClientRect().left];
await new Promise((resolve) => requestAnimationFrame(resolve));
samples.push(needle.getBoundingClientRect().left);
@@ -3201,20 +3073,27 @@ async function main() {
expectedSlot: targetIndex,
previewPlayDelta: previewPlays - playsBefore,
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
audioState: state.audioState,
tuningStatic: state.tuningStatic,
samples,
spread: Math.max(...samples) - Math.min(...samples),
};
});
check(
'pointer preview never plays and release commits the nearest station once without a needle sweep',
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
tunerDirectRelease.targetId
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
&& tunerDirectRelease.previewPlayDelta === 0
&& tunerDirectRelease.commitPlayDelta === 1
&& tunerDirectRelease.commitPlayDelta === 0
&& tunerDirectRelease.audioState === 'stopped'
&& !tunerDirectRelease.tuningStatic
&& tunerDirectRelease.spread < 0.5,
JSON.stringify(tunerDirectRelease),
);
// The release plays only if a stream was active when the drag began.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
// Earlier cases scroll and resize the directory. Bring the physical drag
// target back into its scroller before deriving viewport mouse coordinates.
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
@@ -3622,6 +3501,9 @@ async function main() {
});
});
await sleep(500);
// Next left the Radio stopped; a release plays only from an active stream.
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
const failedTuner = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
@@ -3820,7 +3702,14 @@ async function main() {
radio.stopPlayback();
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
// Next plays only while a stream is active, so start from a playing station.
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
});
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
await page.evaluate(() => {
const gev = window.__godsEyeView;
const camera = gev.viewer.camera;
const radio = gev.dataManager.layers.get('radio').module;
const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
@@ -3835,22 +3724,22 @@ async function main() {
gev.dataManager.layers.get('flights').enabled = true;
});
await sleep(500);
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('global-context-panel', true));
await page.evaluate(() => {
const button = document.getElementById('context-radio-toggle-btn');
if (button.getAttribute('aria-expanded') !== 'true') button.click();
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
});
await page.waitForSelector('#context-radio-mini', { visible: true });
await page.waitForSelector('#radio-next-btn', { visible: true });
await page.evaluate(() => {
window.__qaRadioCameraHeight = window.__godsEyeView.viewer.camera.positionCartographic.height;
});
const radioOnlyNextDispatched = await page.evaluate(() => {
const button = document.getElementById('context-radio-mini-next-btn');
const button = document.getElementById('radio-next-btn');
if (!button) return false;
button.click();
return true;
});
if (!radioOnlyNextDispatched) throw new Error('Radio compact next button unavailable');
if (!radioOnlyNextDispatched) throw new Error('Radio next button unavailable');
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
@@ -3885,7 +3774,7 @@ async function main() {
flyToCalls: window.__qaRadioFlyToCalls.length,
flightTargets,
cameraHeight: window.__qaRadioCameraHeight,
compactPlayLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
playLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
regularMarkerSize: Array.from({ length: window.__godsEyeView.viewer.dataSources.length }, (_, index) => window.__godsEyeView.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations')?.entities.values[0]?.point?.pixelSize?.getValue(),
selectedMarkerSize: selectedEntity?.point?.pixelSize?.getValue(),
@@ -3898,7 +3787,7 @@ async function main() {
};
});
check(
'compact Next follows stable directory order and retries its next available station',
'Radio Next follows stable directory order and retries its next available station',
playing.state.selected?.id === playing.expectedFallback?.id
&& playing.calls[0] === playing.expectedPrimary?.streamUrl
&& playing.calls[1] === playing.expectedFallback?.streamUrl,
@@ -3909,9 +3798,9 @@ async function main() {
fallback: playing.expectedFallback?.id,
}),
);
check('compact next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.compactPlayLabel.startsWith('Pause'));
check('Radio next is explicit playback and updates play/pause state', playing.calls.length === 2 && playing.playLabel.startsWith('Pause'));
check(
'compact next rotates toward its station without changing zoom, including fallback',
'Radio next rotates toward its station without changing zoom, including fallback',
playing.flyToCalls === 2
&& Math.abs(playing.flightTargets[0]?.lat - playing.expectedPrimary?.lat) < 0.001
&& Math.abs(playing.flightTargets[0]?.lon - playing.expectedPrimary?.lon) < 0.001
@@ -3950,11 +3839,11 @@ async function main() {
};
return [
await run('flights', 'radio-next-btn'),
await run('military', 'context-radio-mini-prev-btn'),
await run('military', 'radio-prev-btn'),
];
});
check(
'full and compact Radio navigation preserve Flights/Military camera ownership',
'full Radio Next and Prev navigation preserve Flights/Military camera ownership',
trackedRadioControls.every((sample) => sample.afterStationId
&& sample.afterStationId !== sample.beforeStationId
&& sample.flyToDelta === 0
@@ -3968,7 +3857,7 @@ async function main() {
viewer.trackedEntity = undefined;
window.__qaRadioDelayNextFailure = true;
const beforeFlyToCalls = window.__qaRadioFlyToCalls.length;
document.getElementById('context-radio-mini-next-btn')?.click();
document.getElementById('radio-next-btn')?.click();
return { beforeFlyToCalls };
});
await page.waitForFunction(() => typeof window.__qaRejectRadioPlay === 'function');
@@ -4244,7 +4133,7 @@ async function main() {
window.__godsEyeView.dataManager.layers.get('flights').enabled = false;
});
const radioOnlyNextClicked = await page.evaluate(() => {
const button = document.getElementById('context-radio-mini-next-btn');
const button = document.getElementById('radio-next-btn');
if (!(button instanceof HTMLButtonElement)) return false;
button.click();
return true;
@@ -4508,106 +4397,12 @@ async function main() {
&& companion.radioCollapsed && !companion.contextCollapsed && companion.nested,
);
const expandedContextRadioBefore = await page.evaluate(() => {
const gev = window.__godsEyeView;
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
scroller.scrollTop = 0;
const camera = gev.viewer.camera.positionWC;
return {
scrollTop: scroller.scrollTop,
pageY: window.scrollY,
camera: { x: camera.x, y: camera.y, z: camera.z },
plays: window.__qaRadioPlayCalls.length,
};
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
await page.waitForFunction((priorScroll) => (
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
const expandedContextRadioAfter = await page.evaluate(() => {
const gev = window.__godsEyeView;
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
const launcher = document.getElementById('context-radio-toggle-btn');
const radio = document.getElementById('radio-panel');
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
const camera = gev.viewer.camera.positionWC;
return {
ariaControls: launcher.getAttribute('aria-controls'),
expanded: launcher.getAttribute('aria-expanded'),
label: launcher.getAttribute('aria-label'),
radioCollapsed: radio.classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
scrollTop: scroller.scrollTop,
focusTarget: document.activeElement?.getAttribute('data-collapse-target'),
pageY: window.scrollY,
camera: { x: camera.x, y: camera.y, z: camera.z },
plays: window.__qaRadioPlayCalls.length,
state: gev.dataManager.layers.get('radio').module.getUIState().audioState,
};
});
const expandedContextRadioCameraDelta = Math.hypot(
expandedContextRadioAfter.camera.x - expandedContextRadioBefore.camera.x,
expandedContextRadioAfter.camera.y - expandedContextRadioBefore.camera.y,
expandedContextRadioAfter.camera.z - expandedContextRadioBefore.camera.z,
);
check(
'expanded Context Radio icon reveals and scrolls to embedded Radio without an overlay or playback/camera theft',
expandedContextRadioAfter.ariaControls === 'radio-panel'
&& expandedContextRadioAfter.expanded === 'true'
&& expandedContextRadioAfter.label === 'Go to expanded Radio section'
&& !expandedContextRadioAfter.radioCollapsed
&& expandedContextRadioAfter.compactHidden
&& expandedContextRadioAfter.scrollTop > expandedContextRadioBefore.scrollTop
&& expandedContextRadioAfter.focusTarget === 'radio-panel'
&& expandedContextRadioAfter.pageY === expandedContextRadioBefore.pageY
&& expandedContextRadioCameraDelta < 0.01
&& expandedContextRadioAfter.plays === expandedContextRadioBefore.plays
&& expandedContextRadioAfter.state === 'playing',
JSON.stringify({
before: expandedContextRadioBefore,
after: expandedContextRadioAfter,
cameraDelta: expandedContextRadioCameraDelta,
}),
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('global-context-panel', true);
manager._setRadioDisclosure(true);
manager.setPanelCollapsed('global-context-panel', false);
manager.setPanelCollapsed('radio-panel', false);
});
await page.waitForFunction(() => {
const close = document.getElementById('context-radio-mini-close-btn');
const rect = close.getBoundingClientRect();
return !document.getElementById('context-radio-mini').hidden && rect.width > 0 && rect.height > 0;
});
await page.click('#context-radio-mini-close-btn');
const closedCompact = await page.evaluate(() => {
const launcher = document.getElementById('context-radio-toggle-btn');
return {
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
focusRestored: document.activeElement === launcher,
};
});
await page.click('#context-radio-toggle-btn');
await page.waitForFunction(() => !document.getElementById('context-radio-mini').hidden);
await page.click('#context-radio-details-btn');
const repeatedOpen = await page.evaluate(() => ({
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
compactHidden: document.getElementById('context-radio-mini').hidden,
}));
const splitDetailActions = { closedCompact, repeatedOpen };
check(
'collapsed Context keeps compact close and full-panel open as separate stable actions',
!splitDetailActions.closedCompact.radioCollapsed
&& splitDetailActions.closedCompact.compactHidden
&& splitDetailActions.closedCompact.focusRestored
&& !splitDetailActions.repeatedOpen.radioCollapsed
&& splitDetailActions.repeatedOpen.compactHidden,
JSON.stringify(splitDetailActions),
);
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
await page.click('#global-context-flights-btn');
await page.waitForFunction(() => !document.getElementById('context-flights-view').hidden);
+41 -5
View File
@@ -106,6 +106,39 @@ try {
),
),
);
// A long rename keeps its editor visible inside the shot row.
await page.click('.scene-shot-label', { count: 2 });
await page.waitForSelector('.scene-shot-rename', { visible: true });
await page.type(
'.scene-shot-rename',
' with a much longer title that runs well past the shot row',
);
await page.keyboard.press('End');
check(
'A long rename keeps the editor visible inside its shot row',
await page.evaluate(() => {
// The label clips with an ellipsis, which hides an editor that
// overflows it, so its content must fit its width.
const input = document.querySelector('.scene-shot-rename');
const label = input?.closest('.scene-shot-label');
const box = input?.getBoundingClientRect();
const style = input && getComputedStyle(input);
return (
!!box &&
box.width > 120 &&
box.height > 10 &&
label.scrollWidth <= label.clientWidth + 1 &&
style.visibility === 'visible' &&
style.borderTopWidth !== '0px' &&
document.activeElement === input
);
}),
);
await page.keyboard.press('Escape');
await page.waitForFunction(
() => !document.querySelector('.scene-shot-rename'),
);
await page.click('#scene-capture-btn');
await page.waitForFunction(
() => document.querySelectorAll('.scene-shot-row').length === 2,
@@ -409,12 +442,12 @@ try {
return '{"scenes":[]}';
},
});
// A shot row held across disposal must not act once it is revoked.
const retainedLabel = document.querySelector('.scene-shot-label');
const stopping = director.destroy();
const notificationsAtStop = window.__qaSceneState.length;
const stoppedSynchronously =
controls.destroyed &&
controls.removers.length === 0 &&
controls.rowRemovers.length === 0;
const stoppedSynchronously = controls.destroyed;
retainedLabel?.click();
document.getElementById('scene-new-btn').click();
controls.run('capture');
finish();
@@ -423,6 +456,7 @@ try {
await director.destroy();
return {
stoppedSynchronously,
heldRowAction: Boolean(retainedLabel),
stateStopped: window.__qaSceneState.length === notificationsAtStop,
sameProject: director._project === project,
noLateStatus:
@@ -432,7 +466,9 @@ try {
});
check(
'Disposal revokes fixed and row actions before waiting for scene work',
teardown.stoppedSynchronously && teardown.rowsGone,
teardown.stoppedSynchronously &&
teardown.heldRowAction &&
teardown.rowsGone,
);
check(
'Late import completion cannot replace the disposed project or status',
+29 -25
View File
@@ -4,7 +4,7 @@
* file dropped from the desktop, proved in a real browser.
*
* The dialog draws a translated row over the native file input
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
* (src/ui/views/panels/SceneDialog.vue). The input must still cover the row,
* or drops miss it. Drops a file on the row's centre via CDP and expects
* `input.files` to hold it.
*
@@ -15,31 +15,34 @@
* drop selects the file, 1 otherwise.
*/
import http from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import puppeteer from 'puppeteer';
import { createServer } from 'vite';
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TYPES = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.html': 'text/html',
};
// The scene panels view draws the dialog; Vite compiles it for the page.
const PAGE = `<!doctype html><html lang="en"><body>
<div id="view"></div>
<script type="module">
import { createApp, nextTick } from 'vue';
import { applicationPinia } from '/src/ui/vueRuntime.js';
import LayerPanelsView from '/src/ui/views/LayerPanelsView.vue';
import { createSceneDialog } from '/src/ui/sceneSharing.js';
window.openDialog = () => {
createApp(LayerPanelsView).use(applicationPinia()).mount('#view');
window.openDialog = async () => {
const dialog = createSceneDialog('Share selected scene', () => {});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
files.multiple = true;
window.filesInput = files;
// Centre of the visible row (the wrapper's last child).
const row = files.parentElement.lastElementChild;
const r = row.getBoundingClientRect();
dialog.input('Choose data-pack files', '', { type: 'file', multiple: true });
await nextTick();
const input = document.querySelector('[data-director-dialog] input[type=file]');
window.filesInput = input;
// Centre of the visible row (the label's last child).
const r = input.parentElement.lastElementChild.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
window.ready = true;
@@ -56,21 +59,21 @@ async function main() {
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
const pack = path.join(dir, 'pack.json');
await writeFile(pack, '{}');
const server = http.createServer(async (req, res) => {
const vite = await createServer({
root: ROOT,
configFile: false,
logLevel: 'error',
plugins: [vue(VUE_PLUGIN_OPTIONS)],
server: { middlewareMode: true, hmr: false },
appType: 'custom',
});
const server = http.createServer((req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(PAGE);
return;
}
try {
const body = await readFile(path.join(ROOT, req.url));
res.writeHead(200, {
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
});
res.end(body);
} catch {
res.writeHead(404).end();
}
vite.middlewares(req, res, () => res.writeHead(404).end());
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
@@ -99,6 +102,7 @@ async function main() {
} finally {
await browser.close();
server.close();
await vite.close();
await rm(dir, { recursive: true, force: true });
}
}
+674
View File
@@ -0,0 +1,674 @@
#!/usr/bin/env node
/**
* Scope and HUD browser acceptance. Requires the development server.
* Run: node scripts/qa-scope-hud.mjs
* Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo).
* Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space,
* feather 0/11/100/200, layout independence,
* share/scene restore, and visible text backing including rotated metadata.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
const check = (name, passed, detail) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) {
failures += 1;
if (detail) console.log(JSON.stringify(detail));
}
};
const readScope = () =>
page.evaluate(() => {
const scope = window.__godsEyeView.styleManager.getVisualState().scope;
const canvas = document.getElementById('scope-mask');
const context = canvas.getContext('2d');
const radialX = Math.round(
canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35,
);
return {
enabled: scope.enabled,
feather: scope.featherPct / 100,
cornerAlpha: context.getImageData(1, 1, 1, 1).data[3],
radialAlpha: context.getImageData(
radialX,
Math.round(canvas.height / 2),
1,
1,
).data[3],
checked: document.getElementById('scope-toggle').checked,
disabled: document.getElementById('scope-feather-slider').disabled,
value: Number(document.getElementById('scope-feather-slider').value),
readout: document.getElementById('scope-feather-value').textContent,
};
});
const readHudGlyphs = () =>
page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const probe = document.createElement('span');
probe.style.display = 'none';
hud.appendChild(probe);
const baseline = {
color: getComputedStyle(probe).color,
shadow: getComputedStyle(probe).textShadow,
};
probe.remove();
const glyphs = [
'.hud-mode',
'.hud-system',
'.hud-summary-label',
'.hud-summary',
'.hud-orbital',
'#hud-mgrs',
'#hud-coll',
].map((selector) => {
const el = hud.querySelector(selector);
const style = getComputedStyle(el);
let opacity = 1;
for (
let current = el;
current && current !== hud;
current = current.parentElement
)
opacity *= Number(getComputedStyle(current).opacity);
return {
selector,
color: style.color,
shadow: style.textShadow,
opacity,
};
});
return { baseline, glyphs };
});
async function settleTextBackings(enabled) {
// The pre-existing active-style value transitions all properties for 300ms.
// Wait for its backing to finish changing before inspecting or capturing it.
await page
.waitForFunction(
(scopeEnabled) => {
const expected = scopeEnabled
? 'rgba(0, 0, 0, 0)'
: 'rgba(0, 0, 0, 0.7)';
return [
...document.querySelectorAll(
'#intel-hud .hud-readout, .scene-text-holdout',
),
].every(
(el) =>
getComputedStyle(el).backgroundColor === expected &&
getComputedStyle(el).boxShadow === 'none',
);
},
{ timeout: 2_000 },
enabled,
)
.catch(() => null);
}
const readSceneChrome = () =>
page.evaluate(() => {
const title = document.querySelector('#title-bar h1');
const eye = title.querySelector('.title-logo');
const subtitle = document.querySelector('#title-bar .subtitle');
const label = document.querySelector('#style-indicator .indicator-label');
const rect = (el, text = false) => {
if (!el.getClientRects().length) return null;
const range = document.createRange();
range.selectNodeContents(el);
const box = text
? range.getBoundingClientRect()
: el.getBoundingClientRect();
return {
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
};
};
const titleBox = rect(title);
const eyeBox = rect(eye);
const backing = getComputedStyle(title, '::before');
const plate = {
left: titleBox.left + parseFloat(backing.left),
top: titleBox.top + parseFloat(backing.top),
right: titleBox.right - parseFloat(backing.right),
bottom: titleBox.bottom - parseFloat(backing.bottom),
};
const contains = (box) =>
Object.entries(box).every(([side, value]) =>
['left', 'top'].includes(side)
? plate[side] <= value + 0.5
: plate[side] >= value - 0.5,
);
const probe = document.createElement('span');
document.body.appendChild(probe);
probe.style.color = 'var(--text-dim)';
const dim = getComputedStyle(probe).color;
probe.style.color = 'var(--text-secondary)';
const secondary = getComputedStyle(probe).color;
probe.remove();
return {
positions: {
title: titleBox,
eye: eyeBox,
text: rect(title.querySelector('[translate="no"]'), true),
subtitle: rect(subtitle, true),
},
titleUnbacked: backing.backgroundColor === 'rgba(0, 0, 0, 0)',
titleCovered:
backing.position === 'absolute' &&
backing.backgroundColor === 'rgba(0, 0, 0, 0.7)' &&
contains(titleBox) &&
contains(eyeBox),
leftAligned:
!rect(subtitle) || Math.abs(plate.left - rect(subtitle).left) <= 0.5,
colors: [getComputedStyle(subtitle).color, getComputedStyle(label).color],
dim,
secondary,
};
});
function checkSceneChrome(name, before, after) {
const stable = Object.keys(before.positions).every((key) => {
const a = before.positions[key];
const b = after.positions[key];
return a && b
? Object.keys(a).every((side) => Math.abs(a[side] - b[side]) <= 0.5)
: a === b;
});
check(`${name}: title, eye and subtitle stay in place across Scope`, stable, {
before,
after,
});
check(
`${name}: OFF title plate covers the eye and aligns with the subtitle`,
before.titleUnbacked && after.titleCovered && after.leftAligned,
after,
);
check(
`${name}: secondary labels brighten only with Scope OFF`,
before.colors.every((color) => color === before.dim) &&
after.colors.every((color) => color === after.secondary),
{ before: before.colors, after: after.colors },
);
}
async function openApplication(url) {
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60_000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return (
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
}
try {
await page.setViewport({ width: 1440, height: 900 });
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
url.searchParams.set('welcome', '0');
// This browser has a fresh profile. Start without authored state so the
// first assertion exercises the defaults rather than a share-link restore.
url.hash = '';
await openApplication(url);
const fresh = await readScope();
check(
'bare-URL fresh boot opens Scope ON with 100% feather',
fresh.enabled &&
fresh.checked &&
!fresh.disabled &&
fresh.feather === 1 &&
fresh.value === 100 &&
fresh.readout === '100%' &&
fresh.cornerAlpha > 0,
fresh,
);
// Use a known city view for canvas and readability checks below.
url.hash =
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l=';
// Changing only the hash does not rerun startup restoration. Leave the app
// document first so this fixture is applied by a new application boot.
await page.goto('about:blank');
await openApplication(url);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
});
manager.hud.setMode('on');
manager.setStyle('normal');
});
const initial = await readScope();
check(
'Austin fixture restores Scope ON and 100% feather',
initial.enabled &&
initial.checked &&
!initial.disabled &&
initial.feather === 1 &&
initial.value === 100 &&
initial.readout === '100%' &&
initial.cornerAlpha > 0,
initial,
);
const name = await page.$eval('#scope-toggle', (el) => ({
type: el.type,
label: el.labels?.[0]?.textContent.trim(),
}));
check(
'Scope uses a native checkbox with a visible label',
name.type === 'checkbox' && name.label?.includes('Scope'),
name,
);
await page.click('#scope-toggle');
const off = await readScope();
check(
'click disables Scope and its feather slider',
!off.enabled &&
!off.checked &&
off.disabled &&
off.feather === 1 &&
off.cornerAlpha === 0,
off,
);
await page.focus('#scope-toggle');
await page.keyboard.press('Space');
const on = await readScope();
check(
'Space re-enables the native checkbox without losing feather',
on.enabled &&
on.checked &&
!on.disabled &&
on.feather === 1 &&
on.cornerAlpha > 0,
on,
);
const paintedAlpha = [];
for (const pct of [0, 11, 100, 200]) {
await page.$eval(
'#scope-feather-slider',
(el, value) => {
el.value = String(value);
el.dispatchEvent(new Event('input', { bubbles: true }));
},
pct,
);
const state = await readScope();
paintedAlpha.push(state.radialAlpha);
check(
`feather ${pct}% reaches the mask and readout`,
state.value === pct &&
state.feather === pct / 100 &&
state.readout === `${pct}%`,
state,
);
}
check(
'painted gradient widens beyond the old 100% ceiling',
paintedAlpha[0] === 0 &&
paintedAlpha[1] === 0 &&
paintedAlpha[2] > 0 &&
paintedAlpha[3] > paintedAlpha[2],
paintedAlpha,
);
await page.focus('#scope-feather-slider');
await page.keyboard.press('Home');
check('native Home reaches the hard edge', (await readScope()).feather === 0);
await page.keyboard.press('End');
check(
'native End reaches the expanded 200% maximum',
(await readScope()).feather === 2,
);
await page.click('#scope-toggle');
for (const layout of ['minimal', 'cyber', 'tactical']) {
await page.select('#hud-layout-select', layout);
const state = await readScope();
check(
`Scope OFF and 200% survive ${layout}`,
!state.enabled &&
!state.checked &&
state.disabled &&
state.feather === 2 &&
state.value === 200 &&
state.cornerAlpha === 0,
state,
);
}
const restored = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const links = manager.shareLinkManager;
manager._syncShareState();
const params = links._buildHashParams();
const encoded = { enabled: params.get('sc'), feather: params.get('scf') };
await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } });
await manager._visualSettings.restoreShareState({
scopeEnabled: encoded.enabled === '1',
scopeFeatherPct: Number(encoded.feather),
});
const shared = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: true, featherPct: 200 },
});
const scene = manager.getVisualState().scope;
await manager.applyVisualState({
scope: { enabled: false, featherPct: 200 },
});
return { encoded, shared, scene };
});
check(
'share state serializes and restores Scope OFF with 200%',
restored.encoded.enabled === '0' &&
restored.encoded.feather === '200' &&
restored.shared.enabled === false &&
restored.shared.featherPct === 200,
restored,
);
check(
'scene state restores Scope ON with 200%',
restored.scene.enabled === true && restored.scene.featherPct === 200,
restored,
);
for (const layout of ['minimal', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true, featherPct: 100 },
}),
);
await settleTextBackings(true);
const originalGlyphs = await readHudGlyphs();
const originalChrome = await readSceneChrome();
const original = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const wrappers = [...hud.querySelectorAll('.hud-readout')];
const scene = [...document.querySelectorAll('.scene-text-holdout')];
const unexpectedBackings = [...wrappers, ...scene]
.map((el) => {
const style = getComputedStyle(el);
return {
selector: el.id || el.className,
background: style.backgroundColor,
shadow: style.boxShadow,
};
})
.filter(
(style) =>
style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none',
);
return {
markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'),
unbacked: unexpectedBackings.length === 0,
unexpectedBackings,
wrappersTransparent: wrappers.every(
(el) => getComputedStyle(el).display === 'contents',
),
};
});
check(
`${layout}: Scope ON retains the original presentation without new backplates`,
original.markerAbsent &&
original.unbacked &&
original.wrappersTransparent,
original,
);
check(
`${layout}: original HUD color, glow and metadata alpha remain intact`,
originalGlyphs.glyphs.every(
(glyph) =>
glyph.color === originalGlyphs.baseline.color &&
glyph.shadow === originalGlyphs.baseline.shadow,
) &&
originalGlyphs.glyphs.find(
(glyph) => glyph.selector === '.hud-summary-label',
).opacity === 0.55 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital')
.opacity === 0.7 &&
originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll')
.opacity === 0.5,
originalGlyphs,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-on-${layout}.png`,
),
});
}
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
const backedGlyphs = await readHudGlyphs();
checkSceneChrome(layout, originalChrome, await readSceneChrome());
check(
`${layout}: Scope OFF adds backing without changing glyph styles`,
JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs),
backedGlyphs,
);
const coverage = await page.evaluate(() => {
const hud = document.getElementById('intel-hud');
const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT);
const missing = [];
let visible = 0;
let node;
while ((node = walker.nextNode())) {
if (
!node.textContent.trim() ||
node.parentElement.closest('.hud-bracket')
)
continue;
const range = document.createRange();
range.selectNodeContents(node);
if (
![...range.getClientRects()].some(
(rect) => rect.width > 0 && rect.height > 0,
)
)
continue;
visible += 1;
const backing = node.parentElement.closest('.hud-readout');
const color = backing && getComputedStyle(backing).backgroundColor;
if (
!backing ||
color !== 'rgba(0, 0, 0, 0.7)' ||
getComputedStyle(backing).boxShadow !== 'none' ||
backing.parentElement.closest('.hud-readout')
)
missing.push(node.textContent.trim());
}
const wrapped = [...hud.querySelectorAll('.hud-readout')].filter(
(el) => el.offsetWidth > 0 && el.offsetHeight > 0,
);
const empty = wrapped.filter((el) => !el.innerText.trim()).length;
const overlaps = [];
for (const parent of new Set(wrapped.map((el) => el.parentElement))) {
const siblings = wrapped.filter((el) => el.parentElement === parent);
for (let i = 1; i < siblings.length; i += 1) {
const previous = siblings[i - 1].getBoundingClientRect();
const current = siblings[i].getBoundingClientRect();
const horizontal =
Math.min(previous.right, current.right) -
Math.max(previous.left, current.left);
const vertical =
Math.min(previous.bottom, current.bottom) -
Math.max(previous.top, current.top);
// Horizontal bars share a vertical band. Only intersecting text boxes
// can stack their backing alpha; allow subpixel layout rounding.
if (horizontal > 0.5 && vertical > 0.5) {
overlaps.push({
parent: parent.className,
previous: siblings[i - 1].innerText,
current: siblings[i].innerText,
vertical,
});
}
}
}
const lineIds = [
'hud-mgrs',
'hud-latlon',
'hud-gsd',
'hud-alt',
'hud-timestamp',
'hud-bottom-line',
];
const multiline = lineIds.filter((id) => {
const el = document.getElementById(id);
if (!el.getClientRects().length) return false;
const range = document.createRange();
range.selectNodeContents(el);
return (
range.getBoundingClientRect().height >
parseFloat(getComputedStyle(el).lineHeight) * 1.5
);
});
const edges = [...hud.querySelectorAll('.hud-edge')]
.filter((el) => getComputedStyle(el).display !== 'none')
.map((el) => {
const box = el.querySelector('.hud-readout').getBoundingClientRect();
return (
getComputedStyle(el).transform !== 'none' &&
box.height > box.width &&
Number(getComputedStyle(el).opacity) === 1
);
});
const broadBackings = [
...hud.querySelectorAll('.hud-corner, .hud-bottom-bar'),
].filter(
(el) =>
el.offsetWidth > 0 &&
getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)',
);
return {
scopeOff: document.documentElement.dataset.scopeMask === 'off',
visible,
missing,
empty,
overlaps,
multiline,
edges,
broadBackings: broadBackings.length,
};
});
check(
`${layout}: all visible HUD text has one backing`,
coverage.scopeOff &&
coverage.visible > 0 &&
coverage.missing.length === 0 &&
coverage.empty === 0 &&
coverage.broadBackings === 0,
coverage,
);
check(
`${layout}: adjacent readout backings do not overlap`,
coverage.overlaps.length === 0,
coverage,
);
check(
`${layout}: desktop telemetry remains single line; rotated backing follows the labels`,
coverage.multiline.length === 0 && coverage.edges.every(Boolean),
coverage,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`scope-off-${layout}.png`,
),
});
}
}
const details = await page.evaluate(() => {
const summary = document.getElementById('hud-summary');
summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30);
const ais = document.getElementById('hud-ais-vessel');
ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION';
return {
summaryClipped:
summary.scrollWidth > summary.clientWidth &&
getComputedStyle(summary).textOverflow === 'ellipsis',
aisMultiline:
getComputedStyle(ais).whiteSpace === 'pre-line' &&
ais.textContent.split('\n').length === 2 &&
ais.getBoundingClientRect().height + 0.5 >=
parseFloat(getComputedStyle(ais).lineHeight) * 2,
recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color,
};
});
check(
'summary clipping, multiline AIS and red REC dot retain their behavior',
details.summaryClipped &&
details.aisMultiline &&
details.recColor === 'rgb(255, 51, 51)',
details,
);
await page.setViewport({ width: 390, height: 844 });
await page.select('#hud-layout-select', 'minimal');
const narrow = await page.evaluate(() =>
[...document.querySelectorAll('#intel-hud .hud-readout')]
.filter(
(el) =>
el.getClientRects().length &&
getComputedStyle(el).display !== 'contents',
)
.every((el) => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth;
}),
);
check('narrow HUD text backings stay within the viewport', narrow);
for (const layout of ['minimal', 'tactical']) {
await page.select('#hud-layout-select', layout);
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: true },
}),
);
await settleTextBackings(true);
const before = await readSceneChrome();
await page.evaluate(() =>
window.__godsEyeView.styleManager.applyVisualState({
scope: { enabled: false },
}),
);
await settleTextBackings(false);
checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome());
}
check('runtime has no uncaught browser errors', errors.length === 0, errors);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+38 -7
View File
@@ -35,15 +35,21 @@ try {
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
}),
);
// DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES.
await page.evaluate(() => {
for (const panel of ['pp-toggles', 'utilities-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
const surface = await page.evaluate(() => {
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
// Colour swatches are one-of-many too, but chips, not segments: they are
// checked on their own below.
const groups = [
...document.querySelectorAll('[role="radiogroup"]:not(.draw-colors)'),
];
const buttons = groups.flatMap((group) => [
...group.querySelectorAll('[role="radio"]'),
]);
@@ -69,6 +75,31 @@ try {
JSON.stringify(surface),
);
const swatches = await page.evaluate(() => {
const group = document.getElementById('draw-colors');
const chips = [...(group?.querySelectorAll('[role="radio"]') || [])];
return {
count: chips.length,
round: chips.every(
(chip) => getComputedStyle(chip).borderRadius === '50%',
),
colours: new Set(
chips.map((chip) => getComputedStyle(chip).backgroundColor),
).size,
checked: chips.filter(
(chip) => chip.getAttribute('aria-checked') === 'true',
).length,
};
});
check(
"Draw's colour swatches are five round chips, one checked",
swatches.count === 5 &&
swatches.round &&
swatches.colours === 5 &&
swatches.checked === 1,
JSON.stringify(swatches),
);
await page.evaluate(() =>
document.getElementById('models3d-mode-all').click(),
);
+14 -12
View File
@@ -69,8 +69,6 @@ try {
calDirty: false,
},
});
window.__qaBadgeText =
document.getElementById('cctv-source-badge').textContent;
});
await page.waitForSelector('.weather-coverage.gev-status-badge');
const inventory = await page.evaluate(() => {
@@ -120,20 +118,22 @@ try {
);
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
const result = await page.evaluate((state) => {
const result = await page.evaluate(async (state) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
? 'true'
: '';
owner._cctvFrameWrap.classList.toggle(
'has-frame',
state === 'retained',
);
const frame = owner._frame;
owner._frame = {
...frame,
loading: state === 'loading',
error: ['error', 'retained'].includes(state),
hasFrame: state === 'retained',
};
owner._syncCctvSourceBadge(
owner._cctvState.activeCamera,
state !== 'idle',
);
owner._frame = frame;
// The panel view renders the badge on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
const badge = document.getElementById('cctv-source-badge');
return {
state: badge.dataset.frameState,
@@ -164,7 +164,7 @@ try {
['curated', true, 'rgb(255, 208, 138)'],
]) {
const result = await page.evaluate(
({ calBadge, dirty }) => {
async ({ calBadge, dirty }) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
document.getElementById('cctv-next-btn').focus();
owner._renderCctvState({
@@ -175,6 +175,8 @@ try {
calDirty: dirty,
},
});
// The panel view renders the chip on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
const badge = document.getElementById('cctv-quality-chip');
return {
state: badge.dataset.calBadge,
+5
View File
@@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) {
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest();
return {
pageVisibility: document.visibilityState,
total: state._vehicles.size,
visible: state._visible.size,
moving: state._moving.size,
@@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) {
};
});
console.log(` ${label}: ${JSON.stringify(result)}`);
if (result.pageVisibility !== 'visible')
throw new Error(
`${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`,
);
if (result.renderLoop === false || result.renderErrors.length)
throw new Error(
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
+5
View File
@@ -1,6 +1,7 @@
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const BOSTON = {
id: 'boston',
feedId: 'mbta',
label: 'Boston (MBTA)',
lat: 42.3554,
lon: -71.0605,
@@ -11,6 +12,7 @@ const LIVE_CITIES = [
BOSTON,
{
id: 'austin',
feedId: 'capmetro-austin',
label: 'Austin (CapMetro)',
lat: 30.2672,
lon: -97.7431,
@@ -19,6 +21,7 @@ const LIVE_CITIES = [
},
{
id: 'minneapolis',
feedId: 'metrotransit-msp',
label: 'Minneapolis (Metro Transit)',
lat: 44.9778,
lon: -93.265,
@@ -27,6 +30,7 @@ const LIVE_CITIES = [
},
{
id: 'helsinki',
feedId: 'hsl-helsinki',
label: 'Helsinki (HSL)',
lat: 60.1699,
lon: 24.9384,
@@ -35,6 +39,7 @@ const LIVE_CITIES = [
},
{
id: 'oslo',
feedId: 'entur-norway',
label: 'Oslo (Entur)',
lat: 59.9139,
lon: 10.7522,
+2 -2
View File
@@ -1,6 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -307,7 +307,7 @@ try {
result.pixelPass = pixels.pass;
result.pixelDetails = pixels;
result.errors = errors;
await page.screenshot({ path: `${shots}/recovery.png` });
await captureQaScreenshot(page, { path: `${shots}/recovery.png` });
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
const pass =
+7 -6
View File
@@ -1,3 +1,4 @@
import { captureQaScreenshot } from './qa-chrome.mjs';
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
// Not registered or active: camera discovery and live polls never own this feed.
@@ -219,7 +220,7 @@ export async function runFleetBudgets({
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-fleet-${count}.jpg`,
type: 'jpeg',
quality: 85,
@@ -392,7 +393,7 @@ export async function runBostonMatrix({
JSON.stringify({ pixels, contrast }),
{ unexercised: pixels.length < 6 },
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
type: 'jpeg',
quality: 90,
@@ -407,7 +408,7 @@ export async function runBostonMatrix({
target.key,
);
await wait(500);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
type: 'jpeg',
quality: 90,
@@ -431,7 +432,7 @@ export async function runBostonMatrix({
);
}
await wait(300);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
type: 'jpeg',
quality: 90,
@@ -579,7 +580,7 @@ export async function runBostonLive({
JSON.stringify(selection),
{ unexercised: !target },
);
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
type: 'jpeg',
quality: 90,
@@ -1010,7 +1011,7 @@ export async function runTrailVisibility({
stable: pixels.stable[i],
eligible: pixels.eligible[i],
}));
await page.screenshot({
await captureQaScreenshot(page, {
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
type: 'jpeg',
quality: 95,
+67 -20
View File
@@ -36,10 +36,11 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js';
import {
reduceAnchor,
@@ -264,6 +265,7 @@ const browser = await puppeteer.launch({
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-backgrounding-occluded-windows',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1920,1080',
@@ -271,6 +273,10 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
// This gate measures visible-page motion. OS minimization must not silently
// switch it into the app's intentional hidden-tab polling pause.
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
boundPageEvaluations(page);
await page.setViewport({ width: 1920, height: 1080 });
const pageErrors = [];
@@ -286,11 +292,13 @@ page.on('request', (request) => {
});
const shot = (name, caption) =>
page
.screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 })
.then(() => {
console.log(` shot ${name} — ${caption}`);
});
captureQaScreenshot(page, {
path: `${SHOTS}/${name}`,
type: 'jpeg',
quality: 82,
}).then(() => {
console.log(` shot ${name} — ${caption}`);
});
const view = (city) =>
`${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`;
@@ -389,22 +397,42 @@ try {
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
});
}, city);
await page.waitForFunction(
() =>
(window.__godsEyeView.dataManager.layers
.get('transit')
.module.getStats().count || 0) > 0,
{ timeout: 90_000 },
);
const arrived = await page
.waitForFunction(
(feedId) =>
[
...window.__godsEyeView.dataManager.layers
.get('transit')
.module._transitStateForTest()
._vehicles.keys(),
].some((key) => key.startsWith(`${feedId}:`)),
{ timeout: 90_000 },
city.feedId,
)
.then(
() => true,
() => false,
);
if (!arrived) {
check(
`${city.label}: live feed arrived`,
false,
`No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`,
{ unexercised: true },
);
continue;
}
await wait(9_000);
const fleet = await page.evaluate(() => {
const fleet = await page.evaluate((feedId) => {
const layer =
window.__godsEyeView.dataManager.layers.get('transit').module;
return {
count: layer._transitStateForTest()._vehicles.size,
count: [...layer._transitStateForTest()._vehicles.keys()].filter(
(key) => key.startsWith(`${feedId}:`),
).length,
stats: layer.getStats(),
};
});
}, city.feedId);
check(
`${city.label}: vehicles are rendered`,
fleet.count >= 20,
@@ -426,6 +454,7 @@ try {
.module._transitStateForTest();
let best = null;
for (const entry of state._vehicles.values()) {
if (!entry.key.startsWith(`${c.feedId}:`)) continue;
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
if (!best || d < best.d)
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
@@ -1607,6 +1636,7 @@ try {
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
];
const frames = [];
const observedPolls = new Set();
const publicationsBefore = state._cardPublications || 0;
let headSamples = 0,
headErrorPx = 0,
@@ -1623,6 +1653,13 @@ try {
const proj = scene.camera.frustum.projectionMatrix;
const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
if (
entry.marker.show !== false &&
rect &&
Number.isFinite(entry.pollSeq)
) {
observedPolls.add(entry.pollSeq);
}
frames.push({
t: performance.now() - t0,
mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth,
@@ -1668,7 +1705,15 @@ try {
headSamples++;
}
}
if (performance.now() - t0 >= durationMs) finish();
const elapsed = performance.now() - t0;
// Keep at least 20 seconds of motion and require an observed poll
// transition. A fixed 20s wall-clock window can miss a 15s poll
// when an earlier refresh is still settling.
if (
(elapsed >= 20_000 && observedPolls.size >= 2) ||
elapsed >= durationMs
)
finish();
};
let remove;
const finish = () => {
@@ -1676,6 +1721,7 @@ try {
remove?.();
resolve({
selected: true,
elapsedMs: performance.now() - t0,
frames,
textPublishes:
(state._cardPublications || 0) - publicationsBefore,
@@ -1690,7 +1736,7 @@ try {
});
},
target.key,
20_000,
3 * TRANSIT_POLL_MS,
);
}
check(
@@ -1711,8 +1757,9 @@ try {
);
check(
'card text publication stays throttled while the anchor runs at frame rate',
anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3,
`${anchor.textPublishes} text publications in 20 s`,
anchor.textPublishes > 0 &&
anchor.textPublishes <= anchor.elapsedMs / 250 + 3,
`${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`,
);
} else {
check(
+17 -4
View File
@@ -40,6 +40,8 @@ try {
cancelable: true,
}),
);
// The title bar renders the readout on Vue's next flush.
await Promise.resolve();
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
@@ -118,10 +120,18 @@ try {
if (type === 'resize' && callback === resizeHandler) counts.resize++;
return removeEventListener.call(this, type, callback, options);
};
const { setSplitFlapText } = await import('/src/splitFlap.js');
// Through the owner, so its copy of the label and the store agree.
const feedbackLabel = document.getElementById('global-loading-label');
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
for (const label of ['CHECKING LIVE DATA', 'LOAD COMPLETE']) {
ui._feedback._publishLoading({
hidden: false,
state: 'loading',
label,
detail: '',
});
// Vue renders on a microtask; a task later the label shows it.
await new Promise((resolve) => setTimeout(resolve));
}
const permanentText =
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
let stateNotifications = 0;
@@ -202,6 +212,7 @@ try {
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
permanentText &&
permanentText.data === 'LOAD COMPLETE' &&
document.getElementById('global-loading-status').hidden &&
!feedbackLabel.classList.contains('gev-flap-active') &&
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
0,
@@ -211,7 +222,9 @@ try {
ui._lifetime.timers.size === 0 &&
ui._lifetime.removers.size === 0,
frameRateReleased:
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
fpsWasVisible &&
!fpsReadout.isConnected &&
!document.querySelector('.frame-rate-readout'),
stateStopped: stateNotifications === 0,
idempotent: once === JSON.stringify(counts),
};
-1
View File
@@ -80,7 +80,6 @@ try {
'thermal',
'anime',
'noir',
'snow',
'normal',
]) {
await page.evaluate(
+5 -3
View File
@@ -57,7 +57,7 @@ try {
persist: false,
syncShare: false,
});
manager._sliderPanel.classList.remove('collapsed');
manager._presentPanel(manager._sliderPanel, { collapsed: false });
const slider = manager._sliderContainer.querySelector('input');
window.__qaOldSlider = slider;
const name = 'pixelation';
@@ -140,9 +140,11 @@ try {
hudBefore,
),
);
const displayBefore = await page.evaluate(() => {
const displayBefore = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
manager._setBloomEnabled(true);
// The Display view shows the slider row on its next render.
await new Promise((resolve) => requestAnimationFrame(resolve));
manager._bloomSlider.value = '80';
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
manager._bloomSlider.focus();
@@ -263,7 +265,7 @@ try {
'destroyed Display controls cannot change settings',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._displayControls.destroy();
manager._displayBindings._detachDisplayControls();
const before = manager.bloomEnabled;
const contrast = manager._bloomStage.uniforms.contrast;
manager._bloomBtn.click();
+6 -2
View File
@@ -12,10 +12,14 @@ import { after, before, test } from 'node:test';
import puppeteer from 'puppeteer';
const root = new URL('../', import.meta.url);
const template = await readFile(
new URL('src/ui/templates/provider-settings.html', root),
const view = await readFile(
new URL('src/ui/views/ProviderSettingsView.vue', root),
'utf8',
);
const template = view.slice(
view.indexOf('<template>') + '<template>'.length,
view.lastIndexOf('</template>'),
);
const assets = new Set([
'/src/keySetup.js',
'/src/voice/cloudVoiceAuth.js',
+517 -4
View File
@@ -23,9 +23,13 @@
* node scripts/qa-voice-routing.mjs # both layers, :4415
* node scripts/qa-voice-routing.mjs --layer routing --budget 120
* node scripts/qa-voice-routing.mjs --layer behavior --url http://localhost:4173
* node scripts/qa-voice-routing.mjs --layer behavior --behavior outlines
* node scripts/qa-voice-routing.mjs --layer behavior --behavior acceptance [--headful] [--shots qa-shots/outlines]
* # operator-only: may spend the configured guarded Nominatim allowance
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
* node scripts/qa-voice-routing.mjs --layer behavior --behavior geometry # areas, drawn counts, imagery
*
* House gotchas honored: camera.cancelFlight() before every teleport; puppeteer
* suites must run sequentially with other harnesses (SwiftShader saturation);
@@ -72,6 +76,7 @@ const LAYER = getOpt('--layer', 'all'); // routing | behavior | all | deixis | d
const TURN_BUDGET = Number(getOpt('--budget', '120'));
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
const ONLY = getOpt('--only', null); // substring filter on phrase text
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance | geometry
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
@@ -160,7 +165,11 @@ async function mintSession() {
throw new Error(
`token mint returned no client secret: ${JSON.stringify(body).slice(0, 160)}`,
);
return { value, model };
// The tools this server offered (osm_query only with an operator Overpass).
const tools = new Set(
(body?.session?.tools || []).map((tool) => tool?.name).filter(Boolean),
);
return { value, model, tools };
}
/** One Realtime WS session that can run several text turns sequentially. */
@@ -367,8 +376,10 @@ async function runRoutingLayer() {
}
const batch = list.slice(i, i + PHRASES_PER_SESSION);
let session;
let offered = new Set();
try {
const { value, model } = await mintSession();
const { value, model, tools } = await mintSession();
offered = tools;
session = new RoutingSession(value, model, evidence);
await session.connect();
} catch (e) {
@@ -386,6 +397,14 @@ async function runRoutingLayer() {
skipped(`route: "${p.phrase}"`, 'turn budget exhausted');
continue;
}
const missing = (p.requires || []).filter((name) => !offered.has(name));
if (missing.length) {
skipped(
`route: "${p.phrase}"`,
`${missing.join(', ')} not offered (no configured Overpass)`,
);
continue;
}
turnsUsed += 1;
let calls = [];
try {
@@ -1023,7 +1042,7 @@ async function runDeixisBehavior(page, run, settle) {
};
const beginAt = (x, y, said = null) =>
page.evaluate(
(px, py, text) => {
async (px, py, text) => {
const voice = window.__gevVoiceCommands;
if (text) {
// What the session emits for a spoken turn: a new turn, then the caption.
@@ -1043,6 +1062,8 @@ async function runDeixisBehavior(page, run, settle) {
voice.pointer.hold('keydown');
const snapshot = voice.beginPointerTurn('keydown');
voice.pointer.releaseHold();
// The voice card and the reticle render on Vue's next flush.
await Promise.resolve();
const chip = document.getElementById('gev-voice-card-pointer');
const reticle = document.querySelector('.gev-pointer-reticle');
return {
@@ -1323,7 +1344,10 @@ async function runBehaviorLayer() {
// Overpass queries through the app and any direct request to an Overpass
// host (the status probe is not a query).
const overpassRequests = [];
const packTimes = [];
const requestStarted = new Map();
page.on('request', (request) => {
requestStarted.set(request, Date.now());
const target = new URL(request.url());
const viaApp =
target.origin === new URL(APP_URL).origin &&
@@ -1334,12 +1358,30 @@ async function runBehaviorLayer() {
`${request.method()} ${target.origin}${target.pathname}`,
);
});
page.on('requestfinished', (request) => {
if (/us_census_places|wof_neighborhoods/.test(request.url()))
packTimes.push({
file: request.url().split('/').pop().split('?')[0],
ms: Date.now() - (requestStarted.get(request) ?? Date.now()),
});
requestStarted.delete(request);
});
const resolverLog = [];
page.on('console', (message) => {
const text = message.text();
if (text.startsWith('[Resolver]')) resolverLog.push(text);
});
try {
// The point-and-ask block skips the first-run launcher so its evidence
// screenshots show the map; the full run keeps the historical URL.
const behaviorUrl = new URL(APP_URL);
if (DEIXIS_ONLY) behaviorUrl.searchParams.set('welcome', '0');
if (
DEIXIS_ONLY ||
BEHAVIOR === 'acceptance' ||
BEHAVIOR === 'geometry'
)
behaviorUrl.searchParams.set('welcome', '0');
await page.goto(behaviorUrl.href, {
waitUntil: 'domcontentloaded',
timeout: 60000,
@@ -1382,6 +1424,26 @@ async function runBehaviorLayer() {
});
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
if (BEHAVIOR === 'outlines') {
await runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
});
return;
}
if (BEHAVIOR === 'acceptance') {
await runOutlineAcceptance({ page, run, settle, overpassRequests });
return;
}
if (BEHAVIOR === 'geometry') {
await runGeometryScenarios({ page, run, settle, overpassRequests });
return;
}
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
// while the camera is local — the resolver's proximity gate rejects far
// targets BY DESIGN (annotating Austin from over the Alps must fail).
@@ -2293,6 +2355,17 @@ async function runBehaviorLayer() {
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
);
await runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
});
// Voice geometry last: it moves the camera and turns layers off.
await runGeometryScenarios({ page, run, settle, overpassRequests });
const shotDir = path.join(ROOT, 'qa-shots');
fs.mkdirSync(shotDir, { recursive: true });
await page.screenshot({
@@ -2316,6 +2389,446 @@ async function runBehaviorLayer() {
}
}
/**
* Area asks that the bundled packs answer with no boundary lookup: a US
* Census place, the San Francisco DataSF pack, and Who's On First
* neighborhoods. Each teleports the camera over the place, draws the area,
* and checks the outline, the pack that answered, and that no request went
* to Overpass.
*/
const BUNDLED_OUTLINES = [
{ target: 'Austin', lat: 30.2672, lon: -97.7431, pack: 'us-census' },
{ target: 'the Mission District', lat: 37.7599, lon: -122.4148, pack: 'datasf', entityKind: 'district' },
{ target: 'Notting Hill', lat: 51.5118, lon: -0.2046, pack: 'wof' },
{ target: 'Williamsburg', lat: 40.7081, lon: -73.9571, pack: 'wof' },
{ target: 'Le Marais', lat: 48.8575, lon: 2.358, pack: 'wof' },
];
async function runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
}) {
const timings = [];
for (const scenario of BUNDLED_OUTLINES) {
const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`;
await page.evaluate(({ lat, lon }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
height: 9000,
}),
});
}, scenario);
await run('clear_annotations', {});
await settle(1500);
const overpassBefore = overpassRequests.length;
const logBefore = resolverLog.length;
const packsBefore = packTimes.length;
const started = Date.now();
const r = await run('annotate_map', {
annotations: [{
type: 'area',
target: scenario.target,
label: scenario.target,
...(scenario.entityKind ? { entityKind: scenario.entityKind } : {}),
}],
});
let ringPoints = 0;
for (let i = 0; i < 40 && ringPoints < 4; i += 1) {
ringPoints = await page.evaluate((target) => {
const items = window.__gevAnnotations?.list?.() || [];
const item = items.find((it) => it.label === target);
return Array.isArray(item?.ring) ? item.ring.length : 0;
}, scenario.target);
if (ringPoints < 4) await settle(250);
}
const ms = Date.now() - started;
const line = resolverLog.slice(logBefore).find((l) => l.includes('bundled')) || '';
const pack = /\((us-census|datasf|wof|natural-earth)[,)]/.exec(line)?.[1] || 'none';
const packFiles = packTimes.slice(packsBefore);
const overpass = overpassRequests.slice(overpassBefore);
timings.push({ target: scenario.target, ms, pack, overpass: overpass.length });
report((r?.drawn ?? 0) >= 1 && ringPoints >= 4 && pack === scenario.pack && overpass.length === 0,
label,
`drawn=${r?.drawn} ring=${ringPoints} pack=${pack} outlineMs=${ms} overpass=${overpass.length} ` +
`packFiles=${packFiles.map((f) => `${f.file}:${f.ms}ms`).join(',') || 'cached'}`);
}
await run('clear_annotations', {});
report(
overpassRequests.length === 0,
'behavior: no request reached Overpass during the run',
`overpass=${overpassRequests.length} ${overpassRequests.slice(0, 2).join(' ')}`,
);
console.log(` bundled outline timings: ${JSON.stringify(timings)}`);
console.log(` pack files loaded: ${packTimes.map((f) => `${f.file}:${f.ms}ms`).join(' ')}`);
}
/**
* Outline acceptance across six countries: each ask teleports the camera
* over the place, draws the area once cold and once warm (after clearing),
* and records the rung that answered, the parts drawn, the latency, and how
* many public Nominatim requests the server spent (its daily count in
* .gev-cache/nominatim/state.json). Nothing may reach Overpass.
*/
const OUTLINE_ASKS = [
// group, target, camera lat/lon/height(m), extra annotation args, shot name
['country', 'Switzerland', 46.8, 8.2, 1_200_000],
['country', 'Iran', 32.4, 53.7, 3_500_000],
['state', 'Texas', 31.0, -99.0, 2_000_000],
['state', 'Bavaria', 48.9, 11.4, 900_000],
['city', 'Austin', 30.2672, -97.7431, 60_000, {}, 'austin-city'],
['city', 'Paris', 48.8566, 2.3522, 40_000],
['city', 'Tokyo', 35.6762, 139.6503, 90_000],
['city', 'São Paulo', -23.5505, -46.6333, 90_000],
['city', 'Mumbai', 19.076, 72.8777, 60_000],
['city', 'Lagos', 6.5244, 3.3792, 60_000],
['neighborhood', 'the Mission District', 37.7599, -122.4148, 8000, { entityKind: 'district' }],
['neighborhood', 'the Marina', 37.8037, -122.4368, 8000, { entityKind: 'district' }],
['neighborhood', 'Notting Hill', 51.5118, -0.2046, 8000, { entityKind: 'district' }, 'notting-hill'],
['neighborhood', 'Williamsburg', 40.7081, -73.9571, 8000, { entityKind: 'district' }],
['neighborhood', 'SoHo', 40.7233, -74.003, 8000, { entityKind: 'district' }],
['neighborhood', 'Le Marais', 48.8575, 2.358, 8000, { entityKind: 'district' }],
['neighborhood', 'Shibuya', 35.6595, 139.7005, 8000, { entityKind: 'district' }],
['landmark', 'the Texas Capitol grounds', 30.2747, -97.7404, 3000, { entityKind: 'compound' }],
['landmark', 'Central Park', 40.7829, -73.9654, 6000, {}, 'central-park'],
['landmark', 'the Eiffel Tower', 48.8584, 2.2945, 3000],
['landmark', 'Stanford', 37.4275, -122.1697, 8000],
['landmark', 'Stanford University', 37.4275, -122.1697, 8000],
['landmark', 'Lady Bird Lake', 30.2638, -97.7526, 6000],
['street', 'Congress Avenue', 30.2672, -97.7425, 3000, { entityKind: 'street' }, 'congress-avenue'],
['street', 'Lombard Street', 37.8021, -122.4187, 3000, { entityKind: 'street' }],
['building', 'this building', 30.27472, -97.74035, 1200,
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
/**
* Voice geometry, runner-driven: reusable areas, drawn-area counts, a
* landmark area and recent imagery, all without a default Overpass request.
* Public Nominatim use is read from the server's daily count.
*/
async function runGeometryScenarios({ page, run, settle, overpassRequests }) {
const shotDir = path.join(ROOT, 'qa-shots', 'pr-geometry');
fs.mkdirSync(shotDir, { recursive: true });
const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }).catch(() => {});
const nominatimStart = nominatimCount();
const overpassStart = overpassRequests.length;
const teleport = (lat, lon, height, pitch = -90) => page.evaluate(({ lat, lon, height, pitch }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height,
}),
orientation: { heading: 0, pitch: (pitch * Math.PI) / 180, roll: 0 },
});
}, { lat, lon, height, pitch });
const timings = {};
// (G1) "Outline Bagmati, then count fires inside it".
await teleport(27.7, 85.4, 400_000);
await settle(1500);
let t0 = Date.now();
const bagmati = await run('resolve_area', { query: 'Bagmati province', draw: true, flyTo: false });
timings.bagmatiMs = Date.now() - t0;
const bagmatiRefused = [
'AREA_IDENTITY_MISMATCH',
'AREA_IDENTITY_UNVERIFIED',
].includes(bagmati?.code);
const bagmatiVerified =
bagmati?.ok === true &&
bagmati?.drawn === true &&
bagmati?.level === 'admin1' &&
bagmati?.name === 'Bagmati Province' &&
bagmati?.sourceIdentity === 'Bagmati Province';
report(
bagmatiVerified || bagmatiRefused,
'geometry: "outline Bagmati province" uses verified current identity or refuses honestly',
`areaId=${bagmati?.areaId} name=${bagmati?.name} sourceIdentity=${bagmati?.sourceIdentity} source=${bagmati?.source} km2=${bagmati?.areaKm2} ms=${timings.bagmatiMs} code=${bagmati?.code || ''} ${bagmati?.error || ''}`,
);
await settle(3000);
await shot('bagmati-outline');
t0 = Date.now();
let r = await run('resolve_area', { query: 'Bagmati province' });
report(
bagmatiVerified
? r?.ok === true &&
r?.cached === true &&
r?.ms < 300 &&
r?.sourceIdentity === 'Bagmati Province'
: bagmatiRefused &&
r?.ok === false &&
r?.code === bagmati?.code &&
!r?.areaId,
'geometry: a repeated Bagmati request reuses only verified identity, never refused geometry',
`ok=${r?.ok} code=${r?.code || ''} areaId=${r?.areaId || ''} sourceIdentity=${r?.sourceIdentity || ''} cached=${r?.cached} ms=${r?.ms} roundTrip=${Date.now() - t0}`,
);
// A city no bundled pack holds: the guarded Nominatim outline route.
t0 = Date.now();
const kathmandu = await run('resolve_area', { query: 'Kathmandu', draw: true, flyTo: false });
timings.kathmanduMs = Date.now() - t0;
report(kathmandu?.ok === true && kathmandu?.drawn === true && kathmandu?.source === 'osm' && kathmandu?.areaKm2 > 10 && kathmandu?.areaKm2 < 2000,
'geometry: "outline Kathmandu" comes from the guarded Nominatim outline',
`areaId=${kathmandu?.areaId} source=${kathmandu?.source} km2=${kathmandu?.areaKm2} parts=${kathmandu?.parts} ms=${timings.kathmanduMs} err=${kathmandu?.code || ''} ${kathmandu?.error || ''}`);
await settle(2500);
await shot('kathmandu-outline');
r = await run('resolve_area', { query: 'Georgia' });
report(r?.ok === false && r?.needsClarification === true && (r?.candidates || []).length >= 2,
'geometry: an ambiguous area asks with candidates instead of guessing',
`candidates=${(r?.candidates || []).map((c) => `${c.name}/${c.level}/${c.country || ''}`).join(', ')}`,
);
if (bagmatiVerified) {
await run('set_layer_visibility', {
layerId: 'local-firms',
enabled: true,
});
let fires = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
fires = await run('analyst_query', {
layers: ['local-firms'],
scope: { kind: 'area', areaId: bagmati.areaId },
});
if (fires?.ok || !['NOT_READY', 'LAYER_OFF'].includes(fires?.code)) break;
await settle(4000);
}
if (fires?.code === 'FEED_UNAVAILABLE') {
skipped(
'geometry: "count fires inside it" counts inside the verified Bagmati area',
`fires feed unavailable: ${fires?.error}`,
);
} else {
report(
fires?.ok === true &&
Number.isFinite(fires?.count) &&
/Bagmati/i.test(fires?.scopeLabel || '') &&
fires?.coverage?.scope === `area:${bagmati.areaId}`,
'geometry: "count fires inside it" counts inside the verified Bagmati area',
`count=${fires?.count} scopeLabel="${fires?.scopeLabel}" scope=${fires?.coverage?.scope} err=${fires?.error || ''}`,
);
}
await run('set_layer_visibility', {
layerId: 'local-firms',
enabled: false,
});
} else {
skipped(
'geometry: fire count waits for a verified Bagmati area',
`boundary refused with ${bagmati?.code || 'unknown error'}`,
);
}
// (G2) "Count flights inside the area I drew": the draw tool's own spec.
await teleport(30.27, -97.74, 600_000);
await run('set_layer_visibility', { layerId: 'flights', enabled: true });
for (let attempt = 0; attempt < 12; attempt += 1) {
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' } });
if (r?.ok && r.count > 0) break;
await settle(5000);
}
const drawnBox = [[-99, 29], [-96.5, 29], [-96.5, 31.5], [-99, 31.5], [-99, 29]];
await page.evaluate((ring) => window.__gevAnnotations.annotate([{ type: 'area', manual: true, ring, label: 'QA drawn box' }]), drawnBox);
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'drawn' } });
const loaded = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' } });
report(r?.ok === true && r?.scopeLabel === 'inside QA drawn box' && Number.isFinite(r?.count) && r.count <= (loaded?.count ?? Infinity),
'geometry: "count flights inside the area I drew" counts inside the drawn outline',
`count=${r?.count} of ${loaded?.count} loaded scopeLabel="${r?.scopeLabel}" err=${r?.error || ''}`);
await settle(2500);
await shot('drawn-area-count');
// Thousands of aircraft starve a SwiftShader page; the landmark lookup
// below has a 10 s budget.
await run('set_layer_visibility', { layerId: 'flights', enabled: false });
await run('clear_annotations', {});
// (G3) "Draw an area around the Ferry Building": the enclosing mapped area
// (Nominatim grounds, then the OpenFreeMap area holding it), else an
// approximate buffer — either way a drawn, reusable area.
await teleport(37.7955, -122.3937, 2500, -60);
await settle(1500);
t0 = Date.now();
const ferry = await run('resolve_area', { query: 'Ferry Building, San Francisco', around: true, flyTo: false });
timings.ferryMs = Date.now() - t0;
const ferryNear = Array.isArray(ferry?.bbox) && Math.abs(ferry.bbox[1] - 37.795) < 0.05 && Math.abs(ferry.bbox[0] + 122.394) < 0.05;
report(ferry?.ok === true && ferry?.drawn === true && ferryNear && (ferry?.approximate === true || ['osm', 'openfreemap'].includes(ferry?.source)),
'geometry: "draw an area around the Ferry Building" draws a reusable landmark area',
`areaId=${ferry?.areaId} source=${ferry?.source} approximate=${ferry?.approximate} basis=${ferry?.display?.basis} km2=${ferry?.areaKm2} ms=${timings.ferryMs} err=${ferry?.error || ''}`);
await settle(3000);
await shot('ferry-building-area');
// (G4) "Find recent imagery of this area": the Recent Imagery catalog for
// that area, the best acquisition pinned through the layer itself.
t0 = Date.now();
r = await run('find_imagery', { areaId: ferry?.areaId || 'missing' });
timings.imageryMs = Date.now() - t0;
const pinned = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('recent-imagery')?.module?.getSnapshot?.().pins?.a?.key || null);
if (r?.code === 'CATALOG_UNAVAILABLE' || r?.code === 'SEARCH_TIMEOUT') {
skipped('geometry: "find recent imagery of this area" pins the best acquisition', `catalog: ${r?.error}`);
} else {
report(r?.ok === true && (r?.count === 0 ? r?.shown === null : Boolean(r?.shown?.date) && String(pinned).endsWith(`:${r.shown.date}`)),
'geometry: "find recent imagery of this area" pins the best acquisition',
`count=${r?.count} shown=${JSON.stringify(r?.shown)} pinned=${pinned} ms=${timings.imageryMs} err=${r?.error || ''}`);
}
await settle(4000);
await shot('recent-imagery-area');
await run('set_layer_visibility', { layerId: 'recent-imagery', enabled: false });
// (G5) Bulk place search without an operator Overpass: a plain refusal.
const configured = await page.evaluate(() => fetch('/api/overpass/status').then((res) => res.json()).catch(() => null));
r = await run('osm_query', { what: 'hospitals' });
if (configured?.configured) {
skipped('geometry: place search without an operator Overpass is refused', 'this server has OVERPASS_UPSTREAMS');
} else {
report(r?.ok === false && r?.code === 'OVERPASS_NOT_CONFIGURED',
'geometry: place search without an operator Overpass is refused', `code=${r?.code} error=${r?.error}`);
}
const nominatimUsed = nominatimCount() - nominatimStart;
const overpass = overpassRequests.slice(overpassStart);
console.log(` geometry timings: ${JSON.stringify(timings)}; public Nominatim requests: ${nominatimUsed}`);
report(overpass.length === 0, 'geometry: no Overpass request', overpass.slice(0, 3).join(' | ') || 'none');
report(nominatimUsed <= 15, 'geometry: public Nominatim requests within budget (15)', `used=${nominatimUsed}`);
await run('clear_annotations', {});
}
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
function nominatimCount() {
try {
return JSON.parse(fs.readFileSync(NOMINATIM_STATE, 'utf8')).count || 0;
} catch {
return 0;
}
}
async function runOutlineAcceptance({
page,
run,
settle,
overpassRequests,
}) {
const trace = [];
page.on('console', (msg) => {
const text = msg.text();
if (/^\[(Outline|Resolver)\]/.test(text)) trace.push(text);
});
const nominatimStart = nominatimCount();
const overpassStart = overpassRequests.length;
if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true });
const teleport = (ask) => page.evaluate(({ lat, lon, height }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
height,
}),
});
}, ask);
const rungOf = (lines, state) => {
for (const line of lines) {
const m = /→ (openfreemap|nominatim)\b/.exec(line) ||
/bundled → ([a-z-]+)/.exec(line) ||
/\((us-census|datasf|wof|natural-earth),/.exec(line);
if (m) return m[1];
}
if (state?.synthesized) return 'approximate';
return state?.ring >= 4 ? 'other' : 'point';
};
const once = async (ask) => {
await run('clear_annotations', {});
await teleport(ask);
await settle(1200);
trace.length = 0;
const before = nominatimCount();
const label = `QA ${ask.target}`;
const started = Date.now();
const r = await run('annotate_map', {
annotations: [{ type: 'area', target: ask.target, label, ...ask.extra }],
});
let state = null;
for (let i = 0; i < 120; i += 1) {
state = await page.evaluate((l) => {
const it = (window.__gevAnnotations?.list?.() || []).find((a) => a.label === l);
if (!it) return null;
return {
ring: Array.isArray(it.ring) ? it.ring.length : 0,
parts: Array.isArray(it.polygons) && it.polygons.length ? it.polygons.length : (it.ring ? 1 : 0),
synthesized: Boolean(it.synthesized),
unavailable: Boolean(it.outlineUnavailable),
pending: Boolean(it.pendingOutline),
};
}, label);
if (state && !state.pending) break;
if (!state && r?.drawn === 0 && i > 8) break;
await settle(250);
}
const ms = Date.now() - started;
return {
ms,
drawn: r?.drawn ?? 0,
state,
rung: rungOf(trace, state),
nominatim: nominatimCount() - before,
error: r?.error || r?.results?.find?.((x) => x?.error)?.error || null,
};
};
const table = [];
// --only "Paris|Stanford" reruns a few rows.
const only = ONLY ? ONLY.split('|') : null;
for (const ask of OUTLINE_ASKS.filter((a) => !only || only.some((o) => a.target.includes(o)))) {
const cold = await once(ask);
if (SHOT_DIR && ask.shot) {
await settle(HEADFUL ? 6000 : 2500);
await page.screenshot({ path: path.resolve(ROOT, SHOT_DIR, `${ask.shot}.png`) });
}
const warm = await once(ask);
const rendered = Boolean(cold.state?.ring >= 4) && !cold.state?.synthesized;
const row = {
group: ask.group,
ask: ask.target,
rung: cold.rung,
rendered,
approximate: Boolean(cold.state?.synthesized),
parts: cold.state?.parts ?? 0,
coldMs: cold.ms,
warmMs: warm.ms,
warmRung: warm.rung,
nominatim: cold.nominatim + warm.nominatim,
};
table.push(row);
const detail = `rung=${row.rung} parts=${row.parts} cold=${row.coldMs}ms warm=${row.warmMs}ms ` +
`nominatim=${row.nominatim}${row.approximate ? ' approximate' : ''}` +
(rendered ? '' : ` trace=${trace.slice(-2).join(' | ').slice(0, 300)}`);
if (rendered) report(true, `outlines: ${ask.group} "${ask.target}"`, detail);
else skipped(`outlines: ${ask.group} "${ask.target}" (no exact outline)`, detail);
}
await run('clear_annotations', {});
const overpass = overpassRequests.slice(overpassStart);
report(overpass.length === 0, 'outlines: no request reached Overpass during the run',
`overpass=${overpass.length} ${overpass.slice(0, 2).join(' ')}`);
const used = nominatimCount() - nominatimStart;
report(used <= 30, 'outlines: public Nominatim requests within the run budget (30)', `used=${used}`);
console.log('\n | Group | Ask | Rung | Outline | Parts | Cold ms | Warm ms | Nominatim |');
console.log(' |---|---|---|---|---|---|---|---|');
for (const row of table) {
const outline = row.rendered ? 'yes' : row.approximate ? 'approximate' : 'no (point)';
console.log(` | ${row.group} | ${row.ask} | ${row.rung} | ${outline} | ${row.parts} | ${row.coldMs} | ${row.warmMs} | ${row.nominatim} |`);
}
if (SHOT_DIR)
fs.writeFileSync(path.resolve(ROOT, SHOT_DIR, 'outline-table.json'), JSON.stringify(table, null, 2));
}
// ── Main ────────────────────────────────────────────────────
console.log('\nVoice-surface QA harness');
console.log(` App URL : ${APP_URL}`);
+1 -1
View File
@@ -261,7 +261,7 @@ try {
for (const [id, entry] of gev.dataManager.layers)
if (entry.enabled)
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
document.querySelector('#data-panel')?.classList.remove('collapsed');
gev.styleManager._presentPanel('data-panel', { collapsed: false });
});
// 1. Earth view, clean.
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env node
/** Real-app zoom and tilt regression. Start a dev server, then run this file. */
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
]),
],
protocolTimeout: 120_000,
});
const errors = [];
try {
const page = await browser.newPage();
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1200, height: 800 });
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.viewer &&
window.__godsEyeView.viewer.scene.screenSpaceCameraController
.enableInputs &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
const renderer = await page.evaluate(async () => {
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js');
const { readCameraTargetFrame } =
await import('/src/ui/cameraOrientationControls.js');
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight();
viewer.trackedEntity = undefined;
// Explicit real renders make each gesture independent of host frame rate.
viewer.useDefaultRenderLoop = false;
const controller = viewer.scene.screenSpaceCameraController;
controller.inertiaZoom = 0;
controller.inertiaSpin = 0;
const original = viewer.camera.setView.bind(viewer.camera);
const qa = {
viewer,
guardWrites: 0,
targetPitch: () => readCameraTargetFrame(viewer)?.pitch,
history: [],
frame() {
viewer.scene.requestRender();
viewer.render();
qa.history.push({
height: viewer.camera._positionCartographic.height,
guardWrites: qa.guardWrites,
position: { ...viewer.camera.position },
});
if (qa.history.length > 80) qa.history.shift();
},
pose() {
return {
height: viewer.camera.positionCartographic.height,
pitch: viewer.camera.pitch,
maxPitch: maxPitchForHeight(
viewer.camera.positionCartographic.height,
),
guardWrites: qa.guardWrites,
};
},
reset(height = 1_000_000, pitch = -35) {
viewer.camera.cancelFlight();
viewer.camera.lookAtTransform(C.Matrix4.IDENTITY);
original({
destination: C.Cartesian3.fromDegrees(0, 0, height),
orientation: { heading: 0, pitch: C.Math.toRadians(pitch), roll: 0 },
});
qa.frame();
qa.guardWrites = 0;
},
};
viewer.camera.setView = (options) => {
// Cesium itself also calls setView while processing touch movement.
if (new Error().stack.includes('/src/app/zoomOutGuard.js'))
qa.guardWrites++;
return original(options);
};
window.__zoomGuardQa = qa;
const gl = viewer.scene.context._gl;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
});
console.log(`[INFO] WebGL renderer: ${renderer}`);
const frame = (count = 1) =>
page.evaluate(async (n) => {
for (let i = 0; i < n; i++) {
window.__zoomGuardQa.frame();
await new Promise((resolve) => setTimeout(resolve, 0));
}
return window.__zoomGuardQa.pose();
}, count);
const reset = (height, pitch) =>
page.evaluate((h, p) => window.__zoomGuardQa.reset(h, p), height, pitch);
const session = await page.createCDPSession();
for (const viewport of [
{ width: 1200, height: 800 },
{ width: 390, height: 844 },
]) {
await page.setViewport(viewport);
await page.evaluate(() => window.__zoomGuardQa.viewer.resize());
const x = Math.round(viewport.width / 2);
const y = Math.round(viewport.height / 2);
await reset(1_000_000, -25);
await page.mouse.move(x, y);
await page.evaluate(
({ x, y }) => {
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 2400,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
},
{ x, y },
);
const first = await frame();
console.log(`[INFO] ${viewport.width}px wheel delivered`);
const settled = await frame(40);
assert.ok(first.height > 1_000_000, 'wheel must move the real camera');
assert.ok(settled.guardWrites > 0, 'zoom guard must run');
assert.ok(
settled.pitch <= settled.maxPitch + 1e-6,
`stationary pitch must finish easing: ${JSON.stringify(settled)}`,
);
for (let i = 0; i < 12; i++) {
await page.evaluate(
({ x, y }) => {
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 2400,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
},
{ x, y },
);
await frame();
}
const ceiling = await frame(40);
assert.ok(ceiling.height <= 40_000_001, 'user zoom respects the ceiling');
assert.ok(
ceiling.height > 39_000_000,
'exercise the ceiling, not a low orbit',
);
assert.ok(
ceiling.pitch <= ceiling.maxPitch + 1e-6,
`ceiling must settle: ${JSON.stringify(await page.evaluate(() => window.__zoomGuardQa.history))}`,
);
console.log(
`[PASS] ${viewport.width}px wheel, stationary easing and ceiling`,
);
if (process.env.QA_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.QA_SCREENSHOT_DIR,
`zoom-ceiling-${viewport.width}.png`,
),
});
}
await reset();
await page.mouse.move(x, y);
await page.keyboard.down('Control');
await page.mouse.down({ button: 'right' });
for (let i = 1; i <= 6; i++) {
await page.mouse.move(x, y + i * 3);
await frame();
}
await page.mouse.up({ button: 'right' });
await page.keyboard.up('Control');
const tilted = await frame(3);
assert.equal(
tilted.guardWrites,
0,
'Ctrl+right tilt must not trigger guard',
);
assert.ok(Math.abs(tilted.height - 1_000_000) > 1, 'tilt must move camera');
await reset();
const touches = (dy) => [
{ x: x - 60, y: y + dy, id: 1 },
{ x: x + 60, y: y + dy, id: 2 },
];
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: touches(0),
});
await frame();
for (let i = 1; i <= 6; i++) {
await session.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: touches(i * 3),
});
await frame();
}
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
const translated = await frame(3);
assert.equal(
translated.guardWrites,
0,
'parallel two-finger tilt must not trigger guard',
);
assert.ok(
Math.abs(translated.height - 1_000_000) > 1,
'touch tilt must move camera',
);
console.log(
`[PASS] ${viewport.width}px Ctrl+right and parallel touch tilt`,
);
await reset(1_000_000, -35);
await page.evaluate(
({ x, y }) => {
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 120,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
},
{ x, y },
);
const beforeTilt = await frame();
assert.ok(
beforeTilt.guardWrites > 0,
'wheel correction must be pending before UI tilt',
);
const targetPitchBeforeClick = await page.evaluate(() =>
window.__zoomGuardQa.targetPitch(),
);
assert.ok(
Number.isFinite(targetPitchBeforeClick),
'UI tilt fixture must have a ground target under the canvas center',
);
// Programmatic fixture resets precede moveEnd, so the button's published
// aria state may still describe the previous view. The action toggles the
// current target frame: angles shallower than -60 degrees are tilted.
const wasTilted = targetPitchBeforeClick > -Math.PI / 3;
await page.click('#tilt-map-view');
const uiTilt = await page.evaluate(
async (desiredDegrees) => {
const qa = window.__zoomGuardQa;
const desired = (desiredDegrees * Math.PI) / 180;
const started = performance.now();
const deadline = started + 3000;
while (performance.now() < deadline) {
qa.frame();
// The 650 ms animator can be numerically near its destination
// before it releases preUpdate. Finish its lifetime before resetting.
if (
performance.now() - started >= 700 &&
Math.abs(qa.targetPitch() - desired) < 1e-5
)
return { settled: true, guardWrites: qa.guardWrites };
await new Promise((resolve) => setTimeout(resolve, 16));
}
return {
settled: false,
guardWrites: qa.guardWrites,
pitch: qa.targetPitch(),
};
},
wasTilted ? -89 : -35,
);
assert.ok(
uiTilt.settled,
`UI tilt must reach its destination: ${JSON.stringify(uiTilt)}`,
);
assert.equal(
uiTilt.guardWrites,
beforeTilt.guardWrites,
'UI navigation handoff must cancel pending zoom correction',
);
console.log(
`[PASS] ${viewport.width}px UI tilt takes over a pending wheel correction`,
);
await reset(1_000_000, -25);
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: touches(0),
});
await frame();
await session.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: x - 35, y, id: 1 },
{ x: x + 35, y, id: 2 },
],
});
await frame();
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
const pinched = await frame(40);
assert.ok(pinched.height > 1_000_000, 'pinch-out must move camera outward');
assert.ok(pinched.guardWrites > 0, 'pinch-out must trigger the guard');
assert.ok(pinched.pitch <= pinched.maxPitch + 1e-6);
console.log(`[PASS] ${viewport.width}px pinch-out settles below the limb`);
}
assert.deepEqual(errors, [], 'no JavaScript page errors');
console.log('[PASS] no JavaScript page errors');
} finally {
await browser.close();
}
+7 -1
View File
@@ -71,8 +71,14 @@ export function allocationTestArgs(file) {
return ['--expose-gc', '--test', '--test-concurrency=1', file];
}
/** Node flags every unit-test process receives. */
export const UNIT_TEST_NODE_FLAGS = Object.freeze([
// Single-file components compile on import.
'--import=./build/registerVue.js',
]);
function runTests(args) {
const result = spawnSync(process.execPath, args, {
const result = spawnSync(process.execPath, [...UNIT_TEST_NODE_FLAGS, ...args], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
+22 -5
View File
@@ -57,7 +57,7 @@ export function classifyNodeVersion(version = process.versions.node) {
// NEWER than this release has verified is a warning, never a refusal: a
// future Node must not brick a no-terminal install with advice its user
// cannot follow. Too-old stays an error above — old runtimes genuinely fail.
return { level: 'warn', summary: 'newer than this release has verified; Node 24.14.x or 26.x is the tested path' };
return { level: 'warn', summary: 'newer than this release has verified; Node 24.22.x or 26.x is the tested path' };
}
/** Verify that every direct package declared by this checkout is present. */
@@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
}
/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */
export function resolveOpenSkyCredentialsFile({
authoritativeEnvironment = false,
environment = process.env,
rootDir = ROOT,
} = {}) {
const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE');
const value = authoritativeEnvironment && environmentDefines
? String(environment.OPENSKY_CREDENTIALS_FILE || '')
: String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir);
return isConfiguredValue(value.trim());
}
function hasKeychainItem(service, account) {
if (process.platform !== 'darwin') return false;
const result = spawnSync('security', [
@@ -153,10 +166,11 @@ export function resolveCredential(spec, {
export function buildCapabilitySummary(
credentials,
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {},
) {
const configured = (name) => credentials[name]?.configured === true;
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true;
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
? requestedOpenSkyMode
@@ -164,11 +178,13 @@ export function buildCapabilitySummary(
const flights = openSkyMode === 'anon'
? 'OpenSky keyless anonymous access (rate-limited)'
: openSkyMode === 'basic'
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)'
: openSkyMode === 'auto'
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
: hasOAuthCredentials
: hasClientPair
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
: hasOAuthCredentials
? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)'
: 'OpenSky keyless anonymous access (rate-limited)';
const route = selectMapStartupRoute({
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
@@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
credentials,
capabilities: buildCapabilitySummary(credentials, {
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }),
}),
};
}
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env node
// Minimal screenshot sink: the browser POSTs a data:image/... URL (grabbed from
// the real-GPU Cesium canvas) and this writes it to qa-shots/height-datum/<name>.
// Keeps large image payloads OUT of the agent's context — browser → sink → disk.
// Usage: node scripts/shot-sink.mjs (listens on :4399)
import http from 'node:http';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'qa-shots', 'height-datum');
mkdirSync(OUT, { recursive: true });
const PORT = 4399;
const cors = (res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
};
http.createServer((req, res) => {
cors(res);
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const url = new URL(req.url, 'http://localhost');
if (req.method === 'POST' && url.pathname === '/save') {
const name = (url.searchParams.get('name') || 'shot').replace(/[^a-zA-Z0-9._-]/g, '_');
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
try {
const m = /^data:image\/(png|jpeg);base64,(.+)$/s.exec(body.trim());
if (!m) { res.writeHead(400); res.end('bad dataurl'); return; }
const ext = m[1] === 'jpeg' ? 'jpg' : 'png';
const file = path.join(OUT, `${name}.${ext}`);
writeFileSync(file, Buffer.from(m[2], 'base64'));
res.writeHead(200); res.end(file);
console.log(`saved ${file} (${Math.round(m[2].length / 1024)} KB b64)`);
} catch (e) { res.writeHead(500); res.end(String(e)); }
});
return;
}
res.writeHead(404); res.end('nope');
}).listen(PORT, () => console.log(`shot-sink on http://localhost:${PORT} → ${OUT}`));
+27 -9
View File
@@ -94,7 +94,7 @@
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
@@ -612,6 +612,7 @@ async function main() {
if (models3dButton) {
if (!models3dButton.classList.contains('active')) models3dButton.click();
models3dButton.click(); // → OFF: both layers must follow the one control
await new Promise((resolve) => setTimeout(resolve)); // the view renders
models3dOff.button = models3dButton.classList.contains('active');
models3dOff.fl = fl.getParams().models3d;
models3dOff.mil = mil.getParams().models3d;
@@ -2697,16 +2698,18 @@ async function main() {
// Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The fleet runs every 80 ms,
// independently of rendered FPS. Observe the first completed settle and
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
// rotation refresh can repair an incorrect first reveal.
// independently of rendered FPS. Observe the first completed settle frame
// within 500 ms of forcing moveEnd and the first reveal frame within
// 500 ms of that settle frame: each budget sits inside the 1 s idle
// rotation refresh that could otherwise repair an incorrect first reveal,
// and a ~300 ms headless SwiftShader frame fits inside each of them.
// ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
const { observeArrivalRotationWithRetry } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from).
@@ -2768,7 +2771,23 @@ async function main() {
const bb = findBB(id);
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below
const observed = await observeArrivalRotation({
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
// A wall-clock timeout is inconclusive, not a wrong angle: the frame
// landed where the 1 s idle refresh could already have repaired it
// (seen once on headless SwiftShader: a 926 ms first frame). Such an
// observation gets one more attempt; one that measured a wrong angle —
// a wrong first reveal, or a wrong settle frame before a reveal
// timeout — never does.
const observed = await observeArrivalRotationWithRetry({
async prepare() {
// Freshen both layers' rotation passes right before the probe, so
// the settle frame is measured against a pass that just ran rather
// than one left over from the previous probe — with the camera
// idle, a layer otherwise runs a pass only every
// ROTATION_REFRESH_MS.
v.camera.moveEnd.raiseEvent();
await nextFrames(1);
},
scene: v.scene,
startMoveEnd() {
// Only moveEnd can repair the tampered angle at the idle pose.
@@ -2789,7 +2808,6 @@ async function main() {
};
},
});
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
return { r0, ...observed, hasModel };
};
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
@@ -2799,8 +2817,8 @@ async function main() {
military: await probe('bbb101', militaryCourse),
};
});
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`);
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; ${arrivalAngleViolated(a) ? 'angle violation (not retried); ' : ''}${Number.isFinite(a.dMoveEnd) ? `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° ` : ''}${Number.isFinite(a.dReveal) ? `reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° ` : ''}elapsed=${Number(a.elapsedMs).toFixed(1)} ms attempts=${a.attempts}`
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms attempts=${a.attempts} hasModel=${a.hasModel}`);
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights));
+50 -15
View File
@@ -90,8 +90,11 @@ export const CORE_PHRASES = Object.freeze([
// — annotations —
{ phrase: 'Annotate the Texas State Capitol and its grounds', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Outline the state of Texas', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Outline Lady Bird Lake', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'area'))] },
// A named boundary outlines through resolve_area (a reusable handle the
// user can count or search inside); annotate_map is for marks.
{ phrase: 'Outline the state of Texas', expect: 'resolve_area', args: { draw: true }, typedArgs: [where('resolve_area', (a) => a.draw === true)] },
// A lake is a water footprint as much as a boundary: either tool draws it.
{ phrase: 'Outline Lady Bird Lake', expect: { oneOf: ['annotate_map', 'resolve_area'] }, typedArgs: [(c) => c.name === 'resolve_area' || (c.name === 'annotate_map' && (c.args?.annotations || []).some((x) => x.type === 'area'))] },
{ phrase: 'Draw the walking route from the Capitol to Zilker Park', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'route' && (x.mode ?? 'walking') === 'walking'))] },
{ phrase: 'How far is the Eiffel Tower from the Louvre?', expect: 'annotate_map', typedArgs: [where('annotate_map', (a) => (a.annotations || []).some((x) => x.type === 'arrow'))] },
{ phrase: 'Clear the map', expect: 'clear_annotations' },
@@ -219,7 +222,7 @@ export const COVERAGE_PROBE = Object.freeze([
},
{
id: 'recent-imagery',
v2: { supported: true, correct: [[call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
v2: { supported: true, correct: [[call('find_imagery')], [call('set_layer_visibility', { layerId: 'recent-imagery', enabled: true })]] },
utterance: 'Show recent satellite imagery of this area',
scene: 'Austin',
expect: 'enable recent-imagery for current view (no tool today) — must NOT toggle satellites data layer or silently switch basemap',
@@ -250,13 +253,8 @@ export const COVERAGE_PROBE = Object.freeze([
},
{
id: 'fires-in-drawn-area',
// There is no drawn-area scope yet; the prompt says to say so. Any count
// presented as inside the drawn area is a faked capability.
v2: {
supported: false,
correct: [],
hallucinated: [where('analyst_query', (a) => (a.layers || []).includes('local-firms'))],
},
// The drawn-area scope exists: a count must be scoped to it.
v2: { supported: true, correct: [[where('analyst_query', (a) => (a.layers || []).includes('local-firms') && ['drawn', 'annotation', 'area'].includes(a.scope?.kind))]] },
utterance: 'Count the fires inside the area I drew',
scene: 'Los Angeles, fires layer on, an area drawn around Angeles National Forest',
expect: 'analyst_query local-firms scoped to the drawn polygon (no polygon scope today)',
@@ -548,14 +546,42 @@ export function dialogueResult(dialogue, toolName, turn) {
return byTurn[turn] ?? null;
}
// ── New capabilities (point-and-ask, layer reach) ─────────────────────────
// ── New capabilities (voice geometry, point-and-ask, layer reach) ─────────
//
// ROUTING_PHRASES is the qa-voice-routing table, so that harness runs exactly
// its own table. The benchmark runs CAPABILITY_EXTRAS as the separate
// `capability` suite so the core suite stays comparable across tool versions.
// ROUTING_PHRASES is the qa-voice-routing table (core + geometry + place
// search), so that harness runs exactly its own table. The benchmark runs
// CAPABILITY_EXTRAS as the separate `capability` suite so the core suite
// stays comparable across tool versions.
/** Areas, drawn-area counts and imagery: offered in every session. */
export const GEOMETRY_PHRASES = Object.freeze([
{ phrase: 'Outline Bagmati province', expect: 'resolve_area', args: { query: 'bagmati', draw: true } },
{ phrase: 'Outline Kathmandu district', expect: 'resolve_area', args: { query: 'kathmandu' } },
{ phrase: 'Show me the border of Punjab', expect: 'resolve_area', args: { query: 'punjab' } },
{ phrase: 'Draw an area around the Ferry Building in San Francisco', expect: 'resolve_area', args: { around: true } },
{ phrase: 'Count the fires inside Bagmati province', expect: { oneOf: ['analyst_query', 'resolve_area'] } },
{ phrase: 'How many flights are inside the area I drew?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.scope?.kind === 'drawn')] },
{ phrase: 'How many ships are inside the area you just outlined?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => a.scope?.kind === 'annotation' || a.scope?.kind === 'area')] },
{ phrase: 'Show me recent satellite imagery of this area', expect: 'find_imagery' },
{ phrase: 'Find the latest cloud-free satellite image here', expect: 'find_imagery', typedArgs: [where('find_imagery', (a) => typeof a.maxCloudPct === 'number' && a.maxCloudPct <= 20)] },
{ phrase: 'Get me recent imagery of Kathmandu', expect: { oneOf: ['find_imagery', 'resolve_area'] } },
]);
/**
* Bulk place search: osm_query is offered only when the server has an
* operator Overpass, so the routing harness skips these otherwise.
*/
export const OVERPASS_PHRASES = Object.freeze([
{ phrase: 'Show me all the hospitals in Kathmandu', expect: { oneOf: ['resolve_area', 'osm_query'] }, requires: ['osm_query'] },
{ phrase: 'Find the police stations in view', expect: 'osm_query', args: { what: 'police' }, requires: ['osm_query'] },
]);
/** The full qa-voice-routing table. */
export const ROUTING_PHRASES = CORE_PHRASES;
export const ROUTING_PHRASES = Object.freeze([
...CORE_PHRASES,
...GEOMETRY_PHRASES,
...OVERPASS_PHRASES,
]);
// Pointer context items in the app's own format (src/voice/pointerContext.js
// output), sent as an inert system item just before the user's words.
@@ -569,6 +595,7 @@ const GROUND_POINTER = {
ground: { lat: 30.2849, lon: -97.7341 }, screen: { x: 0.427, y: 0.547 },
};
const isPointer = (v) => String(v || '').toLowerCase() === 'pointer';
const bagmati = { ok: true, areaId: 'area-1', name: 'Bagmati Province', level: 'admin1', drawn: true, say: 'Bagmati Province — about 20,000 km²' };
const flightsInView = {
ok: true,
count: 3,
@@ -593,6 +620,14 @@ export const CAPABILITY_EXTRAS = Object.freeze([
{ phrase: 'Turn on the weather radar', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'weather-radar' && a.enabled === true)] },
{ phrase: 'Are there any hurricanes right now?', expect: { oneOf: ['analyst_query', 'set_layer_visibility'] }, typedArgs: [(c) => (c.args?.layers || []).includes('weather-cyclones') || c.args?.layerId === 'weather-cyclones'] },
{ phrase: 'Which bus is closest to the middle of the screen?', expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('transit'))] },
{ phrase: 'Show recent satellite imagery here', context: GROUND_POINTER, expect: { oneOf: ['find_imagery', 'set_layer_visibility'] }, typedArgs: [(c) => c.name === 'find_imagery' || (c.name === 'set_layer_visibility' && c.args?.layerId === 'recent-imagery')] },
{
id: 'bagmati-then-fires',
turns: ['Outline Bagmati province', 'Count the fires inside it'],
results: { resolve_area: [bagmati] },
first: { expect: 'resolve_area' },
second: { expect: 'analyst_query', typedArgs: [where('analyst_query', (a) => (a.layers || []).includes('local-firms') && a.scope?.kind === 'area' && a.scope?.areaId === 'area-1')] },
},
{
id: 'flights-then-second',
turns: ['How many flights are in view?', 'Track the second one'],
+51
View File
@@ -0,0 +1,51 @@
{
"reviewedSources": {
"zs": {
"name": "Zetashapes",
"registryCommit": "e17af153c144fe6e08aa8db2607b4bf8aecd448d",
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"rawLicenseType": "Public domain",
"licenseTextSha256": "91757f96f74f6dfb4a7206486b78586da0d039e9611e224fc793c7167dcfe615",
"decision": "Approved from the pinned WOF registry text: no added restrictions beyond public-domain US TIGER/Line data and Flickr-derived data; retain both upstream credits."
}
},
"licenses": [
"Attribution 3.0 Unported",
"CC BY",
"CC BY 2.0",
"CC BY 3.0",
"CC BY 3.0 US",
"CC BY 4.0",
"CC-BY",
"CC0",
"CC0 1.0 Universal",
"ODC-By, v1.0",
"ODC-By v1.0",
"OGL - Canada",
"OGL - City of New Westminster",
"OGL - City of Surrey",
"Open Government Licence v3.0",
"Open Government Licence - British Columbia, v2.0",
"Open Government Licence - Canada",
"Open Government Licence - City of Calgary",
"Open Government Licence - City of Regina",
"Open Government Licence - Edmonton",
"Open Government Licence - Ontario, v1.0",
"Open Government License - British Columbia, v2.0",
"Open Government License - Winnipeg",
"PDDL 1.0",
"Public domain",
"Public Domain",
"Singapore Open Data Licence v1"
],
"recordExclusions": {
"reason": "Sources whose pinned registry licence is only an \"equivalent\" designation (for example \"CC BY (equivalent)\" or \"CC0 (equivalent)\"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack.",
"archives": {
"neighbourhood": "8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455",
"macrohood": "f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679",
"microhood": "e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac"
},
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"sources": {}
}
}