// 使用 Node 内置测试器和最小 DOM 验证真实页面脚本,无需安装浏览器或 GPU。 const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); class Element { constructor() { this.childNodes = []; this.className = ''; this.style = {}; this.textContent = ''; this.classList = { add() {}, remove() {}, toggle() {} }; } append(...nodes) { nodes.forEach(node => this.appendChild(node)); } appendChild(node) { node.parent = this; this.childNodes.push(node); } replaceChildren() { this.childNodes = []; } addEventListener() {} remove() { if (this.parent) this.parent.childNodes = this.parent.childNodes.filter(n => n !== this); } querySelector(selector) { const name = selector.slice(1); for (const child of this.childNodes) { if (child.className.split(' ').includes(name)) return child; const nested = child.querySelector(selector); if (nested) return nested; } return null; } get firstChild() { return this.childNodes[0]; } } // 每个测试使用独立页面上下文,不共享会话状态。 function page() { const elements = new Map(); const timers = []; const context = vm.createContext({ document: { getElementById(id) { if (!elements.has(id)) elements.set(id, new Element()); return elements.get(id); }, createElement() { return new Element(); }, body: new Element(), }, console, setTimeout(fn, ms) { timers.push({ fn, ms }); }, clearInterval() {}, WebSocket: { OPEN: 1 }, fetch: async () => ({ json: async () => ({ model: 'test', model_service_url: 'http://fake/v1' }) }), }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../static/app.js'), 'utf8'), context); return { context, elements, timers }; } const block = (id, speaker, text = 'text', reason = '') => ({ block_id: `block-${id}`, sentence: text, sentence_type: 1, start_time: id * 1000, end_time: (id + 1) * 1000, speaker_id: speaker, speaker_name: speaker < 0 ? '' : `Person ${speaker}`, speaker_evidence: speaker < 0 ? 'pending' : 'confirmed', speaker_reason: reason, }); test('display snapshots update and merge pending rows; stale snapshots are ignored', () => { const { context, elements } = page(); context.renderDisplayState({ revision: 1, display_blocks: [block(0, -1), block(1, -1)] }, true); assert.equal(elements.get('resultArea').childNodes.length, 2); context.renderDisplayState({ revision: 2, display_blocks: [block(0, 0, 'A B')] }, true); assert.equal(elements.get('resultArea').childNodes.length, 1); assert.equal(elements.get('resultArea').querySelector('.speaker-name').textContent, 'Person 0'); context.renderDisplayState({ revision: 1, display_blocks: [block(0, -1), block(1, -1)] }, true); assert.equal(elements.get('resultArea').childNodes.length, 1); }); test('A B A retains time order and an unknown interruption has its own bubble', () => { const { context, elements } = page(); context.renderDisplayState({ revision: 1, display_blocks: [block(0, 0), block(1, 1), block(2, 0), block(3, -1, 'short', '音频不足')] }, true); const rows = elements.get('resultArea').childNodes; assert.deepEqual(rows.map(row => row.querySelector('.speaker-name').textContent), ['Person 0', 'Person 1', 'Person 0', '未知说话人']); assert.equal(rows[3].querySelector('.speaker-name').title, '音频不足'); }); test('sentences do not duplicate display_state and end uses the final snapshot', () => { const { context, elements } = page(); vm.runInContext('displayStateSupported = true', context); context.handleServerMessage({ type: 'sentences', sentences: [{ ...block(0, -1), sentence_id: 0 }] }, true); assert.equal(elements.get('resultArea').childNodes.length, 0); context.handleServerMessage({ type: 'end', display_blocks: [block(0, 0)], sentences: [] }, true); assert.equal(elements.get('resultArea').childNodes.length, 1); }); test('stop sends a control message without scheduling a forced close', () => { const { context, timers } = page(); vm.runInContext('var sent = []; ws = { readyState: 1, send(message) { sent.push(JSON.parse(message)); } }; sending = true', context); context.stopRecognition(); assert.equal(vm.runInContext('sent[0].type', context), 'stop'); assert.equal(timers.length, 0); assert.equal(vm.runInContext('ws !== null', context), true); }); test('raw logs render untrusted transcript as text', () => { const { context, elements } = page(); context.appendLog({ type: 'sentences', text: '' }); const entry = elements.get('logArea').childNodes[0]; assert.equal(entry.innerHTML, undefined); assert.ok(entry.childNodes[1].textContent.includes('