Merge origin/main into fix/hal-722-radio-next-no-autoplay

main moved the Radio markup into Vue views and rebuilt the UI tests on
stores. Resolved:
- src/ui/templates/context.html: deleted in main; the new privacy note now
  lives in src/ui/views/panels/RadioPanel.vue.
- src/ui/radioControls.test.mjs: main's store fixture; the Next/Previous
  autoplay test drives store.act('cycleRadio').
- src/voice/gevRealtime.test.mjs: main's createTestVoiceUi; the stop and
  select handoff cases stay one table-driven test.
- src/data/radioNextState.test.mjs reads the published gev-radio view.
- src/i18n/locales/ar.json: the new note translated for the Arabic locale.
This commit is contained in:
German Khokhlov committed 2026-11-02 12:36:31 +04:00
commit a2be947aba
679 files changed
+45369 -19625

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
import { readFileSync } from 'node:fs';
import { createPinia } from 'pinia';
import { createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { APPLICATION_VIEWS } from '../ui/views/index.js';
const HOST = /<div class="gev-view" data-gev-view="([^"]+)"><\/div>/g;
/**
* Application markup as the browser shows it once views are mounted: each
* view rendered into its host. Markup tests read this instead of the page
* source.
* @param {string} html Document with `.gev-view` hosts.
* @returns {Promise<string>}
*/
export async function renderApplicationHtml(html) {
let output = html;
for (const [host, name] of [...output.matchAll(HOST)]) {
const view = APPLICATION_VIEWS[name];
if (!view) throw new Error(`Unknown application view: ${name}`);
const markup = (await renderToString(createSSRApp(view).use(createPinia())))
// Hydration markers, empty-attribute values and escaped apostrophes
// are SSR detail; the DOM is the same as the template's.
.replace(/<!--\[-->|<!--\]-->|<!---->/g, '')
.replace(/ ([\w:.-]+)=""/g, ' $1')
.replaceAll('&#39;', "'");
output = output.replace(
host,
() => `<div class="gev-view" data-gev-view="${name}">${markup}</div>`,
);
}
return output;
}
/** The standalone page's markup with every view rendered. */
export function readApplicationHtml() {
return renderApplicationHtml(
readFileSync(new URL('../../index.html', import.meta.url), 'utf8'),
);
}
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
const describe = (node) =>
node?.nodeType === 1
? `<${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}>`
: String(node);
/**
* Assert that two DOM nodes (or a node and null) are the same. Never hand
* happy-dom nodes to assert.equal or deepEqual: a failure prints both, and a
* node reaches the whole page and window, which runs the process out of
* memory.
*/
export function assertSameNode(actual, expected, message = '') {
assert.ok(
actual === expected,
`${message ? `${message}: ` : ''}${describe(actual)} is not ${describe(expected)}`,
);
}
+2 -1
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { createVoiceSession } from '../voice/session.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { createGeminiSession } from '../voice/geminiSession.js';
import { createTestVoiceUi } from './voiceUi.mjs';
import { createNarrationScheduler } from '../voice/narration.js';
export const turnOfEventLoop = () =>
@@ -158,7 +159,7 @@ export function nativeIntegrationFixture(t) {
createAdapter: (hooks) =>
createGeminiSession({
...hooks,
ui: { root: { remove() {} } },
ui: createTestVoiceUi(),
dataManager,
now: clock.now,
setTimer: clock.setTimer,
+2 -1
View File
@@ -1,4 +1,5 @@
import { GeminiSessionController } from '../voice/geminiSession.js';
import { createTestVoiceUi } from './voiceUi.mjs';
import { createNarrationScheduler } from '../voice/narration.js';
const endpoint =
@@ -121,7 +122,7 @@ function harness(overrides = {}) {
calls.push({ name, args, options });
return { ok: true, action: name };
},
ui: { root: { remove() {} } },
ui: createTestVoiceUi(),
radioLayer: radio,
now: () => clock,
setTimer: (fn, ms) => {
+35
View File
@@ -0,0 +1,35 @@
// src/testSupport/hudReadouts.mjs — the Intel HUD's published readouts by the
// element ids its view renders them in, for tests that drive IntelHUD alone.
const FIELDS = Object.freeze({
'hud-alt': 'alt',
'hud-summary': 'summary',
'hud-mgrs': 'mgrs',
'hud-latlon': 'latlon',
'hud-bottom-line': 'bottom',
'hud-gsd': 'gsd',
'hud-coll': 'coll',
'hud-ona': 'ona',
'hud-mode': 'mode',
'hud-timestamp': 'timestamp',
});
/**
* A `publish` to hand IntelHUD and each readout's latest text.
* @returns {{publish: (view: object) => void, view: () => ?object,
* elements: Map<string, {readonly textContent: string}>}}
*/
export function hudReadouts() {
let view = null;
const elements = new Map(
Object.entries(FIELDS).map(([id, field]) => [
id,
{
get textContent() {
return view?.[field] ?? '';
},
},
]),
);
return { publish: (next) => (view = next), view: () => view, elements };
}
+21
View File
@@ -0,0 +1,21 @@
// src/testSupport/panelState.mjs — present a panel's state the way the shell
// does (PanelChrome._presentPanel), drawn at once as its root's v-panel-state
// would, for fixtures whose panels are plain objects with a classList.
import { applyPanelState, readPanelState } from '../ui/rail/panelState.js';
/**
* @param {object|string} panelOrId A panel, or its id in `documentRef`.
* @param {object} [patch] The state that changes.
* @param {Document} [documentRef]
*/
export function presentPanelLocally(
panelOrId,
patch = {},
documentRef = globalThis.document,
) {
const panel =
typeof panelOrId === 'string'
? documentRef?.getElementById(panelOrId)
: panelOrId;
if (panel) applyPanelState(panel, { ...readPanelState(panel), ...patch });
}
+5
View File
@@ -0,0 +1,5 @@
import { GlobalRegistrator } from '@happy-dom/global-registrator';
// Import first in a test file whose modules load Vue at import time: Vue binds
// the document when it loads. Each test file runs in its own process.
GlobalRegistrator.register({ url: 'http://localhost/' });
+16
View File
@@ -0,0 +1,16 @@
import { createPinia } from 'pinia';
import { useVoiceStore } from '../ui/stores/voice.js';
import { createVoiceControl } from '../voice/control.js';
/**
* A real voice control presenter on its own store, for owner tests that run
* without a page. Read what it shows from `ui.state`; report interactions
* with `ui.emit(name, ...args)`.
* @param {{panel?: boolean, pinia?: import('pinia').Pinia}} [options]
*/
export function createTestVoiceUi({
panel = false,
pinia = createPinia(),
} = {}) {
return createVoiceControl({ panel, store: useVoiceStore(pinia) });
}
+39
View File
@@ -0,0 +1,39 @@
import { createApp, h, nextTick } from 'vue';
import { createPinia, storeToRefs } from 'pinia';
import { useVoiceStore } from '../ui/stores/voice.js';
import VoiceControl from '../ui/views/panels/VoiceControl.vue';
/**
* Render whichever voice control the store presents, as the command dock
* does, into the current document. Import registerDom.mjs first.
* @param {import('node:test').TestContext} t
* @returns {{pinia: import('pinia').Pinia, settle: () => Promise<void>}}
*/
export function mountVoiceView(t) {
const doc = globalThis.document;
// happy-dom lacks the browser's `[hidden]` rule, which visibility checks
// and focus moves rely on.
if (!doc.getElementById('test-ua-hidden')) {
const style = doc.head.appendChild(doc.createElement('style'));
style.id = 'test-ua-hidden';
style.textContent = '[hidden] { display: none; }';
}
doc.body.replaceChildren();
const host = doc.body.appendChild(doc.createElement('div'));
const pinia = createPinia();
const { view } = storeToRefs(useVoiceStore(pinia));
const app = createApp({
render: () =>
view.value
? h(VoiceControl, { key: view.value.id, view: view.value })
: null,
}).use(pinia);
app.mount(host);
t.after(() => app.unmount());
// Renders, then any focus the owners asked for after it.
const settle = async () => {
await nextTick();
await nextTick();
};
return { pinia, settle };
}
+10
View File
@@ -0,0 +1,10 @@
import { GlobalRegistrator } from '@happy-dom/global-registrator';
/**
* Give a component test a browser document; returns the teardown.
* Call it from `before`/`after`, since other suites assert there is no DOM.
*/
export function installVueDom() {
GlobalRegistrator.register({ url: 'http://localhost/' });
return () => GlobalRegistrator.unregister();
}