255 lines
14 KiB
TypeScript
255 lines
14 KiB
TypeScript
import React, { act } from "react";
|
|
import assert from "node:assert/strict";
|
|
import { afterEach, test } from "node:test";
|
|
import { parseHTML } from "linkedom";
|
|
import { setDictationActive } from "../components/dictation-state.ts";
|
|
import { I18nProvider, LanguageSelect, SttModelSelect, useI18n } from "../components/i18n.tsx";
|
|
import { AUTH_KEYS, type AuthKeyStatus, type ModelCatalogEntry, type UserSettings } from "../src/core/index.ts";
|
|
import { ModelMenuList } from "../components/settings/model-menu.tsx";
|
|
import { DictationLanguageSetting } from "../components/dictation-language.tsx";
|
|
|
|
let dispose: (() => Promise<void>) | undefined;
|
|
let restore: (() => void) | undefined;
|
|
const byTest = (id: string) => document.querySelector<HTMLButtonElement>(`[data-testid="${id}"]`)!;
|
|
const trigger = () => byTest("language-select-sttLanguage");
|
|
const options = () => [...document.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')];
|
|
async function click(element: Element | Document) { await act(async () => { element.dispatchEvent(new window.Event("click", { bubbles: true })); }); }
|
|
async function key(element: Element | Document, value: string) {
|
|
await act(async () => {
|
|
const event = new window.Event("keydown", { bubbles: true, cancelable: true });
|
|
Object.assign(event, { key: value }); element.dispatchEvent(event);
|
|
});
|
|
}
|
|
async function search(value: string, element: HTMLInputElement) {
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
|
|
element.dispatchEvent(new window.Event("input", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
async function fixture(failRead = false) {
|
|
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div></body></html>");
|
|
let active: Element = doc.body;
|
|
Object.defineProperty(doc, "activeElement", { get: () => active });
|
|
Object.assign(doc, { oninput: null });
|
|
Object.assign(win, { innerWidth: 1280, innerHeight: 900 });
|
|
win.HTMLElement.prototype.focus = function () { active = this; };
|
|
win.HTMLElement.prototype.scrollIntoView = () => {};
|
|
win.HTMLElement.prototype.getBoundingClientRect = () => ({ top: 200, bottom: 230, left: 400, right: 500, width: 100, height: 30, x: 400, y: 200, toJSON() {} });
|
|
const state = {
|
|
record: { version: 1, general: { sttLanguage: "en" }, dictation: { language: "pl" } } as UserSettings,
|
|
writes: [] as string[], failWrite: false, outerEscapes: 0, failRead,
|
|
keys: [] as AuthKeyStatus[], nextWrite: undefined as Promise<Response> | undefined,
|
|
};
|
|
const previous = new Map<string, PropertyDescriptor | undefined>();
|
|
for (const [name, value] of Object.entries({
|
|
React, window: win, document: doc, Element: win.Element, HTMLElement: win.HTMLElement,
|
|
HTMLInputElement: win.HTMLInputElement, HTMLTextAreaElement: win.HTMLTextAreaElement,
|
|
Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
|
|
navigator: { userAgent: "node", languages: ["en-US"] },
|
|
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
|
|
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
|
|
cancelAnimationFrame: clearTimeout,
|
|
fetch: async (url: string, init?: RequestInit) => {
|
|
if (url === "/api/keys") return Response.json({ keys: state.keys, providers: [] });
|
|
if (url === "/api/settings") return state.failRead ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
|
|
assert.equal(url, "/api/settings/general"); assert.equal(init?.method, "PUT");
|
|
state.writes.push(init!.body as string);
|
|
if (state.nextWrite) return state.nextWrite;
|
|
if (state.failWrite) return Response.json({}, { status: 500 });
|
|
state.record = { ...state.record, general: JSON.parse(init!.body as string) };
|
|
return Response.json({ settings: state.record });
|
|
},
|
|
})) {
|
|
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
|
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
|
}
|
|
restore = () => {
|
|
for (const [name, descriptor] of previous) {
|
|
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
|
else delete (globalThis as Record<string, unknown>)[name];
|
|
}
|
|
};
|
|
doc.addEventListener("keydown", (event) => { if ((event as KeyboardEvent).key === "Escape") state.outerEscapes++; });
|
|
const { createRoot } = await import("react-dom/client");
|
|
let root = createRoot(doc.getElementById("root")!);
|
|
let i18n!: ReturnType<typeof useI18n>;
|
|
function Observer() { i18n = useI18n(); return null; }
|
|
const render = async (element: React.ReactNode) => {
|
|
await act(async () => { root.render(<I18nProvider initialGeneral={state.record.general}><Observer />{element}</I18nProvider>); });
|
|
};
|
|
const mount = () => render(<DictationLanguageSetting />);
|
|
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
|
|
await mount();
|
|
return {
|
|
state, render, get i18n() { return i18n; },
|
|
remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); },
|
|
};
|
|
}
|
|
|
|
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
|
|
|
|
test("the shared language row exposes only fork system/en/ru/multi choices and saves general", async () => {
|
|
const f = await fixture();
|
|
assert.equal(trigger().textContent?.trim(), "English▾");
|
|
await click(trigger());
|
|
assert.deepEqual(options().map((option) => option.textContent?.trim().replace("✓", "").trim()), ["System default", "English", "Русский", "Multilingual"]);
|
|
assert.equal(options()[1]!.getAttribute("aria-checked"), "true");
|
|
await click(options()[2]!);
|
|
assert.equal(f.state.record.general?.sttLanguage, "ru");
|
|
assert.equal(f.state.record.dictation?.language, "pl", "the contradictory field is neither migrated nor updated");
|
|
assert.equal(trigger().textContent?.trim(), "Русский▾");
|
|
assert.equal(f.state.writes.length, 1);
|
|
});
|
|
|
|
test("System default removes the general override without introducing another default", async () => {
|
|
const f = await fixture(); await click(trigger()); await click(options()[0]!);
|
|
assert.equal(Object.hasOwn(f.state.record.general!, "sttLanguage"), false);
|
|
assert.match(trigger().textContent!, /System default/);
|
|
assert.equal(f.i18n.sttLanguage, "en");
|
|
});
|
|
|
|
test("the compact composer selector and settings row share one confirmed general choice", async () => {
|
|
const f = await fixture();
|
|
await f.render(<><DictationLanguageSetting /><LanguageSelect field="sttLanguage" compact /></>);
|
|
await click(trigger()); await click(options()[3]!);
|
|
assert.match(trigger().textContent!, /Multilingual/);
|
|
assert.match(byTest("language-select-sttLanguage-compact").textContent!, /Multi/);
|
|
await click(byTest("language-select-sttLanguage-compact")); await click(options()[1]!);
|
|
assert.equal(f.state.record.general?.sttLanguage, "ru");
|
|
assert.match(trigger().textContent!, /Русский/);
|
|
});
|
|
|
|
test("a settings read failure is visible on save and the existing control can retry", async () => {
|
|
const f = await fixture(true); await click(trigger()); await click(options()[2]!);
|
|
assert.match(document.body.textContent!, /Could not load language settings/);
|
|
assert.equal(f.state.writes.length, 0);
|
|
assert.equal(f.state.record.general?.sttLanguage, "en");
|
|
f.state.failRead = false; await click(trigger()); await click(options()[2]!);
|
|
assert.equal(f.state.record.general?.sttLanguage, "ru");
|
|
});
|
|
|
|
test("failed saves retain the confirmed choice and the same control can retry", async () => {
|
|
const f = await fixture(); f.state.failWrite = true;
|
|
await click(trigger()); await click(options()[3]!);
|
|
assert.match(document.body.textContent!, /Could not save language settings/);
|
|
assert.equal(f.state.record.general?.sttLanguage, "en");
|
|
assert.match(trigger().textContent!, /English/);
|
|
f.state.failWrite = false; await click(trigger()); await click(options()[3]!);
|
|
assert.equal(f.state.record.general?.sttLanguage, "multi");
|
|
});
|
|
|
|
test("an outstanding save locks the selector and releases it only after confirmation", async () => {
|
|
const f = await fixture(); let resolve!: (response: Response) => void;
|
|
f.state.nextWrite = new Promise<Response>((done) => { resolve = done; });
|
|
await click(trigger()); await click(options()[3]!);
|
|
assert.equal(trigger().disabled, true);
|
|
assert.match(trigger().textContent!, /English/);
|
|
f.state.record.general = { sttLanguage: "multi" };
|
|
await act(async () => { resolve(Response.json({ settings: f.state.record })); });
|
|
assert.equal(trigger().disabled, false);
|
|
assert.match(trigger().textContent!, /Multilingual/);
|
|
await f.remount(); assert.match(trigger().textContent!, /Multilingual/);
|
|
});
|
|
|
|
test("Escape and outside click close only the fork language menu", async () => {
|
|
await fixture(); await click(trigger());
|
|
await key(document, "Escape");
|
|
assert.equal(trigger().getAttribute("aria-expanded"), "false");
|
|
assert.equal(document.activeElement, trigger());
|
|
await click(trigger());
|
|
await act(async () => { document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); });
|
|
assert.equal(trigger().getAttribute("aria-expanded"), "false");
|
|
});
|
|
|
|
test("capture locks the shared row and dismisses its open menu until cleanup", async () => {
|
|
await fixture(); await click(trigger());
|
|
assert.equal(trigger().getAttribute("aria-expanded"), "true");
|
|
await act(async () => { setDictationActive(true); });
|
|
assert.equal(trigger().disabled, true);
|
|
assert.equal(trigger().getAttribute("aria-expanded"), "false");
|
|
assert.equal(options().length, 0);
|
|
await act(async () => { setDictationActive(false); });
|
|
assert.equal(trigger().disabled, false);
|
|
});
|
|
|
|
test("the existing STT model selector retains both Deepgram and OpenRouter MAI", async () => {
|
|
const f = await fixture(); await f.render(<SttModelSelect />);
|
|
await click(byTest("stt-model-select"));
|
|
assert.deepEqual(options().map((option) => option.textContent?.replace("✓", "").trim()), ["Nova-3 · Deepgram", "MAI-Transcribe-2 · OpenRouter"]);
|
|
await click(options()[1]!);
|
|
assert.equal(f.state.record.general?.sttModel, "microsoft/mai-transcribe-2");
|
|
assert.equal(f.state.record.general?.sttLanguage, "en");
|
|
});
|
|
|
|
for (const source of ["none", "file", "env"] as const) test(`Dictation retains fork language controls for ${source}-owned keys, including editing`, async () => {
|
|
const f = await fixture();
|
|
f.state.keys = [{
|
|
id: "DEEPGRAM_API_KEY", info: AUTH_KEYS.find((key) => key.id === "DEEPGRAM_API_KEY")!,
|
|
present: source !== "none", masked: source === "none" ? null : "…test (32 chars)",
|
|
source, shadowedByEnv: false,
|
|
}];
|
|
const { KeysSection } = await import("../components/settings/keys-section.tsx");
|
|
await f.render(<KeysSection />);
|
|
const group = byTest("group-dictation");
|
|
const rows = group.lastElementChild!.children;
|
|
assert.equal(rows[0]!.getAttribute("data-testid"), "key-deepgram_api_key");
|
|
assert.equal(rows[1]!.getAttribute("data-testid"), "row-dictation-language");
|
|
assert.equal(rows[2]!.getAttribute("data-testid"), "dictation-setup-hint");
|
|
assert(group.querySelector('[data-testid="row-dictation-language"] [data-testid="language-select-sttLanguage"]'));
|
|
assert.equal(group.querySelector('[data-testid="dictation-language"]'), null);
|
|
assert.equal(byTest("dictation-setup-hint").querySelector("a")!.textContent, "Deepgram API key");
|
|
assert.equal(byTest("dictation-setup-hint").querySelectorAll("a").length, 1);
|
|
assert.match(rows[1]!.querySelector(".font-prose")!.className, /text-\(length:--lh-prose-size\)/);
|
|
assert.equal(trigger().disabled, false, "language is independent of key ownership");
|
|
await click(byTest("key-deepgram_api_key-row"));
|
|
if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"));
|
|
assert.ok(byTest("dictation-setup-hint"), "guidance remains visible while the key is edited");
|
|
await click(trigger());
|
|
assert.equal(options().length, 4);
|
|
await key(document, "Escape");
|
|
assert.equal(options().length, 0);
|
|
if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"), "field Escape does not cancel the key editor");
|
|
await click(trigger()); await click(options()[3]!);
|
|
assert.equal(f.state.record.general?.sttLanguage, "multi");
|
|
});
|
|
|
|
test("the shared model menu preserves ranking, provider identity, keyboard clamping, and row content", async () => {
|
|
const f = await fixture();
|
|
const model = (id: string, provider: string): ModelCatalogEntry => ({
|
|
id, provider, name: `Model ${id}`, contextWindow: 32000, maxTokens: 4096,
|
|
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
|
input: ["text"], reasoning: false, supportedLevels: [],
|
|
});
|
|
const models = [model("beta", "p1"), model("alpha", "p1"), model("alpha", "p2")];
|
|
const picked: ModelCatalogEntry[] = [];
|
|
await f.render(<ModelMenuList models={models} effectiveId="alpha" effectiveProvider="p2" onPick={(m) => picked.push(m)} footer="Model footer" />);
|
|
const modelInput = document.querySelector<HTMLInputElement>('[data-testid="model-search"]')!;
|
|
const options = () => [...document.querySelectorAll<HTMLButtonElement>('[data-testid="model-list"] button')];
|
|
assert.equal(document.activeElement, modelInput);
|
|
assert.match(options()[0]!.textContent!, /p1 · alpha/);
|
|
assert.match(options()[1]!.textContent!, /p2 · alpha/);
|
|
assert.ok(!options()[0]!.className.includes("bg-muted/60"));
|
|
assert.ok(options()[1]!.className.includes("bg-muted/60"));
|
|
assert.match(options()[0]!.textContent!, /\$1\.00\/\$2\.00/);
|
|
assert.match(options()[0]!.textContent!, /32k/);
|
|
assert.match(document.body.textContent!, /Model footer/);
|
|
await key(modelInput, "Enter");
|
|
assert.equal(picked[0], models[1], "Enter without walking selects the first ranked result");
|
|
await key(modelInput, "ArrowUp");
|
|
await key(modelInput, "Enter");
|
|
assert.equal(picked[1], models[0], "ArrowUp enters at the last result");
|
|
await key(modelInput, "ArrowDown");
|
|
await key(modelInput, "ArrowDown");
|
|
await key(modelInput, "Enter");
|
|
assert.equal(picked[2], models[0], "ArrowDown clamps at the last result");
|
|
await search("p2", modelInput);
|
|
assert.equal(options().length, 1);
|
|
await key(modelInput, "Enter");
|
|
assert.equal(picked[3], models[2], "search resets the walked index and includes provider identity");
|
|
await search("no-model-matches", modelInput);
|
|
await key(modelInput, "Enter");
|
|
assert.equal(picked.length, 4);
|
|
assert.match(document.body.textContent!, /No models match/);
|
|
});
|