mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into codex/overpass-admission-review-fixes
This commit is contained in:
1407 files changed
+118138
-22562
No files matched your search
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -29,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",
|
||||
@@ -68,6 +70,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",
|
||||
@@ -180,6 +183,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",
|
||||
@@ -197,9 +202,7 @@
|
||||
"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",
|
||||
@@ -212,7 +215,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",
|
||||
@@ -298,6 +300,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",
|
||||
@@ -527,6 +531,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",
|
||||
@@ -562,6 +567,7 @@
|
||||
"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/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
@@ -650,7 +656,6 @@
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/agent/worldPacket.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"build/application-html.js",
|
||||
"src/app/sources.js",
|
||||
"src/ui/composition.js",
|
||||
"src/services/application.js",
|
||||
@@ -703,7 +708,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 +745,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",
|
||||
@@ -872,6 +878,18 @@
|
||||
"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/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
@@ -886,6 +904,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 +917,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",
|
||||
@@ -1084,7 +1105,6 @@
|
||||
"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",
|
||||
|
||||
+1
-1
@@ -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
@@ -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/') &&
|
||||
|
||||
@@ -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()
|
||||
+937
-90
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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('; ');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ try {
|
||||
(await readScope()).feather === 2,
|
||||
);
|
||||
await page.click('#scope-toggle');
|
||||
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
|
||||
for (const layout of ['minimal', 'cyber', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
const state = await readScope();
|
||||
check(
|
||||
@@ -395,7 +395,7 @@ try {
|
||||
restored,
|
||||
);
|
||||
|
||||
for (const layout of ['minimal', 'operator', 'tactical']) {
|
||||
for (const layout of ['minimal', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
@@ -429,20 +429,13 @@ try {
|
||||
wrappersTransparent: wrappers.every(
|
||||
(el) => getComputedStyle(el).display === 'contents',
|
||||
),
|
||||
operatorFrame:
|
||||
hud.dataset.variant !== 'operator' ||
|
||||
(getComputedStyle(hud.querySelector('.hud-corner'))
|
||||
.backgroundColor === 'rgba(0, 0, 0, 0.36)' &&
|
||||
getComputedStyle(hud.querySelector('.hud-top-bar'))
|
||||
.backgroundImage !== 'none'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${layout}: Scope ON retains the original presentation without new backplates`,
|
||||
original.markerAbsent &&
|
||||
original.unbacked &&
|
||||
original.wrappersTransparent &&
|
||||
original.operatorFrame,
|
||||
original.wrappersTransparent,
|
||||
original,
|
||||
);
|
||||
check(
|
||||
@@ -571,7 +564,7 @@ try {
|
||||
);
|
||||
});
|
||||
const broadBackings = [
|
||||
...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'),
|
||||
...hud.querySelectorAll('.hud-corner, .hud-bottom-bar'),
|
||||
].filter(
|
||||
(el) =>
|
||||
el.offsetWidth > 0 &&
|
||||
@@ -656,7 +649,7 @@ try {
|
||||
}),
|
||||
);
|
||||
check('narrow HUD text backings stay within the viewport', narrow);
|
||||
for (const layout of ['minimal', 'operator', 'tactical']) {
|
||||
for (const layout of ['minimal', 'tactical']) {
|
||||
await page.select('#hud-layout-select', layout);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.applyVisualState({
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -80,7 +80,6 @@ try {
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
'normal',
|
||||
]) {
|
||||
await page.evaluate(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
* 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)
|
||||
@@ -72,6 +75,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
|
||||
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
|
||||
@@ -1023,7 +1027,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 +1047,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 +1329,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 +1343,26 @@ 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')
|
||||
behaviorUrl.searchParams.set('welcome', '0');
|
||||
await page.goto(behaviorUrl.href, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000,
|
||||
@@ -1382,6 +1405,22 @@ 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;
|
||||
}
|
||||
|
||||
// (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 +2332,15 @@ async function runBehaviorLayer() {
|
||||
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
|
||||
);
|
||||
|
||||
await runBundledOutlineScenarios({
|
||||
page,
|
||||
run,
|
||||
settle,
|
||||
overpassRequests,
|
||||
resolverLog,
|
||||
packTimes,
|
||||
});
|
||||
|
||||
const shotDir = path.join(ROOT, 'qa-shots');
|
||||
fs.mkdirSync(shotDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
@@ -2316,6 +2364,259 @@ 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 }));
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}`));
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user