i18n: fix the final pre-merge review findings

Fixes the P1 and the confirmed P2 findings of the final review of the
i18n branch.

- CI: the air-quality model no longer imports i18n (portable module),
  so check:boundaries passes again.
- Air quality layer: named in the layer panel, HUD and voice; its
  Weather card, bands and errors translate. A test keeps the layer-name
  table in step with the layer registry.
- Layer statuses that still reached ru/es in English: GNSS, CelesTrak,
  Transit loading and feed errors, launches, perimeters, ALPR, ECCC,
  wind "field unavailable", Local ADS-B streaming lines.
- Voice and HUD: preset ids show city names ("Fly to San Francisco"),
  the HUD city translates, cockpit refusals cover frame, aircraft and
  satellite; empty Transit notes translate on the analyst card.
- tn() keeps a caller's formatted count ("12,345 map objects").
- Cyber HUD: the language button no longer covers the bottom-right
  card or the cockpit position readout.
- CCTV buttons wrap only in non-English locales; English layout matches
  main again.
- Switching language asks first when Provider Settings holds unsaved
  keys.

Catalogs: es/ru 2611 messages each, complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-16 16:39:25 +04:00
1 parent fada8b161b
commit 3adce33cc6
43 files changed
+1350 -97

No files matched your search

+145 -26
View File
@@ -9,25 +9,42 @@ import {
} from './keySetup.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
const home = { prepend: (node) => { node.parentNode = home; } };
const home = {
prepend: (node) => {
node.parentNode = home;
},
};
const chip = { parentNode: home, hidden: true };
let moves = 0;
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
const toolbar = {
append: (node) => {
moves++;
node.parentNode = toolbar;
},
};
const theme = { dataset: { uiTheme: 'tactical' } };
let notify, disconnected = false;
let notify,
disconnected = false;
class Observer {
constructor(callback) { notify = callback; }
constructor(callback) {
notify = callback;
}
observe(target, options) {
assert.equal(target, theme);
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
}
disconnect() { disconnected = true; }
disconnect() {
disconnected = true;
}
}
const dispose = bindKeySetupPlacement({
documentElement: theme,
getElementById: () => toolbar,
defaultView: { MutationObserver: Observer },
}, chip);
const dispose = bindKeySetupPlacement(
{
documentElement: theme,
getElementById: () => toolbar,
defaultView: { MutationObserver: Observer },
},
chip,
);
assert.equal(chip.parentNode, home);
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
theme.dataset.uiTheme = variant;
@@ -42,10 +59,20 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
});
test('the chip counts what is missing, and retires the count at zero', () => {
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
assert.equal(
keySetupChipLabel({ setCount: 0, total: 8 }),
'POWER UP · 8 KEYS WAITING',
);
assert.equal(
keySetupChipLabel({ setCount: 7, total: 8 }),
'POWER UP · 1 KEY WAITING',
);
assert.equal(keySetupChipLabel({ setCount: 8, total: 8 }), 'POWERED UP');
assert.equal(keySetupChipLabel(null), 'POWERED UP', 'no status is not a broken label');
assert.equal(
keySetupChipLabel(null),
'POWERED UP',
'no status is not a broken label',
);
});
test('collectKeyUpdates keeps only non-empty trimmed values', () => {
@@ -85,14 +112,27 @@ test('OAuth login polling stops as soon as local ChatGPT auth becomes available'
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
for (const result of [
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
{
ok: true,
payload: {
available: false,
loginFailed: true,
error: 'Sign-in failed.',
},
},
{ ok: false, payload: { error: 'Sign-in is local only.' } },
]) {
let checks = 0;
await assert.rejects(waitForChatGptOAuth({
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
sleep: () => assert.fail('failed login must stop polling'),
}), { message: result.payload.error });
await assert.rejects(
waitForChatGptOAuth({
fetchImpl: async () => {
checks += 1;
return { ok: result.ok, json: async () => result.payload };
},
sleep: () => assert.fail('failed login must stop polling'),
}),
{ message: result.payload.error },
);
assert.equal(checks, 1);
}
});
@@ -100,12 +140,34 @@ test('OAuth login polling stops on login or HTTP failure instead of waiting for
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
let clock = 0;
let checks = 0;
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
const poll = {
fetchImpl: async () => {
checks += 1;
return {
ok: true,
json: async () => ({ available: false, pending: true }),
};
},
};
assert.equal(
await waitForChatGptOAuth({
...poll,
timeoutMs: 100,
pollMs: 100,
now: () => clock,
sleep: async (ms) => {
clock += ms;
},
}),
false,
);
assert.equal(checks, 2);
const controller = new AbortController();
controller.abort();
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
assert.equal(
await waitForChatGptOAuth({ ...poll, signal: controller.signal }),
false,
);
assert.equal(checks, 2, 'aborted polling does not fetch');
});
@@ -113,14 +175,22 @@ test('OAuth polling ignores a ready response received after the surface was abor
const controller = new AbortController();
const available = await waitForChatGptOAuth({
signal: controller.signal,
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
fetchImpl: async () => ({
ok: true,
json: async () => {
controller.abort();
return { available: true };
},
}),
sleep: () => assert.fail('aborted polling must not continue'),
});
assert.equal(available, false);
});
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
const stripped = stripKeylessBasemapFromHash(
'lat=30.2&lon=-97.7&map=osm&style=normal',
);
assert.ok(stripped !== null);
const params = new URLSearchParams(stripped);
assert.equal(params.get('map'), null, 'osm basemap removed');
@@ -128,7 +198,11 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha
assert.equal(params.get('style'), 'normal', 'style survives');
// A stack under any other name was chosen or shared on purpose.
assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null);
assert.equal(stripKeylessBasemapFromHash('lat=1&lon=2'), null, 'no stack, nothing to do');
assert.equal(
stripKeylessBasemapFromHash('lat=1&lon=2'),
null,
'no stack, nothing to do',
);
assert.equal(stripKeylessBasemapFromHash(''), null);
assert.equal(stripKeylessBasemapFromHash(undefined), null);
});
@@ -142,11 +216,15 @@ test('aborting pending setup removes its surface and ignores a late response', a
let requestSignal;
const controller = new AbortController();
const pending = initKeySetup({
documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root },
documentRef: {
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
},
signal: controller.signal,
fetchImpl: (_url, { signal }) => {
requestSignal = signal;
return new Promise((resolve) => { resolveResponse = resolve; });
return new Promise((resolve) => {
resolveResponse = resolve;
});
},
});
controller.abort();
@@ -155,3 +233,44 @@ test('aborting pending setup removes its surface and ignores a late response', a
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
assert.equal(await pending, null);
});
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
const { initKeySetup } = await import('./keySetup.js');
const { describeUnsavedWork } = await import('./i18n/index.js');
const node = () => ({
dataset: {},
addEventListener() {},
removeEventListener() {},
setAttribute() {},
querySelector: () => null,
remove() {},
});
const chip = node();
const input = { dataset: { envVar: 'GOOGLE_MAPS_API_KEY' }, value: '' };
const root = {
...node(),
querySelectorAll: (selector) =>
selector === 'input[data-env-var]' ? [input] : [],
};
const controller = new AbortController();
const setup = await initKeySetup({
documentRef: {
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
addEventListener() {},
removeEventListener() {},
},
signal: controller.signal,
fetchImpl: async () => ({
ok: true,
json: async () => ({ keys: [], setCount: 0, total: 1 }),
}),
});
assert.ok(setup);
assert.deepEqual(describeUnsavedWork(), []);
input.value = ' ';
assert.deepEqual(describeUnsavedWork(), []);
input.value = 'AIza-pasted';
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
controller.abort();
assert.deepEqual(describeUnsavedWork(), []);
});