Merge Core main into german/hal-644-ar: Arabic on the Vue UI

Main moved the UI to Vue views and Pinia stores, so the Arabic changes to
the old DOM controllers now live in their views: the rail legend range
comes from formatScaleRange (keeping units a source supplies as given),
the voice timeout options are worded by Intl unit formatting in
VoiceControl, and the scene editor's JSON fields stay left to right in
SceneDialog. Main's CCTV status line, launch panel and calibration values
keep the Arabic isolates and Western-digit formatting.

isolate() and keepCodesWithValues() read the locale through getLocale(),
which follows the page's vue-i18n instance, and the Intl formatters take
intlLocale(getLocale()). A language switch now applies in place, and
applyDocumentLanguage sets <html dir> on every switch, so the page mirrors
without a reload.

The icon rails and their tips are placed with logical properties, so they
stay on the outer side of the mirrored stacks; Cyber's new stack offsets,
padding and edge lines are logical too, and rtl.css moves Cyber's right
stack to its new rail clearance. Main removed the Context mini radio, so its
right-to-left rules and test go.

The Arabic catalog gains main's new strings and drops the ones main no
longer uses; the other catalogs keep main's new keys and the branch's
landing labels.
This commit is contained in:
Sameh Khamis committed 2026-11-01 20:15:02 -08:00
commit bc5d038afd
630 files changed
+41141 -20956

No files matched your search

