Files
baiyue-logistics-chat/tests/frontend/admin.test.cjs
T
ouyangxq 805675f8db 重构物流客服模块并完善会话接待工作台
- 拆分应用工厂、业务服务与路由,保留原有 HTTP/SSE 和存储入口
- 分离页面资源,重排客服工作台并完善响应式导航、草稿和附件交互
- 整理维护脚本、请求示例和贡献指南,移除启动脚本中的密钥配置
- 上传文件改为仅本地保留,补充隔离的后端与前端回归验证

验证:46 项 Python 测试通过,含 14 个已有子测试;12 个 Node 前端场景通过。
2026-10-29 10:59:29 +08:00

214 lines
9.9 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 source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8');
const flush = () => new Promise(setImmediate);
function response(data, status = 200){
return {ok: status >= 200 && status < 300, status, json: async () => data};
}
async function workspace(compact = false){
const elements = new Map();
const documentListeners = {};
let document;
class Element {
constructor(){
this.value = ''; this.children = []; this.attributes = {}; this.listeners = {};
this.style = {}; this.hidden = false; this.scrollHeight = 70;
this.scrollTop = 0; this.clientHeight = 300;
const classes = new Set();
this.classList = {
toggle: (name, enabled) => { enabled ? classes.add(name) : classes.delete(name); },
contains: name => classes.has(name),
};
}
set innerHTML(value){ this.html = value; this.children = []; }
get innerHTML(){ return this.html || ''; }
setAttribute(name, value){ this.attributes[name] = value; }
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
appendChild(element){ this.children.push(element); }
addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; }
focus(){ document.activeElement = this; }
click(){ if(this.onclick) return this.onclick(); }
}
const quickButtons = [new Element(), new Element()];
document = {
body: new Element(), activeElement: null,
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
addEventListener(name, callback){ documentListeners[name] = callback; },
};
const calls = [];
const hooks = {};
const context = vm.createContext({
document, console,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}},
setInterval(){}, setTimeout(){}, clearTimeout(){},
fetch: async (url, options) => {
const body = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url, body});
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
return response(options ? {ok:true} : []);
},
});
vm.runInContext(source, context, {filename:'admin.js'});
await flush();
await context.accept(7);
await flush();
return {context, element: id => document.getElementById(id), calls, hooks, document, documentListeners};
}
test('failed replies preserve the draft and make retry available', async () => {
const app = await workspace();
app.hooks.send = () => response({ok:false}, 503);
app.element('replyText').value = '请确认这批货物的外箱尺寸';
await app.context.sendReply();
assert.equal(app.element('replyText').value, '请确认这批货物的外箱尺寸');
assert.equal(app.element('reply-send').disabled, false);
assert.match(app.element('reply-status').textContent, /内容已保留/);
app.hooks.send = () => response({ok:true});
await app.context.sendReply();
assert.equal(app.element('replyText').value, '');
});
test('a second send click cannot duplicate an in-flight reply', async () => {
const app = await workspace();
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
app.element('replyText').value = '正在确认线路';
const first = app.context.sendReply();
await app.context.sendReply();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
assert.equal(app.element('reply-send').disabled, true);
resolve(response({ok:true}));
await first;
});
test('new text typed while a reply is sending is retained', async () => {
const app = await workspace();
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
const input = app.element('replyText');
input.value = '第一条回复';
const sending = app.context.sendReply();
input.value = '继续补充的第二条回复';
input.listeners.input();
resolve(response({ok:true}));
await sending;
assert.equal(input.value, '继续补充的第二条回复');
});
test('switching sessions during a send does not erase another customer draft', async () => {
const app = await workspace();
await app.context.accept(8);
app.element('replyText').value = '客户 8 的草稿';
app.context.switchSession('7');
app.element('replyText').value = '发给客户 7';
let resolve;
app.hooks.send = () => new Promise(done => { resolve = done; });
const sending = app.context.sendReply();
app.context.switchSession('8');
resolve(response({ok:true}));
await sending;
assert.equal(app.context.curSid, '8');
assert.equal(app.element('replyText').value, '客户 8 的草稿');
app.context.switchSession('7');
assert.equal(app.element('replyText').value, '');
});
test('quick replies become editable drafts before sending', async () => {
const app = await workspace();
app.context.quickReply('请提供运单号');
assert.equal(app.element('replyText').value, '请提供运单号');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.match(app.element('reply-status').textContent, /可编辑后发送/);
});
test('a failed close keeps the selected session and reply visible', async () => {
const app = await workspace();
app.hooks.close = () => response({ok:false}, 503);
await app.context.closeSession();
assert.equal(app.context.curSid, '7');
assert.equal(app.element('chatContent').style.display, 'flex');
assert.match(app.element('reply-status').textContent, /会话仍保留/);
});
test('IME confirmation and Shift+Enter do not submit a reply', async () => {
const app = await workspace();
app.element('replyText').value = '正在输入中文';
const onKey = app.element('replyText').listeners.keydown;
const preventDefault = () => {};
onKey({key:'Enter', isComposing:true, shiftKey:false, preventDefault});
onKey({key:'Enter', isComposing:false, shiftKey:true, preventDefault});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
onKey({key:'Enter', isComposing:false, shiftKey:false, preventDefault});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
});
test('the compact service drawer is keyboard accessible and closes on Escape', async () => {
const app = await workspace(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
app.context.setToolsOpen(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
assert.equal(app.document.activeElement, app.element('tools-close'));
app.documentListeners.keydown({key:'Escape'});
assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'false');
assert.equal(app.document.activeElement, app.element('tools-toggle'));
});
test('desktop service information starts closed and can be toggled on demand', async () => {
const app = await workspace();
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
app.context.setToolsOpen(true);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'true');
app.context.setToolsOpen(false);
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true');
});
test('returning to the mobile session list keeps the selected session and draft', async () => {
const app = await workspace(true);
app.element('replyText').value = '返回列表前的草稿';
app.context.showSessionList();
assert.equal(app.context.curSid, '7');
assert.equal(app.document.body.classList.contains('chat-view'), false);
assert.equal(app.document.activeElement, app.element('tab-active'));
assert.equal(app.element('replyText').value, '返回列表前的草稿');
app.context.switchSession('7');
assert.equal(app.document.body.classList.contains('chat-view'), true);
assert.equal(app.element('replyText').value, '返回列表前的草稿');
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 0);
});
test('mobile operators can open service information without selecting a conversation', async () => {
const app = await workspace(true);
app.context.showEmptySession();
app.element('tools-from-list').click();
assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false');
app.element('tools-close').click();
assert.equal(app.document.activeElement, app.element('tools-from-list'));
});
test('mobile drafts are measured after the chat view becomes visible', async () => {
const app = await workspace(true);
const input = app.element('replyText');
Object.defineProperty(input, 'scrollHeight', {
get: () => app.document.body.classList.contains('chat-view') ? 128 : 0,
});
input.value = '需要恢复的多行草稿';
app.context.showSessionList();
app.context.switchSession('7');
assert.equal(input.style.height, '128px');
assert.equal(input.value, '需要恢复的多行草稿');
});