Merge PR114 dictation foundation for approved provider-row finishing
Preserve original contributor commit 3bb28e5f29. Resolve only the settings-dialog conflict by retaining current General/update behavior: the donor General-only language placement is explicitly superseded by the human-approved row BELOW the full-width Deepgram key. The assigned implementation worker will implement that placement and approved shared dropdown finishing. Foundation only, not a checked candidate.
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
import React, { act } from "react";
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "node:test";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { useDictation, type Dictation } from "../components/dictation.tsx";
|
||||
import { notifySettingsSaved } from "../components/settings-signal.ts";
|
||||
import { useDictationLanguage } from "../components/dictation-preference.ts";
|
||||
import { isDictationActive, useDictationActive } from "../components/dictation-state.ts";
|
||||
import type { UserSettings } from "../src/core/index.ts";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
class Socket extends EventTarget {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static all: Socket[] = [];
|
||||
readyState = Socket.CONNECTING;
|
||||
sent: unknown[] = [];
|
||||
constructor(readonly url: string, readonly protocols: string[]) { super(); Socket.all.push(this); }
|
||||
get language() { return new URL(this.url).searchParams.get("language"); }
|
||||
send(data: unknown) { this.sent.push(data); }
|
||||
close() { this.readyState = 3; }
|
||||
open() { this.readyState = Socket.OPEN; this.dispatchEvent(new Event("open")); }
|
||||
end(code = 1000) { this.close(); this.dispatchEvent(Object.assign(new Event("close"), { code })); }
|
||||
final(text: string) {
|
||||
this.dispatchEvent(Object.assign(new Event("message"), {
|
||||
data: JSON.stringify({ type: "Results", is_final: true, channel: { alternatives: [{ transcript: text }] } }),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
class AudioGraph {
|
||||
state = "running";
|
||||
destination = {};
|
||||
audioWorklet = { addModule: async () => {} };
|
||||
createMediaStreamSource() { return { connect() {}, disconnect() {} }; }
|
||||
}
|
||||
class Worklet { port = {}; connect() {} disconnect() {} }
|
||||
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
let restore: (() => void) | undefined;
|
||||
let dispose: (() => Promise<void>) | undefined;
|
||||
|
||||
async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Response>) {
|
||||
const dom = parseHTML("<html><body><div id='root'></div></body></html>");
|
||||
const state = {
|
||||
record: { version: 1, dictation: { language } } as UserSettings,
|
||||
nextRead: delayedRead,
|
||||
nextWrite: undefined as Promise<Response> | undefined,
|
||||
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
|
||||
readFails: false,
|
||||
active: false,
|
||||
captures: 0,
|
||||
stops: 0,
|
||||
appends: [] as string[],
|
||||
};
|
||||
Socket.all = [];
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries({
|
||||
React, window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent,
|
||||
navigator: { userAgent: "", platform: "", mediaDevices: { getUserMedia: async () => {
|
||||
state.captures++;
|
||||
if (state.nextMic) return state.nextMic;
|
||||
return { getTracks: () => [{ stop() { state.stops++; } }] };
|
||||
} } },
|
||||
WebSocket: Socket, AudioContext: AudioGraph, AudioWorkletNode: Worklet,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
fetch: async (url: string, init?: RequestInit) => {
|
||||
if (url === "/api/stt/token") return Response.json({ token: "test-token" });
|
||||
if (url === "/api/settings") {
|
||||
if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; }
|
||||
return state.readFails ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
|
||||
}
|
||||
assert.equal(url, "/api/settings/dictation");
|
||||
assert.equal(init?.method, "PUT");
|
||||
if (state.nextWrite) { const response = state.nextWrite; state.nextWrite = undefined; return response; }
|
||||
state.record = { ...state.record, dictation: JSON.parse(init.body as string) };
|
||||
return Response.json({ settings: state.record });
|
||||
},
|
||||
})) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
restore = () => {
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete globals[key];
|
||||
}
|
||||
};
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
let latest!: Dictation;
|
||||
let setting!: ReturnType<typeof useDictationLanguage>;
|
||||
function Harness() {
|
||||
latest = useDictation({ append: (text) => state.appends.push(text) });
|
||||
setting = useDictationLanguage();
|
||||
state.active = useDictationActive();
|
||||
return null;
|
||||
}
|
||||
let root = createRoot(document.getElementById("root")!);
|
||||
const mount = async () => { await act(async () => { root.render(<Harness />); }); };
|
||||
dispose = async () => { await act(async () => { root.unmount(); }); };
|
||||
return {
|
||||
state,
|
||||
get dictation() { return latest; },
|
||||
get setting() { return setting; },
|
||||
mount,
|
||||
unmount: () => dispose!(),
|
||||
remount: async () => {
|
||||
await dispose!();
|
||||
root = createRoot(document.getElementById("root")!);
|
||||
await mount();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
|
||||
|
||||
test("waits for the saved language; reconnect preserves it and finals still flush exactly once", async () => {
|
||||
const read = deferred<Response>();
|
||||
const f = await fixture("pl", read.promise);
|
||||
await f.mount();
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(f.dictation.phase, "connecting");
|
||||
assert.equal(f.state.active, true, "the settings row locks while connecting");
|
||||
assert.equal(f.state.captures, 0);
|
||||
assert.equal(Socket.all.length, 0, "must not open a default-English socket before settings arrive");
|
||||
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
|
||||
const first = Socket.all[0]!;
|
||||
assert.equal(first.language, "pl");
|
||||
await act(async () => { first.open(); first.final("Cześć"); });
|
||||
assert.deepEqual(f.state.appends, []);
|
||||
// A preference refresh during capture affects the next run only.
|
||||
f.state.record.dictation = { language: "multi" };
|
||||
await act(async () => { notifySettingsSaved("dictation"); });
|
||||
await act(async () => { first.end(1006); });
|
||||
const second = Socket.all[1]!;
|
||||
assert.equal(second.language, "pl");
|
||||
assert.deepEqual(second.protocols, ["bearer", "test-token"]);
|
||||
await act(async () => { second.open(); second.final("świecie"); });
|
||||
await act(async () => { f.dictation.stop(); });
|
||||
assert.equal(f.dictation.phase, "stopping");
|
||||
assert.equal(f.state.active, true, "the settings row remains locked while stopping");
|
||||
assert.equal(second.sent.at(-1), JSON.stringify({ type: "CloseStream" }));
|
||||
await act(async () => { second.end(); });
|
||||
assert.deepEqual(f.state.appends, ["Cześć świecie"]);
|
||||
assert.equal(f.dictation.phase, "idle");
|
||||
assert.equal(f.state.active, false);
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(Socket.all[2]!.language, "multi");
|
||||
});
|
||||
|
||||
test("a failed save keeps the previous language, including a shortcut pressed while saving", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
const write = deferred<Response>();
|
||||
f.state.nextWrite = write.promise;
|
||||
let saved!: Promise<boolean>;
|
||||
await act(async () => { saved = f.setting.save("multi"); });
|
||||
assert.equal(f.setting.saving, true);
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(f.state.captures, 0);
|
||||
await act(async () => { write.resolve(Response.json({}, { status: 500 })); await saved; });
|
||||
assert.equal(await saved, false);
|
||||
assert.equal(f.setting.language, "pl");
|
||||
assert.match(f.setting.error!, /Could not save/);
|
||||
assert.equal(Socket.all[0]!.language, "pl");
|
||||
});
|
||||
|
||||
test("successful choices survive unmount/remount and failed reads never start capture", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
|
||||
await f.remount();
|
||||
assert.equal(f.setting.language, "multi");
|
||||
f.state.readFails = true;
|
||||
await f.remount();
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.match(f.dictation.error!, /Could not load/);
|
||||
assert.equal(f.state.captures, 0);
|
||||
assert.equal(Socket.all.length, 0);
|
||||
f.state.readFails = false;
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(Socket.all[0]!.language, "multi");
|
||||
});
|
||||
|
||||
test("a shortcut waits for a language save made by the separate settings control", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
const write = deferred<Response>();
|
||||
f.state.nextWrite = write.promise;
|
||||
let saved!: Promise<boolean>;
|
||||
await act(async () => { saved = f.setting.save("multi"); });
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(f.state.captures, 0);
|
||||
f.state.record.dictation = { language: "multi" };
|
||||
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await saved; });
|
||||
assert.equal(Socket.all[0]!.language, "multi");
|
||||
});
|
||||
|
||||
test("a saved-language refresh gates capture even when an older choice is cached", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
const read = deferred<Response>();
|
||||
f.state.nextRead = read.promise;
|
||||
f.state.record.dictation = { language: "multi" };
|
||||
await act(async () => { notifySettingsSaved("dictation"); });
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(f.state.captures, 0);
|
||||
assert.equal(Socket.all.length, 0);
|
||||
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
|
||||
assert.equal(Socket.all[0]!.language, "multi");
|
||||
});
|
||||
|
||||
test("a stale settings refresh cannot overwrite a newer confirmed choice", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
const read = deferred<Response>();
|
||||
f.state.nextRead = read.promise;
|
||||
await act(async () => { notifySettingsSaved("dictation"); });
|
||||
await act(async () => { await f.setting.save("multi"); });
|
||||
await act(async () => { read.resolve(Response.json({ settings: { version: 1, dictation: { language: "pl" } } })); });
|
||||
assert.equal(f.setting.language, "multi");
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(Socket.all[0]!.language, "multi");
|
||||
});
|
||||
|
||||
test("cancel while settings load does not open a microphone or socket later", async () => {
|
||||
const read = deferred<Response>();
|
||||
const f = await fixture("pl", read.promise);
|
||||
await f.mount();
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
await act(async () => { f.dictation.cancel(); });
|
||||
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
|
||||
assert.equal(f.dictation.phase, "idle");
|
||||
assert.equal(f.state.captures, 0);
|
||||
assert.equal(Socket.all.length, 0);
|
||||
});
|
||||
|
||||
test("reopening settings during a save shares its busy state and rejects a competing selection", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
const write = deferred<Response>();
|
||||
f.state.nextWrite = write.promise;
|
||||
let first!: Promise<boolean>;
|
||||
await act(async () => { first = f.setting.save("multi"); });
|
||||
await f.remount();
|
||||
assert.equal(f.setting.saving, true);
|
||||
assert.equal(await f.setting.save("en"), false, "a different selection cannot inherit another write's success");
|
||||
f.state.record.dictation = { language: "multi" };
|
||||
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await first; });
|
||||
assert.equal(f.setting.saving, false);
|
||||
assert.equal(f.setting.language, "multi");
|
||||
});
|
||||
|
||||
test("a confirmed save applies to capture even when subsequent settings GET requests would fail", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
f.state.readFails = true;
|
||||
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
|
||||
assert.equal(f.state.record.dictation?.language, "multi");
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(Socket.all[0]!.language, "multi");
|
||||
});
|
||||
|
||||
test("unmount during a pending language read prevents orphaned capture", async () => {
|
||||
const read = deferred<Response>();
|
||||
const f = await fixture("pl", read.promise);
|
||||
await f.mount();
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
await f.unmount();
|
||||
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
|
||||
assert.equal(f.state.captures, 0);
|
||||
assert.equal(Socket.all.length, 0);
|
||||
assert.equal(isDictationActive(), false);
|
||||
});
|
||||
|
||||
test("unmount during recording stops resources and discards the departing page's buffered text", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
const socket = Socket.all[0]!;
|
||||
await act(async () => { socket.open(); socket.final("do not append into another page"); });
|
||||
await f.unmount();
|
||||
assert.equal(f.state.stops, 1);
|
||||
assert.equal(socket.readyState, 3);
|
||||
assert.equal(isDictationActive(), false);
|
||||
assert.deepEqual(f.state.appends, []);
|
||||
await act(async () => { socket.final("late event"); socket.end(1006); });
|
||||
assert.equal(Socket.all.length, 1);
|
||||
assert.deepEqual(f.state.appends, []);
|
||||
});
|
||||
|
||||
test("microphone permission resolving after unmount immediately stops the late stream", async () => {
|
||||
const f = await fixture();
|
||||
await f.mount();
|
||||
const mic = deferred<{ getTracks: () => { stop: () => void }[] }>();
|
||||
f.state.nextMic = mic.promise;
|
||||
await act(async () => { f.dictation.toggle(); });
|
||||
assert.equal(f.state.captures, 1);
|
||||
await f.unmount();
|
||||
await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); });
|
||||
assert.equal(f.state.stops, 1);
|
||||
assert.equal(Socket.all.length, 0);
|
||||
assert.equal(isDictationActive(), false);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { DICTATION_LANGUAGES, deepgramListenUrl, isDictationLanguage } from "../src/core/dictation.ts";
|
||||
import { sanitizeUserSettings } from "../src/core/settings.ts";
|
||||
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||
import { parseDictationSettingsBody } from "../src/server/settings-body.ts";
|
||||
|
||||
describe("Nova-3 dictation language contract", () => {
|
||||
it("offers monolingual languages/locales and a distinct multilingual mode", () => {
|
||||
assert.equal(DICTATION_LANGUAGES[0]?.code, "multi");
|
||||
assert.equal(new Set(DICTATION_LANGUAGES.map((item) => item.code)).size, DICTATION_LANGUAGES.length);
|
||||
for (const code of ["en", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) {
|
||||
assert.ok(isDictationLanguage(code), code);
|
||||
}
|
||||
// Xhosa and Zulu are documented for multi, not monolingual Nova-3.
|
||||
for (const code of ["xh", "zu", "en-CA", "auto", "xx", "pl&model=other", null, 3]) {
|
||||
assert.equal(isDictationLanguage(code), false, String(code));
|
||||
}
|
||||
const labels = DICTATION_LANGUAGES.slice(1).map((item) => item.label);
|
||||
assert.deepEqual(labels, [...labels].sort((a, b) => a.localeCompare(b, "en")));
|
||||
});
|
||||
|
||||
it("only language varies in the streaming URL; all tuned parameters survive", () => {
|
||||
for (const language of ["en", "pl", "multi", "en-GB"] as const) {
|
||||
const url = new URL(deepgramListenUrl(language));
|
||||
assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen");
|
||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||
model: "nova-3", encoding: "linear16", sample_rate: "16000", channels: "1",
|
||||
smart_format: "true", punctuate: "true", endpointing: "300", language,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("old records default to English and explicit preferences resolve without a migration", () => {
|
||||
assert.equal(resolveSettings(null).dictation.language, "en");
|
||||
assert.equal(resolveSettings({ version: 1 }).dictation.language, "en");
|
||||
for (const language of ["pl", "multi", "en-GB"] as const) {
|
||||
const record = sanitizeUserSettings({ version: 1, dictation: { language } });
|
||||
assert.equal(record?.version, 1);
|
||||
assert.equal(resolveSettings(record).dictation.language, language);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid API values and drops invalid stored preferences", () => {
|
||||
assert.deepEqual(parseDictationSettingsBody({}), { ok: true, settings: {} });
|
||||
assert.deepEqual(parseDictationSettingsBody({ language: "multi" }), { ok: true, settings: { language: "multi" } });
|
||||
for (const value of [null, [], "pl", { language: "xx" }, { language: null }, { language: 3 }]) {
|
||||
assert.equal(parseDictationSettingsBody(value).ok, false);
|
||||
}
|
||||
for (const dictation of [null, [], "pl", { language: "xx" }, { language: 3 }]) {
|
||||
const record = sanitizeUserSettings({ version: 1, dictation, general: { autoUpdate: false } });
|
||||
assert.equal(resolveSettings(record).dictation.language, "en");
|
||||
assert.equal(record?.general?.autoUpdate, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const testData = mkdtempSync(path.join(tmpdir(), "lh-dictation-settings-"));
|
||||
process.env.LH_DATA_DIR = testData;
|
||||
after(() => { rmSync(testData, { recursive: true, force: true }); });
|
||||
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
|
||||
|
||||
it("the section route persists language independently and rejects unsupported values without writing", async () => {
|
||||
const { PUT } = await import("../app/api/settings/[section]/route.ts");
|
||||
writeSettings({ version: 1, general: { autoUpdate: false } });
|
||||
const put = (language: string) => PUT(new Request("http://localhost/api/settings/dictation", {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ language }),
|
||||
}), { params: Promise.resolve({ section: "dictation" }) });
|
||||
assert.equal((await put("pl")).status, 200);
|
||||
const saved = { version: 1, dictation: { language: "pl" }, general: { autoUpdate: false } };
|
||||
assert.deepEqual(readSettings(), saved);
|
||||
assert.equal((await put("unsupported")).status, 400);
|
||||
assert.deepEqual(readSettings(), saved);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
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 { DICTATION_LANGUAGES, type UserSettings } from "../src/core/index.ts";
|
||||
|
||||
let dispose: (() => Promise<void>) | undefined;
|
||||
let restore: (() => void) | undefined;
|
||||
const byTest = (id: string) => document.querySelector<HTMLButtonElement>(`[data-testid="${id}"]`)!;
|
||||
const trigger = () => byTest("dictation-language");
|
||||
const input = () => document.querySelector<HTMLInputElement>('input[aria-label="Search dictation languages"]')!;
|
||||
const menu = () => document.querySelector('[data-testid="dictation-language-menu"]');
|
||||
const click = async (element: HTMLElement) => { assert(element); await act(async () => { element.click(); }); };
|
||||
async function key(element: HTMLElement, 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) {
|
||||
await act(async () => {
|
||||
const element = input();
|
||||
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, dictation: { language: "en" } } as UserSettings,
|
||||
writes: [] as string[], failWrite: false, outerEscapes: 0,
|
||||
failRead,
|
||||
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,
|
||||
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
fetch: async (url: string, init?: RequestInit) => {
|
||||
if (url === "/api/settings") return state.failRead ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
|
||||
assert.equal(url, "/api/settings/dictation");
|
||||
const body = JSON.parse(init!.body as string);
|
||||
state.writes.push(body.language);
|
||||
if (state.nextWrite) { const next = state.nextWrite; state.nextWrite = undefined; return next; }
|
||||
if (state.failWrite) return Response.json({}, { status: 500 });
|
||||
state.record = { ...state.record, dictation: body };
|
||||
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];
|
||||
}
|
||||
};
|
||||
// Register the outer dialog listener first: one Escape must close only
|
||||
// the language menu, even when the settings dialog owns another listener.
|
||||
doc.addEventListener("keydown", (event) => { if ((event as KeyboardEvent).key === "Escape") state.outerEscapes++; });
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { DictationLanguageSetting } = await import("../components/dictation-language.tsx");
|
||||
let root = createRoot(doc.getElementById("root")!);
|
||||
const mount = async () => { await act(async () => { root.render(<DictationLanguageSetting />); }); };
|
||||
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
|
||||
await mount();
|
||||
return { state, remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); } };
|
||||
}
|
||||
|
||||
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
|
||||
|
||||
test("an initial load failure is visible and opening the menu again can retry", async () => {
|
||||
const f = await fixture(true);
|
||||
await click(trigger());
|
||||
assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not load/);
|
||||
f.state.failRead = false;
|
||||
await click(trigger());
|
||||
await click(trigger());
|
||||
assert.equal(menu()!.querySelector('[role="alert"]'), null);
|
||||
await search("Polish");
|
||||
await key(input(), "Enter");
|
||||
assert.match(trigger().textContent!, /Polish/);
|
||||
});
|
||||
|
||||
test("the real picker searches by language, chooses with Arrow/Enter, and restores focus", async () => {
|
||||
const f = await fixture();
|
||||
assert.match(trigger().textContent!, /English/);
|
||||
assert.equal(trigger().getAttribute("data-variant"), "outline");
|
||||
await click(trigger());
|
||||
assert.equal(input().getAttribute("data-slot"), "input");
|
||||
assert.equal(input().getAttribute("role"), "combobox");
|
||||
assert.equal(document.activeElement, input());
|
||||
assert.equal(document.querySelector("[cmdk-item]")!.textContent, "Multilingual");
|
||||
await search("Polish");
|
||||
assert.equal(document.querySelectorAll("[cmdk-item]").length, 1);
|
||||
await key(input(), "ArrowDown");
|
||||
await key(input(), "Enter");
|
||||
assert.deepEqual(f.state.writes, ["pl"]);
|
||||
assert.match(trigger().textContent!, /Polish/);
|
||||
assert.equal(menu(), null);
|
||||
assert.equal(document.activeElement, trigger());
|
||||
await f.remount();
|
||||
assert.match(trigger().textContent!, /Polish/);
|
||||
});
|
||||
|
||||
test("empty results cannot save; Escape and outside click close the menu without closing settings", async () => {
|
||||
const f = await fixture();
|
||||
await click(trigger());
|
||||
await search("zzzz-no-language");
|
||||
assert.match(document.querySelector("[cmdk-empty]")!.textContent!, /No languages match/);
|
||||
await key(input(), "Enter");
|
||||
assert.deepEqual(f.state.writes, []);
|
||||
await key(input(), "Escape");
|
||||
assert.equal(menu(), null);
|
||||
assert.equal(f.state.outerEscapes, 0);
|
||||
await click(trigger());
|
||||
await key(trigger(), "Escape");
|
||||
assert.equal(menu(), null);
|
||||
assert.equal(f.state.outerEscapes, 0);
|
||||
await click(trigger());
|
||||
await act(async () => { document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); });
|
||||
assert.equal(menu(), null);
|
||||
});
|
||||
|
||||
test("failed saves retain the confirmed choice and the same menu can retry", async () => {
|
||||
const f = await fixture();
|
||||
f.state.failWrite = true;
|
||||
await click(trigger());
|
||||
await search("Polish");
|
||||
await key(input(), "Enter");
|
||||
assert.match(trigger().textContent!, /English/);
|
||||
assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not save/);
|
||||
f.state.failWrite = false;
|
||||
await click(byTest("dictation-language-pl"));
|
||||
assert.equal(menu(), null);
|
||||
assert.match(trigger().textContent!, /Polish/);
|
||||
assert.deepEqual(f.state.writes, ["pl", "pl"]);
|
||||
});
|
||||
|
||||
test("a remounted picker stays disabled throughout an outstanding save", async () => {
|
||||
const f = await fixture();
|
||||
let complete!: (response: Response) => void;
|
||||
f.state.nextWrite = new Promise((resolve) => { complete = resolve; });
|
||||
await click(trigger());
|
||||
await click(byTest("dictation-language-multi"));
|
||||
assert.equal(trigger().disabled, true);
|
||||
await f.remount();
|
||||
assert.equal(trigger().disabled, true);
|
||||
await click(trigger());
|
||||
assert.equal(menu(), null);
|
||||
f.state.record.dictation = { language: "multi" };
|
||||
await act(async () => { complete(Response.json({ settings: f.state.record })); });
|
||||
assert.equal(trigger().disabled, false);
|
||||
assert.match(trigger().textContent!, /Multilingual/);
|
||||
assert.deepEqual(f.state.writes, ["multi"]);
|
||||
});
|
||||
|
||||
test("capture locks the row and dismisses an open language menu until capture ends", async () => {
|
||||
await fixture();
|
||||
await click(trigger());
|
||||
await act(async () => { setDictationActive(true); });
|
||||
assert.equal(trigger().disabled, true);
|
||||
assert.match(byTest("row-dictation-language").className, /opacity-40/);
|
||||
assert.equal(menu(), null);
|
||||
await act(async () => { setDictationActive(false); });
|
||||
assert.equal(trigger().disabled, false);
|
||||
await click(trigger());
|
||||
assert(menu());
|
||||
});
|
||||
|
||||
test("clearing each search restores the complete catalog order with Multilingual first", async () => {
|
||||
await fixture();
|
||||
await click(trigger());
|
||||
const order = () => [...document.querySelectorAll("[cmdk-item]")].map((item) => item.getAttribute("data-value"));
|
||||
const expected = DICTATION_LANGUAGES.map((item) => item.code);
|
||||
assert.deepEqual(order(), expected);
|
||||
for (const query of ["en", "multi", "Polish", "zzzzz"]) {
|
||||
await search(query);
|
||||
await search("");
|
||||
assert.deepEqual(order(), expected, `catalog order after clearing ${query}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createDictationLanguageStore } from "../components/dictation-preference.ts";
|
||||
|
||||
test("all readers immediately receive the confirmed write without another GET", async () => {
|
||||
let reads = 0;
|
||||
let notifications = 0;
|
||||
let failReads = false;
|
||||
const store = createDictationLanguageStore(async (_url, init) => {
|
||||
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: JSON.parse(init.body as string) } });
|
||||
reads++;
|
||||
return failReads ? Response.json({}, { status: 500 }) : Response.json({ settings: null });
|
||||
}, () => { notifications++; });
|
||||
assert.equal(await store.load(), "en");
|
||||
const observed: string[] = [];
|
||||
const off = store.subscribe(() => { observed.push(`${store.getSnapshot().language}:${store.getSnapshot().saving}`); });
|
||||
failReads = true;
|
||||
assert.equal(await store.save("multi"), true);
|
||||
assert.equal(await store.getLanguage(), "multi");
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(notifications, 1);
|
||||
assert(observed.includes("multi:true"));
|
||||
assert.equal(observed.at(-1), "multi:false");
|
||||
off();
|
||||
});
|
||||
|
||||
test("a failed external refresh invalidates the cache until a successful retry", async () => {
|
||||
let fail = false;
|
||||
const store = createDictationLanguageStore(async () => fail
|
||||
? Response.json({}, { status: 500 })
|
||||
: Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
|
||||
await store.load();
|
||||
fail = true;
|
||||
await assert.rejects(store.load(true), /Could not load/);
|
||||
await assert.rejects(store.getLanguage(), /Could not load/);
|
||||
fail = false;
|
||||
assert.equal(await store.getLanguage(), "pl");
|
||||
assert.equal(store.getSnapshot().error, null);
|
||||
});
|
||||
|
||||
test("stale reads cannot overwrite a newer write and subscribers see busy before transport runs", async () => {
|
||||
let stale!: (response: Response) => void;
|
||||
let nextRead: Promise<Response> | undefined;
|
||||
const store = createDictationLanguageStore(async (_url, init) => {
|
||||
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
|
||||
if (nextRead) { const response = nextRead; nextRead = undefined; return response; }
|
||||
return Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
|
||||
});
|
||||
await store.load();
|
||||
nextRead = new Promise((resolve) => { stale = resolve; });
|
||||
const oldRead = store.load(true);
|
||||
await Promise.resolve();
|
||||
let startedDuringPublish: Promise<string> | undefined;
|
||||
const off = store.subscribe(() => {
|
||||
if (store.getSnapshot().saving && !startedDuringPublish) startedDuringPublish = store.getLanguage();
|
||||
});
|
||||
assert.equal(await store.save("multi"), true);
|
||||
stale(Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
|
||||
assert.equal(await oldRead, "multi");
|
||||
assert.equal(await startedDuringPublish, "multi");
|
||||
assert.equal(store.getSnapshot().language, "multi");
|
||||
off();
|
||||
});
|
||||
|
||||
for (const newestFirst of [false, true]) test(`overlapping reads keep the newest value, newest resolves first: ${newestFirst}`, async () => {
|
||||
const responses: ((response: Response) => void)[] = [];
|
||||
const store = createDictationLanguageStore(() => new Promise((resolve) => { responses.push(resolve); }));
|
||||
const first = store.load();
|
||||
await Promise.resolve();
|
||||
const second = store.load(true);
|
||||
await Promise.resolve();
|
||||
const old = Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
|
||||
const fresh = Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
|
||||
if (newestFirst) { responses[1]!(fresh); responses[0]!(old); }
|
||||
else { responses[0]!(old); responses[1]!(fresh); }
|
||||
assert.deepEqual(await Promise.all([first, second]), ["multi", "multi"]);
|
||||
assert.equal(await store.getLanguage(), "multi");
|
||||
});
|
||||
|
||||
test("capture also waits for a write initiated as a pending read publishes its result", async () => {
|
||||
const store = createDictationLanguageStore(async (_url, init) => Response.json({ settings: {
|
||||
version: 1, dictation: { language: init?.method === "PUT" ? "multi" : "en" },
|
||||
} }));
|
||||
let started = false;
|
||||
const off = store.subscribe(() => {
|
||||
if (!started && store.getSnapshot().language === "en") {
|
||||
started = true;
|
||||
void store.save("multi");
|
||||
}
|
||||
});
|
||||
assert.equal(await store.getLanguage(), "multi");
|
||||
off();
|
||||
});
|
||||
@@ -234,12 +234,12 @@ describe("parseCompactionSettingsBody (owner round 14)", () => {
|
||||
});
|
||||
|
||||
describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
||||
it("maps exactly the seven record sections", () => {
|
||||
it("maps exactly the eight record sections", () => {
|
||||
// compaction joined (owner round 14): its own section PUT — the
|
||||
// threshold is engine behavior, not an agent-section key.
|
||||
assert.deepEqual(
|
||||
Object.keys(SETTINGS_SECTIONS),
|
||||
["general", "chat", "agent", "appearance", "graph", "compaction", "models"],
|
||||
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user