Files
baiyue-logistics-chat/tests/frontend/reply.test.cjs
T

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);
});