feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -0,0 +1,753 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../..");
|
||||
const SPEECH_SOURCE = fs.readFileSync(
|
||||
path.join(ROOT, "apps/web-console/speech-input.js"),
|
||||
"utf8",
|
||||
);
|
||||
const ASSISTANT_SOURCE = fs.readFileSync(
|
||||
path.join(ROOT, "apps/web-console/assistant-chat.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
class MockClassList {
|
||||
constructor() {
|
||||
this.values = new Set();
|
||||
}
|
||||
|
||||
toggle(name, force) {
|
||||
if (force === undefined) force = !this.values.has(name);
|
||||
if (force) this.values.add(name);
|
||||
else this.values.delete(name);
|
||||
return force;
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.values.has(name);
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
names.forEach((name) => this.values.add(name));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
names.forEach((name) => this.values.delete(name));
|
||||
}
|
||||
}
|
||||
|
||||
class MockEventTarget {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
const listeners = this.listeners.get(type) || [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener) {
|
||||
const listeners = this.listeners.get(type) || [];
|
||||
this.listeners.set(type, listeners.filter((candidate) => candidate !== listener));
|
||||
}
|
||||
|
||||
dispatchEvent(event) {
|
||||
if (!event || !event.type) throw new TypeError("event.type is required");
|
||||
if (!event.target) event.target = this;
|
||||
for (const listener of this.listeners.get(event.type) || []) listener.call(this, event);
|
||||
return event.defaultPrevented !== true;
|
||||
}
|
||||
}
|
||||
|
||||
class MockElement extends MockEventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.attributes = new Map();
|
||||
this.classList = new MockClassList();
|
||||
this.dataset = {};
|
||||
this.disabled = false;
|
||||
this.inert = false;
|
||||
this.maxLength = 4000;
|
||||
this.style = {};
|
||||
this.textContent = "";
|
||||
this.title = "";
|
||||
this.value = "";
|
||||
}
|
||||
|
||||
append() {}
|
||||
click() { this.dispatchEvent({ type: "click", target: this }); }
|
||||
focus() {}
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
toggleAttribute(name, force) { this[name] = Boolean(force); }
|
||||
}
|
||||
|
||||
class FakeAudioNode {
|
||||
constructor() {
|
||||
this.disconnectCount = 0;
|
||||
this.connections = [];
|
||||
}
|
||||
|
||||
connect(node) {
|
||||
this.connections.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.disconnectCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function createStream({ label = "测试麦克风", deviceId = "default-mic", trackCount = 1 } = {}) {
|
||||
const tracks = Array.from({ length: trackCount }, () => ({
|
||||
label,
|
||||
stopCount: 0,
|
||||
getSettings: () => ({ deviceId }),
|
||||
stop() { this.stopCount += 1; },
|
||||
}));
|
||||
return {
|
||||
tracks,
|
||||
getAudioTracks: () => tracks,
|
||||
getTracks: () => tracks,
|
||||
};
|
||||
}
|
||||
|
||||
function response(payload, { ok = true, status = ok ? 200 : 500 } = {}) {
|
||||
return { ok, status, json: async () => payload };
|
||||
}
|
||||
|
||||
function createHarness({
|
||||
fetchImpl,
|
||||
getUserMedia,
|
||||
permissionState = "prompt",
|
||||
now = 1000,
|
||||
} = {}) {
|
||||
const composer = new MockElement();
|
||||
const button = new MockElement();
|
||||
const input = new MockElement();
|
||||
const status = new MockElement();
|
||||
const hiddenLabel = new MockElement();
|
||||
const drawer = new MockElement();
|
||||
const drawerToggle = new MockElement();
|
||||
button.closest = () => composer;
|
||||
|
||||
const selectors = new Map([
|
||||
["#speechDictationButton", button],
|
||||
["#speechDictationButton .speech-visually-hidden", hiddenLabel],
|
||||
["#utterance", input],
|
||||
["#speechDictationStatus", status],
|
||||
["#assistantDrawer", drawer],
|
||||
["#assistantDrawerToggle", drawerToggle],
|
||||
]);
|
||||
const document = new MockEventTarget();
|
||||
document.body = new MockElement();
|
||||
document.hidden = false;
|
||||
document.querySelector = (selector) => selectors.get(selector) || null;
|
||||
const windowEvents = new MockEventTarget();
|
||||
const defaultStream = createStream();
|
||||
const mediaRequests = [];
|
||||
const audioNodes = [];
|
||||
class FakeAudioContext {
|
||||
constructor() {
|
||||
this.destination = new FakeAudioNode();
|
||||
this.sampleRate = 16000;
|
||||
this.state = "running";
|
||||
audioNodes.push(this.destination);
|
||||
}
|
||||
|
||||
async resume() { this.state = "running"; }
|
||||
async close() { this.state = "closed"; }
|
||||
createMediaStreamSource() {
|
||||
const node = new FakeAudioNode();
|
||||
audioNodes.push(node);
|
||||
return node;
|
||||
}
|
||||
createGain() {
|
||||
const node = new FakeAudioNode();
|
||||
node.gain = { value: 1 };
|
||||
audioNodes.push(node);
|
||||
return node;
|
||||
}
|
||||
createScriptProcessor() {
|
||||
const node = new FakeAudioNode();
|
||||
node.onaudioprocess = null;
|
||||
audioNodes.push(node);
|
||||
return node;
|
||||
}
|
||||
}
|
||||
const permissionStatus = new MockEventTarget();
|
||||
permissionStatus.state = permissionState;
|
||||
const fetchCalls = [];
|
||||
const harnessFetch = async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (fetchImpl) return fetchImpl(url, options, fetchCalls.length - 1);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
};
|
||||
const harnessGetUserMedia = async (constraints) => {
|
||||
mediaRequests.push(constraints);
|
||||
return getUserMedia ? getUserMedia(constraints, mediaRequests.length - 1) : defaultStream;
|
||||
};
|
||||
const schedule = (callback, delay, ...args) => {
|
||||
const timer = setTimeout(callback, delay, ...args);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
};
|
||||
let opened = 0;
|
||||
const sandbox = {
|
||||
AbortController,
|
||||
AudioContext: FakeAudioContext,
|
||||
ArrayBuffer,
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, options = {}) { this.type = type; this.detail = options.detail; }
|
||||
},
|
||||
DOMException,
|
||||
DataView,
|
||||
Element: MockElement,
|
||||
Event: class Event {
|
||||
constructor(type, options = {}) {
|
||||
this.type = type;
|
||||
this.bubbles = options.bubbles === true;
|
||||
this.isTrusted = false;
|
||||
}
|
||||
},
|
||||
Float32Array,
|
||||
HTMLFormElement: class HTMLFormElement extends MockElement {},
|
||||
Math,
|
||||
Promise,
|
||||
Set,
|
||||
String,
|
||||
TextDecoder,
|
||||
Uint8Array,
|
||||
URLSearchParams,
|
||||
clearTimeout,
|
||||
console,
|
||||
crypto: { randomUUID: () => "runtime-test-id" },
|
||||
document,
|
||||
fetch: harnessFetch,
|
||||
localStorage: { getItem: () => null, removeItem() {}, setItem() {} },
|
||||
location: { search: "" },
|
||||
isSecureContext: true,
|
||||
navigator: {
|
||||
mediaDevices: {
|
||||
enumerateDevices: async () => [],
|
||||
getUserMedia: harnessGetUserMedia,
|
||||
},
|
||||
permissions: { query: async () => permissionStatus },
|
||||
},
|
||||
performance: { now: () => (typeof now === "function" ? now() : now) },
|
||||
requestAnimationFrame: (callback) => callback(),
|
||||
sessionStorage: { getItem: () => null, removeItem() {}, setItem() {} },
|
||||
setTimeout: schedule,
|
||||
__N7WEB_SPEECH_TEST__: true,
|
||||
__N7WEB_ASSISTANT_TEST__: true,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.CognitiveAgentAssistant = { open: () => { opened += 1; } };
|
||||
sandbox.addEventListener = windowEvents.addEventListener.bind(windowEvents);
|
||||
sandbox.removeEventListener = windowEvents.removeEventListener.bind(windowEvents);
|
||||
sandbox.dispatchEvent = windowEvents.dispatchEvent.bind(windowEvents);
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(SPEECH_SOURCE, sandbox, { filename: "speech-input.js" });
|
||||
return {
|
||||
sandbox,
|
||||
audioNodes,
|
||||
defaultStream,
|
||||
document,
|
||||
elements: { button, composer, drawer, drawerToggle, input, status },
|
||||
fetchCalls,
|
||||
mediaRequests,
|
||||
opened: () => opened,
|
||||
permissionStatus,
|
||||
windowEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function tick() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async function settle(rounds = 8) {
|
||||
for (let index = 0; index < rounds; index += 1) await tick();
|
||||
}
|
||||
|
||||
test("wake preview uses the structured remainder and ignores duplicate, stale, and out-of-order revisions", () => {
|
||||
const harness = createHarness();
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
Object.assign(api.state, {
|
||||
generation: 9,
|
||||
utteranceId: "utterance-9",
|
||||
phase: "armed",
|
||||
wakeWord: "你好,双十一",
|
||||
lastAppliedPreviewSequence: 0,
|
||||
});
|
||||
const context = { generation: 9, utteranceId: "utterance-9", requestedSequence: 2 };
|
||||
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 2,
|
||||
stale: false,
|
||||
raw_text: "你好双十一打开空调",
|
||||
live_text: "ignored fallback",
|
||||
wake_word: {
|
||||
configured_word: "你好,双十一",
|
||||
detected: true,
|
||||
remainder: "打开空调",
|
||||
},
|
||||
}, context), true);
|
||||
assert.equal(harness.elements.input.value, "打开空调");
|
||||
assert.equal(harness.elements.input.dataset.speechStage, "partial");
|
||||
assert.equal(api.state.phase, "capturing");
|
||||
assert.equal(api.state.wakeWord, "你好,双十一");
|
||||
assert.equal(harness.opened(), 1);
|
||||
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 2,
|
||||
wake_word: { detected: true, remainder: "重复错误文本" },
|
||||
}, context), false);
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 1,
|
||||
wake_word: { detected: true, remainder: "乱序错误文本" },
|
||||
}, { ...context, requestedSequence: 1 }), false);
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 3,
|
||||
stale: true,
|
||||
wake_word: { detected: true, remainder: "过期错误文本" },
|
||||
}, { ...context, requestedSequence: 3 }), false);
|
||||
assert.equal(harness.elements.input.value, "打开空调");
|
||||
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 4,
|
||||
wake_word: { detected: true, remainder: "打开空调到24度" },
|
||||
}, { ...context, requestedSequence: 4 }), true);
|
||||
assert.equal(harness.elements.input.value, "打开空调到24度");
|
||||
});
|
||||
|
||||
test("manual preview streams ordinary speech without requiring or displaying a wake word", () => {
|
||||
const harness = createHarness();
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
Object.assign(api.state, {
|
||||
generation: 10,
|
||||
utteranceId: "manual-preview-10",
|
||||
inputMode: "manual",
|
||||
phase: "capturing",
|
||||
wakeDetected: true,
|
||||
lastAppliedPreviewSequence: 0,
|
||||
});
|
||||
const context = {
|
||||
generation: 10,
|
||||
utteranceId: "manual-preview-10",
|
||||
requestedSequence: 1,
|
||||
};
|
||||
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 1,
|
||||
raw_text: "车上有点热",
|
||||
live_text: "车上有点热",
|
||||
wake_word: { detected: false, remainder: "" },
|
||||
}, context), true);
|
||||
assert.equal(harness.elements.input.value, "车上有点热");
|
||||
assert.equal(api.state.phase, "capturing");
|
||||
assert.equal(harness.opened(), 0);
|
||||
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 1,
|
||||
live_text: "重复结果不应覆盖",
|
||||
wake_word: { detected: false },
|
||||
}, context), false);
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 0,
|
||||
live_text: "乱序结果不应覆盖",
|
||||
wake_word: { detected: false },
|
||||
}, { ...context, requestedSequence: 0 }), false);
|
||||
assert.equal(harness.elements.input.value, "车上有点热");
|
||||
});
|
||||
|
||||
test("clicking the microphone starts manual capture immediately without a wake word", async () => {
|
||||
const harness = createHarness({
|
||||
fetchImpl: async (url) => {
|
||||
assert.equal(url, "/api/speech/status");
|
||||
return response({
|
||||
enabled: true,
|
||||
loaded: true,
|
||||
load_state: "ready",
|
||||
live_preview: { recommended_interval_ms: 850 },
|
||||
wake_word: { primary: "你好,双十一" },
|
||||
});
|
||||
},
|
||||
});
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
|
||||
api.initialize();
|
||||
await settle();
|
||||
harness.elements.button.click();
|
||||
await settle();
|
||||
|
||||
assert.equal(api.state.inputMode, "manual");
|
||||
assert.equal(api.state.phase, "capturing");
|
||||
assert.equal(api.state.wakeDetected, true);
|
||||
assert.equal(harness.mediaRequests.length, 1);
|
||||
assert.equal(harness.elements.input.dataset.speechStage, "partial");
|
||||
assert.match(harness.elements.status.textContent, /实时转录/);
|
||||
|
||||
await api.cancelDictation("test cleanup", { silent: true });
|
||||
});
|
||||
|
||||
test("background wake standby stays gated until wake detection and then shows only the remainder", async () => {
|
||||
const harness = createHarness({ permissionState: "granted" });
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
Object.assign(api.state, { backendReady: true, assistantOpen: true });
|
||||
|
||||
assert.equal(await api.startWakeStandby({ automatic: true }), true);
|
||||
assert.equal(api.state.inputMode, "wake");
|
||||
assert.equal(api.state.phase, "armed");
|
||||
|
||||
const context = {
|
||||
generation: api.state.generation,
|
||||
utteranceId: api.state.utteranceId,
|
||||
requestedSequence: 1,
|
||||
};
|
||||
harness.elements.input.value = "原有输入";
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 1,
|
||||
raw_text: "打开空调",
|
||||
live_text: "打开空调",
|
||||
wake_word: { detected: false, remainder: "" },
|
||||
}, context), true);
|
||||
assert.equal(api.state.phase, "armed");
|
||||
assert.equal(harness.elements.input.value, "原有输入");
|
||||
|
||||
assert.equal(api.applyPreview({
|
||||
sequence: 2,
|
||||
raw_text: "你好双十一打开空调到24度",
|
||||
live_text: "打开空调到24度",
|
||||
wake_word: {
|
||||
detected: true,
|
||||
configured_word: "你好,双十一",
|
||||
remainder: "打开空调到24度",
|
||||
},
|
||||
}, { ...context, requestedSequence: 2 }), true);
|
||||
assert.equal(api.state.phase, "capturing");
|
||||
assert.equal(harness.elements.input.value, "打开空调到24度");
|
||||
assert.equal(harness.elements.input.value.includes("你好"), false);
|
||||
|
||||
await api.cancelDictation("test cleanup", { silent: true });
|
||||
});
|
||||
|
||||
test("automatic standby never prompts for microphone permission", async () => {
|
||||
const harness = createHarness({ permissionState: "prompt" });
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
Object.assign(api.state, { backendReady: true, assistantOpen: true });
|
||||
|
||||
assert.equal(await api.startWakeStandby({ automatic: true }), false);
|
||||
assert.equal(api.state.phase, "idle");
|
||||
assert.equal(harness.mediaRequests.length, 0);
|
||||
});
|
||||
|
||||
test("manual capture promotes an active wake stream without reacquiring the microphone", async () => {
|
||||
const harness = createHarness({ permissionState: "granted" });
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
Object.assign(api.state, { backendReady: true, assistantOpen: true });
|
||||
await api.startWakeStandby({ automatic: true });
|
||||
const standbyStream = api.state.stream;
|
||||
|
||||
assert.equal(await api.startManualDictation(), true);
|
||||
assert.equal(api.state.inputMode, "manual");
|
||||
assert.equal(api.state.phase, "capturing");
|
||||
assert.equal(api.state.wakeDetected, true);
|
||||
assert.equal(api.state.stream, standbyStream);
|
||||
assert.equal(harness.mediaRequests.length, 1);
|
||||
|
||||
await api.cancelDictation("test cleanup", { silent: true });
|
||||
});
|
||||
|
||||
test("manual and wake final requests carry explicit mode and wake contracts", async () => {
|
||||
const harness = createHarness({
|
||||
fetchImpl: async (url) => {
|
||||
assert.equal(url, "/api/speech/transcribe");
|
||||
return response({ assistant_text: "测试结果" });
|
||||
},
|
||||
});
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
const wav = new harness.sandbox.ArrayBuffer(48);
|
||||
|
||||
await api.transcribe(wav, "manual-final", "manual");
|
||||
await api.transcribe(wav, "wake-final", "wake");
|
||||
|
||||
assert.deepEqual(
|
||||
harness.fetchCalls.map(({ options }) => ({
|
||||
mode: options.headers["X-Speech-Input-Mode"],
|
||||
wakeRequired: options.headers["X-Speech-Wake-Word-Required"],
|
||||
utteranceId: options.headers["X-Speech-Utterance-Id"],
|
||||
})),
|
||||
[
|
||||
{ mode: "manual", wakeRequired: "0", utteranceId: "manual-final" },
|
||||
{ mode: "wake", wakeRequired: "1", utteranceId: "wake-final" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("manual endpoint silence finalizes recognized speech exactly once", async () => {
|
||||
let previewSequence = 0;
|
||||
const harness = createHarness({
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url === "/api/speech/preview") {
|
||||
previewSequence = Number(options.headers["X-Speech-Preview-Sequence"]);
|
||||
return response({
|
||||
sequence: previewSequence,
|
||||
raw_text: "车上有点热",
|
||||
live_text: "车上有点热",
|
||||
wake_word: { detected: false, remainder: "" },
|
||||
stale: false,
|
||||
});
|
||||
}
|
||||
assert.equal(url, "/api/speech/transcribe");
|
||||
return response({ raw_text: "车上有点热", assistant_text: "车上有点热" });
|
||||
},
|
||||
});
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
Object.assign(api.state, {
|
||||
backendReady: true,
|
||||
generation: 31,
|
||||
utteranceId: "manual-silence-31",
|
||||
inputMode: "manual",
|
||||
phase: "capturing",
|
||||
wakeDetected: true,
|
||||
sampleRate: 16000,
|
||||
startedAt: 0,
|
||||
stream: harness.defaultStream,
|
||||
chunks: [],
|
||||
totalSamples: 0,
|
||||
speechStartSample: null,
|
||||
lastVoiceSample: null,
|
||||
consecutiveVoiceSamples: 0,
|
||||
lastPreviewAt: 0,
|
||||
});
|
||||
|
||||
api.handleAudio(new Float32Array(6400).fill(0.12), 31);
|
||||
api.handleAudio(new Float32Array(18000), 31);
|
||||
await settle(20);
|
||||
|
||||
const finalCalls = harness.fetchCalls.filter(({ url }) => url === "/api/speech/transcribe");
|
||||
assert.equal(finalCalls.length, 1);
|
||||
assert.equal(finalCalls[0].options.headers["X-Speech-Input-Mode"], "manual");
|
||||
assert.equal(finalCalls[0].options.headers["X-Speech-Wake-Word-Required"], "0");
|
||||
assert.equal(harness.elements.input.value, "车上有点热");
|
||||
assert.equal(api.state.phase, "success");
|
||||
assert.equal(harness.defaultStream.tracks[0].stopCount, 1);
|
||||
});
|
||||
|
||||
test("wake standby discards a spoken segment without the wake word and keeps listening", async () => {
|
||||
const harness = createHarness({
|
||||
fetchImpl: async (url, options) => {
|
||||
assert.equal(url, "/api/speech/preview");
|
||||
return response({
|
||||
sequence: Number(options.headers["X-Speech-Preview-Sequence"]),
|
||||
raw_text: "打开空调",
|
||||
live_text: "打开空调",
|
||||
wake_word: { detected: false, remainder: "" },
|
||||
stale: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
Object.assign(api.state, {
|
||||
generation: 41,
|
||||
utteranceId: "wake-silence-41",
|
||||
inputMode: "wake",
|
||||
phase: "armed",
|
||||
wakeDetected: false,
|
||||
sampleRate: 16000,
|
||||
startedAt: 0,
|
||||
chunks: [],
|
||||
totalSamples: 0,
|
||||
speechStartSample: null,
|
||||
lastVoiceSample: null,
|
||||
consecutiveVoiceSamples: 0,
|
||||
lastPreviewAt: 0,
|
||||
});
|
||||
harness.elements.input.value = "不要覆盖";
|
||||
|
||||
api.handleAudio(new Float32Array(6400).fill(0.12), 41);
|
||||
api.handleAudio(new Float32Array(18000), 41);
|
||||
await settle(20);
|
||||
|
||||
assert.equal(api.state.phase, "armed");
|
||||
assert.equal(api.state.wakeDetected, false);
|
||||
assert.equal(harness.elements.input.value, "不要覆盖");
|
||||
assert.equal(
|
||||
harness.fetchCalls.some(({ url }) => url === "/api/speech/transcribe"),
|
||||
false,
|
||||
);
|
||||
assert.ok(harness.fetchCalls.length >= 1);
|
||||
});
|
||||
|
||||
test("manual silence without any detected voice exits capture and releases the microphone", async () => {
|
||||
let clock = 9000;
|
||||
const harness = createHarness({ now: () => clock });
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
Object.assign(api.state, {
|
||||
generation: 51,
|
||||
utteranceId: "manual-no-voice-51",
|
||||
inputMode: "manual",
|
||||
phase: "capturing",
|
||||
wakeDetected: true,
|
||||
sampleRate: 16000,
|
||||
startedAt: 0,
|
||||
stream: harness.defaultStream,
|
||||
chunks: [],
|
||||
totalSamples: 0,
|
||||
speechStartSample: null,
|
||||
lastVoiceSample: null,
|
||||
consecutiveVoiceSamples: 0,
|
||||
});
|
||||
|
||||
api.handleAudio(new Float32Array(2048), 51);
|
||||
await settle();
|
||||
|
||||
assert.equal(api.state.phase, "error");
|
||||
assert.match(harness.elements.status.textContent, /未检测到说话声/);
|
||||
assert.equal(api.state.stream, null);
|
||||
assert.equal(harness.defaultStream.tracks[0].stopCount, 1);
|
||||
clock += 1;
|
||||
});
|
||||
|
||||
test("permission denial leaves no capture resources and gives an actionable error", async () => {
|
||||
const harness = createHarness({
|
||||
getUserMedia: async () => {
|
||||
throw new DOMException("denied", "NotAllowedError");
|
||||
},
|
||||
});
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
api.state.backendReady = true;
|
||||
|
||||
assert.equal(await api.startManualDictation(), false);
|
||||
assert.equal(api.state.phase, "error");
|
||||
assert.equal(api.state.stream, null);
|
||||
assert.equal(harness.mediaRequests.length, 1);
|
||||
assert.match(harness.elements.status.textContent, /麦克风权限/);
|
||||
});
|
||||
|
||||
test("ordinary questions use assistant_text even without executable commands", () => {
|
||||
const harness = createHarness();
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
api.state.utteranceId = "question-1";
|
||||
harness.elements.input.dataset.speechExecutionEligible = "false";
|
||||
|
||||
assert.equal(api.applyResult({
|
||||
assistant_text: "我们中午吃什么?",
|
||||
clean_text: "",
|
||||
raw_text: "我们中午吃什么?",
|
||||
commands: [],
|
||||
execution_eligible: false,
|
||||
}), true);
|
||||
assert.equal(harness.elements.input.value, "我们中午吃什么?");
|
||||
assert.equal(harness.elements.input.dataset.speechStage, "final");
|
||||
assert.equal("speechExecutionEligible" in harness.elements.input.dataset, false);
|
||||
});
|
||||
|
||||
test("status reads the primary wake word instead of stringifying its object", () => {
|
||||
const harness = createHarness();
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
assert.equal(
|
||||
api.configuredWakeWord({ wake_word: { primary: "你好,双十一" } }),
|
||||
"你好,双十一",
|
||||
);
|
||||
assert.equal(api.configuredWakeWord({}), "你好,双十一");
|
||||
assert.deepEqual(
|
||||
{ ...api.speechStatusReadiness({ enabled: true, loaded: false, load_state: "loading" }) },
|
||||
{ loadState: "loading", ready: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ ...api.speechStatusReadiness({ enabled: true, loaded: true, load_state: "ready" }) },
|
||||
{ loadState: "ready", ready: true },
|
||||
);
|
||||
assert.equal(
|
||||
api.speechStatusReadiness({ enabled: true, load_state: "error" }).ready,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("collapsing the assistant disables standby and releases active capture", async () => {
|
||||
const harness = createHarness();
|
||||
const speechApi = harness.sandbox.CognitiveAgentSpeech;
|
||||
const state = speechApi.__test.state;
|
||||
const assistantOpenValues = [];
|
||||
const originalSetAssistantOpen = speechApi.setAssistantOpen;
|
||||
speechApi.setAssistantOpen = (open) => {
|
||||
assistantOpenValues.push(open);
|
||||
return originalSetAssistantOpen(open);
|
||||
};
|
||||
Object.assign(state, {
|
||||
assistantOpen: true,
|
||||
phase: "armed",
|
||||
inputMode: "wake",
|
||||
stream: harness.defaultStream,
|
||||
});
|
||||
vm.runInContext(ASSISTANT_SOURCE, harness.sandbox, { filename: "assistant-chat.js" });
|
||||
|
||||
harness.sandbox.CognitiveAgentAssistant.__test.setDrawerOpen(false);
|
||||
await settle();
|
||||
|
||||
assert.deepEqual(assistantOpenValues, [false]);
|
||||
assert.equal(state.assistantOpen, false);
|
||||
assert.equal(state.phase, "idle");
|
||||
assert.equal(state.stream, null);
|
||||
assert.equal(harness.defaultStream.tracks[0].stopCount, 1);
|
||||
assert.equal(harness.elements.drawer.inert, true);
|
||||
assert.equal(
|
||||
harness.elements.drawerToggle.attributes.get("aria-expanded"),
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
test("cancellation aborts pending requests, disconnects nodes, and stops every track", async () => {
|
||||
const harness = createHarness();
|
||||
const api = harness.sandbox.CognitiveAgentSpeech.__test;
|
||||
const stream = createStream({ trackCount: 2 });
|
||||
const source = new FakeAudioNode();
|
||||
const capture = new FakeAudioNode();
|
||||
capture.onaudioprocess = () => {};
|
||||
const gain = new FakeAudioNode();
|
||||
const abortReasons = [];
|
||||
Object.assign(api.state, {
|
||||
phase: "armed",
|
||||
stream,
|
||||
source,
|
||||
captureNode: capture,
|
||||
silentGain: gain,
|
||||
previewController: { abort: (reason) => abortReasons.push(["preview", reason]) },
|
||||
finalController: { abort: (reason) => abortReasons.push(["final", reason]) },
|
||||
});
|
||||
|
||||
await api.cancelDictation("cleanup", { silent: true });
|
||||
|
||||
assert.deepEqual(abortReasons, [
|
||||
["preview", "speech_cancelled"],
|
||||
["final", "speech_cancelled"],
|
||||
]);
|
||||
assert.deepEqual(stream.tracks.map((track) => track.stopCount), [1, 1]);
|
||||
assert.equal(source.disconnectCount, 1);
|
||||
assert.equal(capture.disconnectCount, 1);
|
||||
assert.equal(gain.disconnectCount, 1);
|
||||
assert.equal(capture.onaudioprocess, null);
|
||||
assert.equal(api.state.phase, "idle");
|
||||
});
|
||||
|
||||
test("speech stop releases every microphone track", async () => {
|
||||
const harness = createHarness();
|
||||
const state = harness.sandbox.CognitiveAgentSpeech.__test.state;
|
||||
let stopped = 0;
|
||||
state.phase = "armed";
|
||||
state.stream = {
|
||||
getTracks: () => [{ stop: () => { stopped += 1; } }],
|
||||
};
|
||||
|
||||
await harness.sandbox.CognitiveAgentSpeech.stop();
|
||||
|
||||
assert.equal(stopped, 1);
|
||||
assert.equal(state.stream, null);
|
||||
assert.equal(state.phase, "idle");
|
||||
});
|
||||
Reference in New Issue
Block a user