175 lines
6.0 KiB
JavaScript
175 lines
6.0 KiB
JavaScript
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);
|
|
});
|