+60 -183
View File
@@ -1,14 +1,14 @@
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
// fake DOM and viewer doubles with an injected loader, and its observable
// effects are asserted — data sources added and removed, list rows, the hint,
// drop handling, the file limit, and what destroy() gives back. Real parsing
// and rendering run in `scripts/qa-geo-import.mjs`.
// effects are asserted — data sources added and removed, the rows and hint it
// publishes, drop handling, the file limit, and what destroy() gives back.
// The Display panel renders the published list (`displayPanel.test.mjs`);
// real parsing and rendering run in `scripts/qa-geo-import.mjs`.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { initGeoFileImport, summarize } from './geoFileImport.js';
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
import { describeUnsavedWork } from '../i18n/index.js';
class FakeClassList {
constructor() {
@@ -31,46 +31,12 @@ class FakeClassList {
}
}
// The one focused element across every fake document (tests run one at a time).
const focus = { active: null };
class FakeElement {
constructor(tagName = 'div', id = '') {
this.tagName = tagName.toUpperCase();
this.id = id;
this.classList = new FakeClassList();
this.dataset = {};
this.style = {};
this.attributes = new Map();
this.children = [];
this.parent = null;
this.listeners = [];
this.textContent = '';
this.value = '';
this.clicks = 0;
}
set className(v) {
this.classList = new FakeClassList();
String(v)
.split(/\s+/)
.filter(Boolean)
.forEach((n) => this.classList.add(n));
}
setAttribute(n, v) {
this.attributes.set(n, String(v));
}
getAttribute(n) {
return this.attributes.get(n) ?? null;
}
append(...kids) {
for (const k of kids) {
k.parent = this;
this.children.push(k);
}
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
@@ -91,71 +57,24 @@ class FakeElement {
...event,
});
}
click() {
this.clicks += 1;
this.emit('click');
}
focus() {
focus.active = this;
}
contains(el) {
for (let at = el; at; at = at.parent) if (at === this) return true;
return false;
}
*walk() {
for (const kid of this.children) {
yield kid;
yield* kid.walk();
}
}
querySelectorAll(selector) {
if (selector !== '[data-import-id]') throw new Error(`fake: ${selector}`);
return [...this.walk()].filter((el) => el.dataset.importId);
}
querySelector(selector) {
const exact = /^button\[data-action="([a-z]+)"\]$/.exec(selector);
for (const el of this.walk()) {
if (el.tagName !== 'BUTTON') continue;
if (selector === 'button' || (exact && el.dataset.action === exact[1]))
return el;
}
return null;
}
closest(selector) {
for (let el = this; el; el = el.parent) {
if (
selector === 'button[data-action]' &&
el.tagName === 'BUTTON' &&
el.dataset.action
)
return el;
if (selector === '[data-import-id]' && el.dataset.importId) return el;
}
return null;
}
}
function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
focus.active = null;
const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'),
'geo-import-input': new FakeElement('input', 'geo-import-input'),
'geo-import-list': new FakeElement('ul', 'geo-import-list'),
'geo-import-hint': new FakeElement('span', 'geo-import-hint'),
};
const document = {
body: new FakeElement('body'),
getElementById: (id) => els[id] ?? null,
createElement: (tag) => new FakeElement(tag),
get activeElement() {
return focus.active;
},
};
Object.setPrototypeOf(document, new FakeElement('document'));
const added = [];
const removed = [];
const flights = [];
let renders = 0;
let shown = null;
// The view's pick and row buttons, as the display store holds them.
let actions = null;
const viewer = {
container: new FakeElement('div', 'cesiumContainer'),
scene: { requestRender: () => (renders += 1) },
@@ -193,6 +112,13 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
...(navigation ? { navigation } : {}),
...(subscribeMapSource ? { subscribeMapSource } : {}),
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
publish: (view) => {
shown = view;
},
connect: (next) => {
actions = next;
return () => (actions = null);
},
});
return {
tool,
@@ -205,25 +131,22 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
revoked,
loads,
renders: () => renders,
shown: () => shown,
actions: () => actions,
/** A control's change, as the view sends it. */
act: (name, ...args) => actions?.[name]?.(...args),
};
}
const file = (name, extra = {}) => ({ name, size: 10, ...extra });
const rows = (els) => els['geo-import-list'].children;
const action = (row, name) =>
row.children.find((c) => c.dataset.action === name);
// Runs first: tools left by later tests would answer the same registry.
test('imported files make the language switch ask first', async () => {
const t = setup();
assert.deepEqual(describeUnsavedWork(), []);
await t.tool.importFiles([file('a.geojson')]);
assert.deepEqual(describeUnsavedWork(), ['imported map files']);
t.tool.remove(t.tool.list()[0].id);
assert.deepEqual(describeUnsavedWork(), [], 'nothing left to lose');
await t.tool.importFiles([file('b.kml')]);
t.tool.destroy();
assert.deepEqual(describeUnsavedWork(), [], 'destroy unregisters');
});
const rows = (t) => t.shown().rows;
const action = (row, name) => row.actions.find((a) => a.action === name);
// A row button's click, as the view sends it.
const ROW_ACTIONS = {
toggle: 'toggleGeoImport',
fly: 'flyToGeoImport',
remove: 'removeGeoImport',
};
const press = (t, row, name) => t.act(ROW_ACTIONS[name], row.id);
test('returns null without its markup or viewer', () => {
const document = { getElementById: () => null };
@@ -237,43 +160,30 @@ test('returns null without its markup or viewer', () => {
);
});
test('the Import button opens the picker and a pick imports each file', async () => {
test('a pick imports each file', async () => {
const t = setup();
t.els['geo-import-button'].click();
assert.equal(t.els['geo-import-input'].clicks, 1);
assert.equal(
t.els['geo-import-input'].accept,
'.geojson,.json,.kml,.kmz,.gpx',
);
const out = await t.tool.importFiles([file('a.geojson'), file('b.kml')]);
assert.deepEqual(
out.map((o) => o.ok),
[true, true],
);
t.act('importGeoFiles', [file('a.geojson'), file('b.kml')]);
for (let turn = 0; turn < 20 && t.added.length < 2; turn += 1)
await new Promise((r) => setImmediate(r));
assert.equal(t.added.length, 2);
assert.notEqual(
t.loads[0].opts.color,
t.loads[1].opts.color,
'each file gets its own color',
);
assert.equal(rows(t.els).length, 2);
assert.ok(t.els['geo-import-list'].classList.contains('visible'));
assert.equal(rows(t).length, 2);
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
assert.equal(t.els['geo-import-hint'].textContent, 'Added 2 files.');
assert.equal(t.shown().hint, 'Added 2 files.');
assert.deepEqual(
t.tool.list().map((f) => f.name),
['a.geojson', 'b.kml'],
);
});
test('file names are text, never markup', async () => {
test('file names are published as given, for the view to show as text', async () => {
const t = setup();
await t.tool.importFiles([file('<img src=x onerror=alert(1)>.kml')]);
const name = rows(t.els)[0].children.find((c) =>
c.classList.contains('geo-import-name'),
);
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
assert.equal(name.innerHTML, undefined);
assert.equal(rows(t)[0].name, '<img src=x onerror=alert(1)>.kml');
});
test('row buttons hide, show, fly to and remove a file', async () => {
@@ -281,19 +191,14 @@ test('row buttons hide, show, fly to and remove a file', async () => {
await t.tool.importFiles([file('a.gpx')]);
const [{ id }] = t.tool.list();
const ds = t.added[0];
// the click lands on the glyph inside the button, as in a browser
const glyphClick = (name) =>
t.els['geo-import-list'].emit('click', {
target: action(rows(t.els)[0], name).children[0],
});
const glyphClick = (name) => press(t, rows(t)[0], name);
assert.equal(ds.show, true);
glyphClick('toggle');
assert.equal(ds.show, false);
assert.equal(t.tool.list()[0].visible, false);
assert.equal(
action(rows(t.els)[0], 'toggle').getAttribute('aria-pressed'),
'false',
);
assert.equal(action(rows(t)[0], 'toggle').pressed, 'false');
assert.equal(action(rows(t)[0], 'toggle').icon, 'visibility_off');
assert.equal(action(rows(t)[0], 'toggle').label, 'Show a.gpx');
glyphClick('fly');
assert.equal(ds.show, true, 'flying to a hidden file shows it');
assert.equal(t.flights.at(-1), ds);
@@ -301,7 +206,7 @@ test('row buttons hide, show, fly to and remove a file', async () => {
assert.deepEqual(t.removed, [{ ds, destroy: true }]);
assert.deepEqual(t.revoked, ['a.gpx']);
assert.equal(t.tool.list().length, 0);
assert.ok(!t.els['geo-import-list'].classList.contains('visible'));
assert.deepEqual(rows(t), []);
assert.equal(t.tool.remove(id), false);
});
@@ -319,7 +224,7 @@ test('a refused file says why and does not stop the rest of the batch', async ()
assert.equal(out[0].error, 'That KML file is not valid XML.');
assert.match(out[1].error, /could not be read\./);
assert.match(
t.els['geo-import-hint'].textContent,
t.shown().hint,
/^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/,
);
assert.equal(t.tool.list().length, 1);
@@ -380,14 +285,10 @@ test('destroy removes every file and listener and the window handle', async () =
t.tool.destroy();
assert.equal(t.removed.length, 2);
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
for (const el of [
t.viewer.container,
t.els['geo-import-button'],
t.els['geo-import-input'],
t.els['geo-import-list'],
])
assert.equal(el.listeners.length, 0);
assert.equal(rows(t.els).length, 0);
assert.equal(t.viewer.container.listeners.length, 0);
assert.equal(t.document.listeners.length, 0);
assert.equal(t.actions(), null, 'the view is disconnected');
assert.deepEqual(t.shown(), { rows: [], hint: '' });
assert.equal(globalThis.window.__gevGeoImport, undefined);
assert.equal(t.tool.diagnostics().destroyed, true);
} finally {
@@ -427,9 +328,7 @@ test('the application composition wires the import and owns its teardown', () =>
new URL('../app/tools.js', import.meta.url),
'utf8',
);
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
const drawAt = tools.indexOf('const drawTool = initDrawTool({');
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
assert.ok(importAt > drawAt && drawAt >= 0);
// Fly-to goes through the application shell's navigation policy.
@@ -438,8 +337,13 @@ test('the application composition wires the import and owns its teardown', () =>
/navigation: styleManager/,
);
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
// The list renders from the store and its controls answer through it.
assert.match(
tools.slice(importAt, importAt + 400),
/publish: \(view\) => displayStore\.publishGeoImport\(view\),\s*connect: connectDisplay,/,
);
const markup = fs.readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
new URL('./views/panels/GeoImport.vue', import.meta.url),
'utf8',
);
for (const id of [
@@ -450,7 +354,7 @@ test('the application composition wires the import and owns its teardown', () =>
])
assert.ok(
markup.includes(`id="${id}"`),
`${id} missing from the DISPLAY template`,
`${id} missing from the Import template`,
);
});
@@ -667,32 +571,6 @@ test('a refused deferred stamp (Cockpit) imports without flying', async () => {
assert.equal(flown.length, 0);
});
test('keyboard focus stays on the control after hide, show and remove', async () => {
const t = setup();
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
// Clicks reach the list's delegated listener, as they bubble in a page.
const press = (btn) =>
t.els['geo-import-list'].emit('click', { target: btn });
const [first] = rows(t.els);
action(first, 'toggle').focus();
press(action(first, 'toggle'));
// The list was rebuilt; focus is on the NEW toggle of the same row.
const again = rows(t.els)[0];
assert.notEqual(again, first);
assert.equal(t.document.activeElement, action(again, 'toggle'));
assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false');
press(action(again, 'toggle'));
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle'));
// Remove the first row: focus moves to the same control on the next row.
action(rows(t.els)[0], 'remove').focus();
press(action(rows(t.els)[0], 'remove'));
assert.equal(rows(t.els).length, 1);
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove'));
// Remove the last row: focus goes back to the Import button.
press(action(rows(t.els)[0], 'remove'));
assert.equal(t.document.activeElement, t.els['geo-import-button']);
});
test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
const t = setup({
load: async (f) => ({
@@ -705,12 +583,11 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
}),
});
await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
const [kml, gpx] = rows(t.els);
assert.ok(kml.children[0].classList.contains('own-styles'));
assert.equal(kml.children[0].style.background, undefined);
assert.match(kml.children[1].title, /drawn with its own styles/);
assert.ok(!gpx.children[0].classList.contains('own-styles'));
assert.ok(gpx.children[0].style.background);
const [kml, gpx] = rows(t);
assert.equal(kml.ownStyles, true);
assert.match(kml.title, /drawn with its own styles/);
assert.equal(gpx.ownStyles, false);
assert.ok(gpx.color);
});
test('the map-object count in a row title keeps digit grouping', async () => {
@@ -725,8 +602,8 @@ test('the map-object count in a row title keeps digit grouping', async () => {
}),
});
await t.tool.importFiles([file('big.geojson')]);
const [row] = rows(t.els);
assert.match(row.children[1].title, /12,345 map objects/);
const [row] = rows(t);
assert.match(row.title, /12,345 map objects/);
t.tool.destroy();
});