Files
gods-eye-view/src/navigationPolicyFrame.test.mjs
T
Said Turksever 4ebb287d36 Merge main into the Utilities panel, rebuilt on the Vue panel rail
main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.

- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
  item (handyman). LayerPanel renders the Directions rows into its list with
  LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
  which render what the draw tool publishes to the display store and call
  its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
  drawMarkList.js is gone. A row's fly-to is an immediate navigation like
  Import's ('drawing' gets its Cockpit refusal), the name field keeps a
  draft through v-field-value, and the colours are one radio group with a
  single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
  buttons.
- The catalogs take main's languages with this branch's strings, Arabic
  included, and the tests follow the views to their new homes.
2026-11-02 16:43:29 +00:00

138 lines
4.8 KiB
JavaScript

// The Cockpit refusal names its destination in the UI language. The scan
// below collects every noun the callers hand to the navigation policy, so a
// noun without its own sentence fails here instead of reaching the page.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { TRACKABLE_FAMILIES } from './agent/worldPacket.js';
import { setLocale } from './i18n/index.js';
import {
COCKPIT_REFUSAL_NOUNS,
runExplicitNavigation,
} from './navigationPolicy.js';
import { WORLD_FOCUS_FRAMING } from './worldFocus.js';
const SRC = fileURLToPath(new URL('.', import.meta.url));
test.afterEach(() => setLocale('en'));
function cockpitToast(noun) {
const log = [];
runExplicitNavigation({
cockpitActive: true,
noun,
showToast: (text) => log.push(text),
stamp: () => 1,
release: () => {},
navigate: () => {},
});
assert.equal(log.length, 1);
return log[0];
}
test('es: the Cockpit refusal for frame_overhead has no English id', () => {
setLocale('es');
const shown = cockpitToast('frame');
assert.doesNotMatch(shown, /\bframe\b/, shown);
assert.equal(shown, 'Sal de la cabina para encuadrar contactos');
});
test('en: the voice nouns read as English sentences', () => {
setLocale('en');
assert.equal(cockpitToast('frame'), 'Exit cockpit to frame contacts');
assert.equal(cockpitToast('aircraft'), 'Exit cockpit to fly to an aircraft');
assert.equal(cockpitToast('satellite'), 'Exit cockpit to fly to a satellite');
});
// ── Every noun a caller passes ─────────────────────────────────────────────
function sourceFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(path);
return /\.js$/.test(entry.name) && !/\.test\./.test(entry.name)
? [path]
: [];
});
}
// The destination noun: the first argument of a navigation call, or the
// second of runManagedVoiceNavigation(styleManager, noun, …) in gevActions.js.
// `runNavigation(fly)` layer seams take only the flight (group 1).
const NOUN_ARGUMENT =
/\b(?:(runNavigation)\s*\(|(?:runImmediateNavigation|_runExplicitNavigation|runOrientation|_beginDeferredNavigation|cockpitRefusal|navigation\??\.(?:run|begin))\s*\(|runManagedVoiceNavigation\s*\(\s*[\w.?]+\s*,)\s*([^,)]*)/g;
// Literal nouns elsewhere: a parameter default (`noun = 'location'`) or both
// branches of `const noun = … ? 'cyclone' : 'weather'`.
const NOUN_LITERAL =
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
function trackableKinds() {
// This is shared with voice; test the exported owner, not its file position.
return TRACKABLE_FAMILIES.map(({ kind }) => kind);
}
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
// its arguments) passes on; its default is a NOUN_LITERAL. Anything else is a
// new dynamic noun: list its values here.
const DYNAMIC_NOUNS = new Map([
['family.kind', trackableKinds],
[
"detail?.kind || 'target'",
() => [...Object.keys(WORLD_FOCUS_FRAMING), 'target'],
],
['noun', () => []],
['...args', () => []],
['...arguments', () => []],
]);
function collectNouns() {
const nouns = new Set();
const unknown = [];
for (const file of sourceFiles(SRC)) {
const text = readFileSync(file, 'utf8');
for (const [, seam, raw] of text.matchAll(NOUN_ARGUMENT)) {
const arg = raw.trim();
const literal = arg.match(/^'([^']*)'$/)?.[1];
if (literal) nouns.add(literal);
else if (DYNAMIC_NOUNS.has(arg))
DYNAMIC_NOUNS.get(arg)().forEach((noun) => nouns.add(noun));
else if (!seam && !/^noun\s*=/.test(arg))
unknown.push(`${relative(SRC, file)}: ${arg}`);
}
for (const m of text.matchAll(NOUN_LITERAL))
m.slice(1).forEach((noun) => noun && nouns.add(noun));
}
return { nouns, unknown };
}
test('every noun the callers pass has its own Cockpit refusal', () => {
const { nouns, unknown } = collectNouns();
assert.deepEqual(unknown, [], 'a new dynamic noun: add it to DYNAMIC_NOUNS');
// The scan still sees the known callers (a guard against a broken regex).
for (const noun of [
'location',
'camera',
'photo',
'shared view',
'file',
'drawing',
'scene',
'route',
'weather',
'cyclone',
'vessel',
'fire',
'frame',
'aircraft',
'satellite',
'target',
])
assert.ok(nouns.has(noun), `found ${noun}`);
const missing = [...nouns].filter(
(noun) => !COCKPIT_REFUSAL_NOUNS.includes(noun),
);
assert.deepEqual(missing, [], 'add these nouns to COCKPIT_REFUSALS');
});