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

690 lines
38 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');
const flush = () => new Promise(setImmediate);
async function customerPage(pageOptions = {}){
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
let voiceOptions;
class Element {
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
this.listeners = {};
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
remove: name => { this.className = this.className.split(' ').filter(value => value !== name).join(' '); },
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
};
}
set innerHTML(value){ this.html = value; this.children = []; }
get innerHTML(){ return this.html || ''; }
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
setAttribute(name, value){ this.attributes[name] = value; }
removeAttribute(name){ delete this.attributes[name]; }
getAttribute(name){ return this.attributes[name]; }
addEventListener(name, callback){ this.listeners[name] = callback; }
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
appendChild(child){ child.parent = this; this.children.push(child); }
append(...children){ children.forEach(child => this.appendChild(child)); }
focus(){}
querySelector(selector){
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector){
const matches = element => {
if(selector.startsWith('.')) return element.classList.contains(selector.slice(1));
const id = selector.match(/data-human-message-id="(\d+)"/);
return id && element.attributes['data-human-message-id'] === id[1];
};
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
}
closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this;
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
}
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
const document = {
body: new Element(), documentElement: {},
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
createElement: () => new Element(),
addEventListener: (name, callback) => { listeners[name] = callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]};
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
requestAnimationFrame:callback => callback(),
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
window:{innerWidth:pageOptions.width || 1200,
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
matchMedia:query => ({matches:pageOptions.width ? pageOptions.width <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1]) : false}),
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
prompt:() => pageOptions.offlineMessage || null},
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
clearInterval:id => intervals.delete(id),
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
clearTimeout:id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options);
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
if(url === '/stream_chat'){
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {value:new TextEncoder().encode('data: {"t":"测试 AI 回复"}\n\ndata: {"d":1}\n\n'), done:false};
}})}};
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/visitor/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
context.ChatVoice.create=options => {voiceOptions=options; return createVoice(options);};
}
}
listeners.DOMContentLoaded();
await flush();
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, hooks, quickButtons, voiceOptions,
resize:async height => {
if(context.window.visualViewport) context.window.visualViewport.height=height;
(windowListeners.resize || []).forEach(callback => callback());
await flush();
},
resizeViewport:async (height,scale=1) => {
Object.assign(context.window.visualViewport,{height,scale});
(viewportListeners.resize || []).forEach(callback => callback());
await flush();
},
setState:value => { state=value; },
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
click:async button => { await button.click(); await flush(); },
};
}
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
const app = await customerPage();
app.setState({status:'closed', messages:[{id:1, side:'agent', content:'最后一条回复'}]});
await app.tick();
const log = app.element('chatDom');
assert.match(log.textContent, /最后一条回复/);
assert.match(log.textContent, /人工客服已结束/);
const rating = log.querySelector('.service-rating');
assert.ok(rating);
await rating.querySelector('.rating-choices').children[4].onclick();
assert.match(rating.textContent, /感谢您的评价/);
assert.equal(app.calls.find(call => call.url === '/api/rate').body.stars, 5);
assert.equal(app.context.window.humanSid, 0);
});
function streamResponse(chunks){
let index=0;
return {ok:true, body:{getReader:() => ({read:async () => index < chunks.length
? {done:false, value:typeof chunks[index] === 'string' ? new TextEncoder().encode(chunks[index++]) : chunks[index++]}
: {done:true}})}};
}
test('AI send preserves a newer draft typed while the conversation is being created', async () => {
const app=await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat']=() => new Promise(done => { resolve=done; });
const sending=app.context.window.fillInput('第一条问题');
app.element('input-text').value='正在补充的下一条问题';
resolve(response({id:18,welcome:'welcome'}));
await sending;
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '第一条问题');
assert.equal(app.element('input-text').value, '正在补充的下一条问题');
});
test('switching to a quick topic discards the previous topic attachment', async () => {
const app=await customerPage({human:false});
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
app.context.window.__pendingTag='[文件]';
await app.click(app.quickButtons[1]);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
});
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
const app=await customerPage({human:false});
const bytes=new TextEncoder().encode('data: {"t":"完整中文回复"}\r\n\r\ndata: {"d":1}\r\n\r\n');
const chunks=Array.from({length:Math.ceil(bytes.length/5)}, (_,index) => bytes.slice(index*5,index*5+5));
app.hooks['/stream_chat']=() => streamResponse(chunks);
await app.context.window.fillInput('请回复');
await flush();
assert.match(app.element('chatDom').textContent, /完整中文回复/);
assert.equal(app.element('chatDom').querySelector('.retry-btn'), null);
assert.equal(app.context.window.__busy, false);
});
test('a stream ending without completion retains partial text and offers retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
await app.context.window.fillInput('请继续说明');
await flush();
assert.match(app.element('chatDom').textContent, /已经收到的部分回答/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
assert.equal(app.context.window.__busy, false);
});
test('rate limit responses show the server message and allow retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => response({e:'请求太频繁,请稍后再试'},429);
await app.context.window.fillInput('请查单');
await flush();
assert.match(app.element('chatDom').textContent, /请求太频繁/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
});
test('a stalled conversation creation times out and can be retried without losing the draft', async () => {
const app=await customerPage({human:false});
app.hooks['/api/new_chat']=options => new Promise((resolve,reject) => {
if(options.signal) options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'), {name:'AbortError'})));
});
app.context.window.fillInput('请帮我查询');
await app.runTimers(10000);
assert.match(app.element('composer-status').textContent, /会话创建失败/);
assert.equal(app.element('input-text').value, '请帮我查询');
app.hooks['/api/new_chat']=() => response({id:19,welcome:'welcome'});
await app.context.window.fillInput('请帮我查询');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('history refresh failures preserve the last successfully loaded list', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留的对话',ctime:'2026-10-29 12:00'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await flush();
assert.match(app.element('historyWrap').textContent, /保留的对话/);
});
test('deleting the active conversation aborts its stream and resets the composer', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前对话',ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/stream_chat']=() => ({ok:true,body:{getReader:() => ({read:() => new Promise(() => {})})}});
await app.context.window.fillInput('请等待回复');
await flush();
const remove=app.element('historyWrap').children[0].children[2];
await remove.onclick({stopPropagation(){}});
await flush();
assert.equal(app.context.window.__busy,false);
assert.equal(app.element('send-button').textContent,'发送');
assert.ok(app.calls.find(call => call.url === '/stream_chat'));
});
test('switching topics aborts an upload and does not erase the new conversation status', async () => {
const app=await customerPage({human:false});
let aborted=false;
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => { aborted=true; reject(Object.assign(new Error('aborted'),{name:'AbortError'})); });
});
app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange();
await app.click(app.quickButtons[0]);
await flush();
assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('an upload timeout releases the composer and reports an actionable failure', async () => {
const app=await customerPage({human:false});
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'),{name:'AbortError'})));
});
app.element('upload-input').files=[{name:'slow.png'}];
app.element('upload-input').onchange();
await app.runTimers(15000);
assert.equal(app.element('upload-btn').disabled,false);
assert.match(app.element('composer-status').textContent,/附件上传失败/);
await app.context.window.fillInput('请继续查单');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('malformed stored pin preferences cannot break the conversation list', async () => {
const app=await customerPage({human:false,storage:{pinned_conv:'{broken'},history:[{id:9,title:'正常对话',ctime:'now'}]});
assert.match(app.element('historyWrap').textContent,/正常对话/);
});
test('clearing history blocks new conversations until its timed request settles', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留历史',ctime:'now'}]});
app.hooks['/api/clear_all']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'),{name:'AbortError'})));
});
app.element('.clear-all-btn').click();
await app.click(app.quickButtons[1]);
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('a failed history deletion preserves the active conversation for the next message', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前历史',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/api/del_history?id=9']=() => response({ok:false},503);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
await app.context.window.fillInput('继续这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id,9);
});
test('successful deletion updates the local list even when its refresh is rate limited', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'待删除会话',ctime:'now'},{id:10,title:'保留会话',ctime:'now'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
assert.doesNotMatch(app.element('historyWrap').textContent,/待删除会话/);
assert.match(app.element('historyWrap').textContent,/保留会话/);
});
test('successful clearing removes cached history when the followup refresh fails', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'待清空会话',ctime:'now'}]});
app.hooks['/api/clear_all']=() => response({ok:true});
app.hooks['/api/get_history']=() => Promise.reject(new Error('offline'));
app.element('.clear-all-btn').click();
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/待清空会话/);
assert.ok(app.element('historyWrap').querySelector('.history-empty'));
});
test('a history refresh started before a deletion cannot resurrect the deleted item', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'旧刷新中的会话',ctime:'now'}]});
let resolve, refreshes=0;
app.hooks['/api/get_history']=() => ++refreshes === 1 ? new Promise(done => { resolve=done; }) : response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
resolve(response([{id:9,title:'旧刷新中的会话',ctime:'now'}]));
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/旧刷新中的会话/);
});
test('history text and order cards preserve their original times and card actions', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'历史时间',ctime:'2026-10-28 07:12'}]});
app.hooks.messages=() => response([
{role:'user',content:'FL202610290001',ctime:'2026-10-28 07:12'},
{role:'assistant',content:'__CARD__'+JSON.stringify({kind:'order',no:'FL202610290001',status:'履约中',fields:[],nodes:[],summary:'订单概要'}),ctime:'2026-10-28 07:13'},
{role:'assistant',content:'后续补充回复',ctime:'2026-10-28 07:14'}
]);
await app.click(app.element('historyWrap').children[0]);
const times=app.element('chatDom').querySelectorAll('.msg-time');
assert.deepEqual(times.map(item => item.textContent),['07:12','07:13','07:14']);
assert.equal(times[0].title,'2026-10-28 07:12');
assert.ok(app.element('chatDom').querySelector('.track-card').querySelector('.msg-actions'));
});
test('recalling an earlier reply preserves newer replies in the customer log', async () => {
const app = await customerPage();
app.setState({status:'active', messages:[{id:1, side:'agent', content:'早先的回复'}, {id:2, side:'agent', content:'最新回复'}]});
await app.tick();
app.setState({status:'active', messages:[{id:3, side:'system', content:'该消息已撤回', recalled_id:1}]});
await app.tick();
const log = app.element('chatDom');
assert.match(log.textContent, /该消息已撤回/);
assert.match(log.textContent, /最新回复/);
assert.doesNotMatch(log.textContent, /早先的回复/);
});
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
const app = await customerPage();
for(const button of app.quickButtons) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
});
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
const app = await customerPage();
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
const button = app.element('question-'+question);
button.dataset.question = question;
app.element('chatDom').listeners.click({target:button});
await flush();
}
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
});
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
const app = await customerPage({human:false});
const button = app.element('contact-card');
button.setAttribute('data-human-support', '');
app.element('chatDom').listeners.click({target:button});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
assert.equal(app.element('human-session').hidden, false);
});
test('new chat clears human routing and the next quote is sent to AI', async () => {
const app = await customerPage();
await app.click(app.element('.new-chat-btn'));
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.__busy, false);
});
test('opening AI history clears the human session and retains that conversation id', async () => {
const app = await customerPage({history:[{id:9, title:'之前的对话', ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
await app.context.window.fillInput('请继续回答这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 9);
assert.equal(app.context.window.humanSid, 0);
const authors = app.element('chatDom').querySelectorAll('.message-author');
assert.ok(authors.some(author => author.innerHTML.includes('data-i18n="assistant"')));
});
test('a repeated quick click while a new conversation is being created sends only once', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click();
app.quickButtons[0].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'}));
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('a late conversation creation cannot replace the newest quick question', async () => {
const app = await customerPage({human:false});
let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click();
await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'}));
await flush();
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 1);
assert.equal(requests[0].body.conv_id, 22);
assert.equal(requests[0].body.msg, '包裹异常处理');
assert.equal(app.context.window.__busy, false);
});
test('a late history response cannot overwrite the latest quick conversation', async () => {
const app = await customerPage({history:[{id:9, title:'旧会话', ctime:'2026-10-29 12:00'}]});
let resolve;
app.hooks.messages = () => new Promise(done => { resolve=done; });
await app.click(app.element('historyWrap').children[0]);
await app.click(app.quickButtons[1]);
resolve(response([{role:'user', content:'旧会话的内容不应显示'}]));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧会话的内容不应显示/);
assert.match(app.element('chatDom').textContent, /测试 AI 回复/);
});
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/visitor/human/request'] = () => new Promise(done => { resolve=done; });
const connecting = app.context.window.showHuman();
await flush();
await app.click(app.quickButtons[1]);
resolve(response({ok:true, session_id:8}));
await connecting;
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('repeated contact clicks create one human request and show a visible queue state', async () => {
const app = await customerPage({human:false});
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.element('human-session').hidden, false);
assert.match(app.element('human-session-label').textContent, /正在排队/);
});
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0);
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
assert.match(app.element('chatDom').textContent, /留言已提交/);
assert.equal(app.context.window.humanSid, 0);
});
test('a failed human send preserves the text and makes retry possible', async () => {
const app = await customerPage();
app.hooks['/api/visitor/human/send'] = () => response({ok:false}, 503);
await app.context.window.fillInput('请确认这批货的价格');
assert.equal(app.element('input-text').value, '请确认这批货的价格');
assert.match(app.element('composer-status').textContent, /内容已保留/);
assert.equal(app.element('send-button').disabled, false);
});
test('a duplicate human send cannot submit an in-flight message twice', async () => {
const app = await customerPage();
let resolve;
app.hooks['/api/visitor/human/send'] = () => new Promise(done => { resolve=done; });
const first = app.context.window.fillInput('请确认时效');
await app.context.window.fillInput('请确认时效');
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 1);
resolve(response({ok:true}));
await first;
assert.equal(app.element('input-text').value, '');
});
test('returning to AI explicitly closes the human session and preserves the conversation', async () => {
const app = await customerPage();
await app.click(app.element('return-ai'));
await app.context.window.fillInput('请继续解答');
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/close').length, 1);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
assert.equal(app.context.window.humanSid, 0);
});
test('a stale human poll cannot append messages to a newly selected AI topic', async () => {
const app = await customerPage();
let resolve;
app.hooks.poll = () => new Promise(done => { resolve=done; });
await app.tick();
await app.click(app.quickButtons[1]);
resolve(response({status:'active', messages:[{id:1, side:'agent', content:'旧人工会话的回复'}]}));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧人工会话的回复/);
});
test('human poll failures are visible and clear after the connection recovers', async () => {
const app = await customerPage();
app.hooks.poll = () => Promise.reject(new Error('Disconnected'));
await app.tick();
assert.match(app.element('composer-status').textContent, /连接中断/);
delete app.hooks.poll;
await app.tick();
assert.equal(app.element('composer-status').hidden, true);
});
test('order cards show facts as text without import metadata, including older saved cards', async () => {
const app = await customerPage({human:false});
const card = {kind:'order', no:'FL202610290001', status:'履约中',
fields:[{key:'weight',value:'30.000 kg'},{key:'warehouse',value:'<img src=x onerror=bad()>'}],
nodes:[{time:'2026-10-29 10:00',text:'已提交',place:''}],
summary:'订单 FL202610290001,申报毛重30.000 kg', snapshot_at:'2026-10-29 10:30', notice:'旧数据说明'};
app.hooks['/stream_chat'] = () => {
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {done:false,value:new TextEncoder().encode('data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n')};
}})}};
};
await app.context.window.fillInput('FL202610290001');
await flush();
const log = app.element('chatDom');
const fields = log.querySelector('.order-data-fields');
assert.ok(fields);
assert.equal(fields.children[1].textContent, '30.000 kg');
assert.equal(fields.children[3].innerHTML, '');
assert.equal(log.querySelector('.order-data-notice'), null);
assert.doesNotMatch(log.textContent, /旧数据说明|数据导入|2026-10-29 10:30/);
assert.equal(app.context.window.__busy, false);
});
test('shipment cards separate forecasts from actual progress and retain both in history', async () => {
const app = await customerPage({human:false, history:[{id:8,title:'订单轨迹',ctime:'2026-10-29'}]});
const card = {kind:'order', no:'FL202610290001', status:'履约中', fields:[], node_kind:'logistics',
tracking_status:'船舶已离开起运港', nodes:[{time:'2026-10-28 10:00',text:'船舶已离开起运港',place:'起运港'}],
estimated_nodes:[{time:'2026-11-02 10:00',text:'预计到达目的港',place:'<img src=x onerror=bad()>'}],
summary:'船舶已离开起运港,预计到达目的港2026-11-02'};
app.hooks.messages = () => response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'2026-10-29 10:00'}]);
await app.click(app.element('historyWrap').children[0]);
const log = app.element('chatDom');
assert.match(log.textContent, /物流轨迹/);
assert.match(log.textContent, /预计节点/);
assert.match(log.textContent, /预计到达目的港/);
const timelines = log.querySelectorAll('.track-timeline');
assert.equal(timelines.length, 2);
assert.ok(timelines[0].querySelector('.track-node').classList.contains('active'));
assert.equal(timelines[1].querySelector('.track-node').classList.contains('active'), false);
assert.match(timelines[1].querySelector('.track-text').innerHTML, /&lt;img/);
assert.ok(log.querySelector('.msg-actions'));
});
test('order details can expand without hiding the shipment timeline', async () => {
const app = await customerPage({human:false,width:390});
const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics',
fields:[{key:'shipment',value:'LONG-SHIPMENT-20261030001'}],
nodes:[{time:'2026-10-29 10:00',text:'海运途中',place:''}],summary:'订单概要'};
app.hooks['/stream_chat'] = () => streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('ORDER2026001');
await flush();
const log=app.element('chatDom'), fields=log.querySelector('.order-data-fields');
const toggle=log.querySelector('.order-details-toggle');
assert.equal(toggle.getAttribute('aria-expanded'),'false');
assert.ok(fields.classList.contains('is-collapsed'));
assert.match(log.querySelector('.track-timeline').textContent,/海运途中/);
await app.click(toggle);
assert.equal(toggle.getAttribute('aria-expanded'),'true');
assert.equal(fields.classList.contains('is-collapsed'),false);
assert.match(fields.textContent,/LONG-SHIPMENT-20261030001/);
await app.click(toggle);
assert.ok(fields.classList.contains('is-collapsed'));
});
test('viewport changes keep latest replies visible and preserve an older reading position', async () => {
const app=await customerPage({human:false,width:390,viewportHeight:844});
await app.context.window.fillInput('查询物流');
await flush();
const log=app.element('chatDom');
Object.assign(log,{scrollHeight:1500,clientHeight:600,scrollTop:900});
log.listeners.scroll();
log.clientHeight=260;
await app.resize(500);
assert.equal(app.element('.chat-container').style.height,'500px');
assert.equal(log.scrollTop,log.scrollHeight);
log.scrollTop=100;
log.listeners.scroll();
await app.resizeViewport(450);
assert.equal(app.element('.chat-container').style.height,'450px');
assert.equal(log.scrollTop,100);
await app.resizeViewport(400,2);
assert.equal(app.element('.chat-container').style.height,'');
assert.equal(log.scrollTop,100);
});
test('clearing a sent draft restores a single-row composer instead of sizing to its placeholder', async () => {
const app=await customerPage({human:false,width:320});
const input=app.element('input-text');
input.value='多行内容\n第二行';
input.listeners.input();
assert.equal(input.style.height,'140px');
await app.context.window.fillInput(input.value);
await flush();
assert.equal(input.value,'');
assert.equal(input.style.height,'');
});
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
for(const human of [false,true]){
const app=await customerPage({human,width:390});
await app.voiceOptions.onText('语音识别的物流问题');
await flush();
const call=app.calls.find(call=>call.url===(human ? '/api/visitor/human/send' : '/stream_chat'));
assert.ok(call);
assert.equal(human ? call.body.content : call.body.msg,'语音识别的物流问题');
assert.match(app.element('chatDom').querySelector('.user-message').textContent,/语音识别的物流问题/);
assert.equal(app.element('input-text').value,'');
}
});
test('a failed voice send keeps the transcript editable and a newer draft is not auto-submitted', async () => {
const app=await customerPage({human:false,width:390});
app.hooks['/api/new_chat']=()=>Promise.reject(new Error('offline'));
await app.voiceOptions.onText('语音内容');
assert.equal(app.element('input-text').value,'语音内容');
assert.equal(app.element('.input-area').classList.contains('voice-mode'),false);
app.element('input-text').value='新草稿';
await app.voiceOptions.onText('后到的识别文字');
assert.equal(app.element('input-text').value,'新草稿\n后到的识别文字');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('a blocked retry retains its previous answer until a replacement can start', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('之前的问题');await flush();
const log=app.element('chatDom'), retry=log.querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
app.hooks['/api/upload']=options=>new Promise((resolve,reject)=>{
options.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
});
app.element('upload-input').files=[{name:'pending.pdf'}];
app.element('upload-input').onchange();await flush();
await app.click(retry);
assert.match(log.textContent,/测试 AI 回复/);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
await app.runTimers(15000);
await app.click(retry);await flush();
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
assert.equal(log.querySelectorAll('.msg-action-btn').filter(button=>button.dataset.action==='retry').length,1);
});
test('an AI regeneration is not forwarded to a human session', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('之前的问题');await flush();
const retry=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
await app.context.window.showHuman();await app.click(retry);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/human/send').length,0);
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
});