Files
gods-eye-view/src/annotations/drawTool.test.mjs
T
Said Turksever 95ed686da5 List drawn shapes under Draw, with icon shapes and colour swatches
Each shape drawn by hand gets a row under Draw, as Import's files do: its
colour swatch, its name, and show/hide, fly-to and remove. A shape is named
in its row, and its swatch opens the five colours there, so labelling and
colouring happen after a shape is drawn and can be seen, instead of in a
field filled in beforehand. Area, Line and Pin become icon buttons, named
for screen readers and tooltips, and the colour for the next shape is a row
of swatches instead of a dropdown.

The annotation engine gains per-mark remove, setHidden, restyle and frame,
and onChange so the list follows the board. A hidden mark stays on the board
at alpha 0, a renamed line keeps its length, and fly-to frames a line by its
whole path. Voice marks are not listed.

The Draw, form-field and segmented-control gates drive the new controls;
swatches are checked as chips, not segments.
2026-10-29 12:41:12 +00:00

236 lines
8.0 KiB
JavaScript

// STRUCTURAL contracts only: where files live, which ids the markup carries,
// what the docs may claim. Things a running test cannot observe.
//
// Everything about the tool's BEHAVIOUR — the pointer lease, borrowing and
// restoring the viewer's own click actions, teardown, the Enter key, preview
// heights, Clear — is asserted against the running module in
// `drawToolBehaviour.test.mjs`, not by reading this file's source.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const tool = () => read('src/annotations/drawTool.js');
test('the application composition wires the draw tool and owns its teardown', () => {
const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(
drawAt > engineAt,
'initDrawTool must follow initAnnotations and receive its engine',
);
assert.match(
tools,
/import \{ initDrawTool \} from '\.\.\/annotations\/drawTool\.js';/,
);
// The tool holds a pointer claim, DOM listeners and a data source, so the
// application lifetime disposes it — not whoever last pressed the button.
const deferAt = tools.indexOf('defer(() => drawTool?.destroy());');
assert.ok(deferAt > drawAt, 'the shell must defer the draw tool teardown');
});
test('the engine resolves manual geometry before any name resolution', () => {
const engine = read('src/annotations/annotationEngine.js');
const resolveAt = engine.indexOf(
'async function resolveSpec(spec, signal) {',
);
const manualAt = engine.indexOf(
'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);',
resolveAt,
);
const routeAt = engine.indexOf("if (type === 'route') {", resolveAt);
assert.ok(
manualAt > resolveAt && manualAt < routeAt,
'manual specs must short-circuit ahead of the route/arrow/area resolvers',
);
assert.match(
engine,
/if \(mode === 'manual'\) return baseLabel \? `\$\{baseLabel\} — \$\{dist\}` : dist;/,
'a drawn line reports length, never a walk time',
);
});
test('the Draw control markup carries the ids the tool binds to', () => {
// UTILITIES is a component template now, assembled into index.html at
// build time — the markup is not in index.html any more.
const html = read('src/ui/templates/layer-panels.html');
const ids = [
'draw-toggle',
'draw-mode-row',
'draw-label-row',
'draw-colors',
'draw-mark-list',
'draw-clear',
'draw-hint',
];
for (const id of ids) {
assert.match(
html,
new RegExp(`id="${id}"`),
`#${id} missing from index.html`,
);
}
// Shapes are icons, named for screen readers and tooltips.
for (const [shape, name] of [
['area', 'Area'],
['line', 'Line'],
['pin', 'Pin'],
]) {
assert.match(
html,
new RegExp(
`data-shape="${shape}"[^>]*aria-label="${name}"[^>]*title="${name}"`,
),
`shape button ${shape} missing`,
);
}
for (const color of ['primary', 'amber', 'cyan', 'green', 'red'])
assert.match(
html,
new RegExp(`class="draw-color[^"]*" type="button" data-color="${color}"`),
);
for (const id of ids) {
assert.match(
tool(),
new RegExp(`getElementById\\('${id}'\\)`),
`drawTool must bind #${id}`,
);
}
});
test('the DISPLAY rail styles live in their current owner, not the shim', () => {
// The PR this came from was written against the pre-refactor layout, where
// these rules lived in the root style.css. That file is an import shim now,
// and putting rules back into it would silently undo the split.
const shim = read('style.css');
assert.ok(
shim.split('\n').length < 40,
'style.css is an import shim, not a stylesheet',
);
assert.doesNotMatch(
shim,
/draw-hint|draw-color/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.draw-color',
'.draw-mark-name',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
]) {
assert.ok(
controls.includes(rule),
`${rule} must live in src/ui/styles/controls.css`,
);
}
});
test('nothing promises a GeoJSON export for drawn shapes', () => {
for (const file of [
'src/annotations/drawTool.js',
'src/annotations/drawMode.js',
]) {
assert.doesNotMatch(
read(file),
/GeoJSON/i,
`${file} still claims a GeoJSON export`,
);
}
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
for (const [file, heading] of [
['README.md', 'UTILITIES ▸ **Draw**'],
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
]) {
const source = read(file);
const start = source.indexOf(heading);
assert.ok(start >= 0, `${file} should describe the Draw control`);
const section = source.slice(start, start + 1200);
assert.doesNotMatch(
section,
/GeoJSON/i,
`${file} still claims a GeoJSON export for drawn shapes`,
);
}
});
test('the docs do not promise a vertex height the renderer would discard', () => {
// Areas and routes are DRAPED, so a per-vertex height is not a placement.
// That the finished spec actually drops it is asserted against the running
// module in drawToolBehaviour.test.mjs; this only guards the wording.
assert.doesNotMatch(tool(), /lands on the roof/);
assert.doesNotMatch(
read('docs/CURRENT-STATE.md'),
/a vertex on a roof lands on the roof/,
);
});
test('draped marks classify onto terrain as well as 3D tiles', () => {
// CESIUM_3D_TILE alone rendered nothing on a keyless boot, where Cesium's own
// globe carries the imagery and there are no tiles to classify against. The
// rendered proof is in scripts/qa-draw-tool.mjs, which counts stroke pixels
// against a keyless server; this stops the constant being narrowed again.
const renderer = read('src/annotations/worldAnnotationRenderer.js');
assert.match(renderer, /const CLASSIFY = Cesium\.ClassificationType\.BOTH;/);
assert.doesNotMatch(
renderer,
/classificationType: Cesium\.ClassificationType\.CESIUM_3D_TILE/,
);
});
test('the draw modules are registered for formatting and boundary checks', () => {
const formatScope = JSON.parse(read('scripts/format-scope.json'));
for (const file of [
'src/annotations/drawMode.js',
'src/annotations/drawMode.test.mjs',
'src/annotations/drawTool.js',
'src/annotations/drawTool.test.mjs',
'src/data/inputOwnership.js',
]) {
assert.ok(
formatScope.includes(file),
`${file} must be in the formatting scope`,
);
}
const boundaries = JSON.parse(read('scripts/package-boundaries.json'));
const owners = (file) =>
Object.entries(boundaries)
.filter(([, group]) => group.modules?.includes(file))
.map(([name]) => name);
for (const file of [
'src/annotations/drawMode.js',
'src/annotations/drawTool.js',
'src/data/inputOwnership.js',
]) {
assert.ok(
owners(file).length > 0,
`${file} must be owned by at least one boundary group`,
);
}
// Every layer that consults the shared claim has to own the module it reads,
// or its bundle fails the boundary check.
for (const group of ['alpr-cameras', 'vessel-layer', 'satellites-layer']) {
assert.ok(
boundaries[group]?.modules.includes('src/data/inputOwnership.js'),
`${group} consults the pointer claim and must own the module`,
);
}
const exports = JSON.parse(read('package.json')).exports;
assert.equal(exports['./annotations'], './src/annotations/index.js');
});