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'); function playback(options = {}){ let clock = 0, nextId = 0, animate = options.animate !== false, completed = 0; const timers = new Map(), text = [], errors = []; const context = vm.createContext({ Intl, setTimeout(callback, delay){ const id = ++nextId; timers.set(id, {callback, at:clock + delay}); return id; }, clearTimeout(id){ timers.delete(id); }, }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js/reply.js'), 'utf8'), context); const reply = context.ChatReply.create({ now:() => clock, shouldAnimate:() => animate, onText:value => text.push(value), onComplete:() => { completed++; }, onError:error => errors.push(error), }); function advance(milliseconds){ const end = clock + milliseconds; let count = 0; while(true){ const next = Array.from(timers).filter(([,timer]) => timer.at <= end).sort((a,b) => a[1].at - b[1].at)[0]; if(!next) break; assert.ok(++count < 10000, 'Timer queue must settle'); clock = next[1].at; timers.delete(next[0]); next[1].callback(); } clock = end; } return {reply, advance, text, errors, timers, completed:() => completed, reduceMotion:() => { animate = false; }}; } test('a complete local response waits briefly and reveals text before finishing', () => { const app = playback(); const answer = '这是来自数据库的报价回复。'.repeat(10); app.reply.append(answer); app.reply.complete(); app.advance(299); assert.deepEqual(app.text, []); app.advance(1); assert.ok(app.text.at(-1).length < answer.length); assert.equal(app.completed(), 0); app.advance(4200); assert.equal(app.text.at(-1), answer); assert.equal(app.completed(), 1); assert.equal(app.timers.size, 0); }); test('real streaming chunks keep their full content and finish once', () => { const app = playback(); app.advance(1000); app.reply.append('第一段'); app.advance(200); app.reply.append(',接着第二段。'); app.reply.complete(); app.advance(4500); assert.equal(app.text.at(-1), '第一段,接着第二段。'); assert.equal(app.completed(), 1); app.reply.complete(); app.advance(100); assert.equal(app.completed(), 1); }); test('replacement supersedes both pending text and a previously displayed prefix', () => { const app = playback(); app.reply.append('旧的详细报价'.repeat(20)); app.advance(400); assert.ok(app.text.length); app.reply.replace('新的简明报价'); app.reply.complete(); app.advance(4500); assert.equal(app.text.at(-1), '新的简明报价'); assert.equal(app.completed(), 1); }); test('cancellation before the first reveal prevents late text and completion', () => { const app = playback(); app.reply.append('等待中的回复'); app.reply.complete(); app.reply.cancel(); app.advance(10000); assert.deepEqual(app.text, []); assert.equal(app.completed(), 0); assert.equal(app.timers.size, 0); }); test('cancellation freezes a displayed prefix and ignores later chunks', () => { const app = playback(); app.reply.append('已经收到的长回复'.repeat(100)); app.advance(500); const prefix = app.text.at(-1); app.reply.cancel(); app.reply.append('迟到的内容'); app.reply.complete(); app.advance(10000); assert.equal(app.text.at(-1), prefix); assert.equal(app.completed(), 0); }); test('cards are shown intact after the waiting feedback', () => { const app = playback(); let shown = 0; app.reply.card(() => { shown++; }); app.reply.complete(); app.advance(299); assert.equal(shown, 0); app.advance(1); assert.equal(shown, 1); assert.equal(app.completed(), 1); }); test('reduced motion immediately renders text and cards', () => { const app = playback({animate:false}); app.reply.append('快速显示的完整回复'); assert.equal(app.text.at(-1), '快速显示的完整回复'); app.reply.complete(); assert.equal(app.completed(), 1); assert.equal(app.timers.size, 0); const card = playback({animate:false}); let shown = false; card.reply.card(() => { shown = true; }); card.reply.complete(); assert.equal(shown, true); assert.equal(card.completed(), 1); }); test('a long answer has a bounded presentation duration', () => { const app = playback(); const answer = '运费和时效。'.repeat(4000); app.reply.append(answer); app.reply.complete(); app.advance(4320); assert.equal(app.text.at(-1), answer); assert.equal(app.completed(), 1); }); test('grapheme clusters remain intact during progressive display', () => { const app = playback(); app.reply.append('👨‍👩‍👧‍👦您好,这是完整字符。'); app.advance(300); assert.equal(app.text[0], '👨‍👩‍👧‍👦'); app.reply.complete(); app.advance(4500); assert.equal(app.text.at(-1), '👨‍👩‍👧‍👦您好,这是完整字符。'); }); test('a hidden or reduced-motion page can flush all received text', () => { const app = playback(); const answer = '完整的报价内容'.repeat(50); app.reply.append(answer); app.reply.complete(); app.advance(350); app.reduceMotion(); app.reply.flush(); assert.equal(app.text.at(-1), answer); assert.equal(app.completed(), 1); assert.equal(app.timers.size, 0); }); test('an asynchronous rendering failure cancels the queue and reports once', () => { const app = playback(); app.reply.card(() => { throw new Error('Cannot render card'); }); app.reply.complete(); app.advance(500); assert.equal(app.errors.length, 1); assert.equal(app.completed(), 0); assert.equal(app.timers.size, 0); });