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

299 lines
14 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 sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.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 []; }
querySelector(selector){
if(selector === '.meta') return this.meta || (this.meta = new Element());
return null;
}
setSelectionRange(start, end){ this.selectionStart = start; this.selectionEnd = end; }
showModal(){ this.open = true; }
close(){ this.open = false; }
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 : []; },
querySelector(selector){
const match = selector.match(/data-message-id="(\d+)"/);
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
},
addEventListener(name, callback){ documentListeners[name] = callback; },
};
const calls = [];
const hooks = {};
const context = vm.createContext({
document, console,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
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 === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue();
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
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(sharedSource, context, {filename:'support.js'});
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
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, '需要恢复的多行草稿');
});
test('transfer returns a conversation to the queue without auto-accepting it again', async () => {
const app = await workspace();
app.hooks.queue = () => response([{id:7, ctime:'now'}]);
await app.context.transferSession();
await flush();
assert.equal(app.context.curSid, 0);
assert.equal(app.context.sessions['7'], undefined);
assert.equal(app.calls.filter(call => call.url === '/api/human/accept').length, 1);
});
test('failed transfers retain the conversation and draft', async () => {
const app = await workspace();
app.hooks.transfer = () => response({ok:false}, 503);
app.element('replyText').value = '等待确认的草稿';
await app.context.transferSession();
assert.equal(app.context.curSid, '7');
assert.equal(app.element('replyText').value, '等待确认的草稿');
assert.match(app.element('reply-status').textContent, /转接未成功/);
});
test('a late note response cannot overwrite a different session note', async () => {
const app = await workspace();
app.context.sessions['7'].noteLoaded = false;
let resolve;
app.hooks.poll = url => url.includes('sid=7&') ? new Promise(done => { resolve = done; }) : response({status:'active', note:'客户 8 的备注', messages:[]});
const first = app.context.loadSessionNote('7');
app.context.sessions['8'] = {lastId:0, msgs:[], unread:0};
app.context.switchSession('8');
await flush();
resolve(response({status:'active', note:'客户 7 的备注', messages:[]}));
await first;
assert.equal(app.element('noteInput').value, '客户 8 的备注');
});
test('failed note saves keep the edited note available', async () => {
const app = await workspace();
app.hooks.note = () => response({ok:false}, 503);
app.element('noteInput').value = '下个班次继续跟进';
await app.context.saveNote();
assert.equal(app.element('noteInput').value, '下个班次继续跟进');
assert.match(app.element('reply-status').textContent, /备注保存失败/);
});
test('recalling an older reply leaves the newest reply intact', async () => {
const app = await workspace();
app.context.sessions['7'].msgs = [
{id:1, side:'agent', content:'较早回复'}, {id:2, side:'agent', content:'最新回复'},
];
app.context.sessions['7'].msgs.forEach(app.context.appendMsg);
await app.context.recallMsg(1);
assert.equal(app.context.sessions['7'].msgs[0].content, '该消息已撤回');
assert.equal(app.context.sessions['7'].msgs[1].content, '最新回复');
assert.equal(app.element('chatBox').children[0].textContent, '该消息已撤回');
});
test('historical search renders customer text without interpreting HTML', async () => {
const app = await workspace();
app.hooks.search = () => response([{title:'<img onerror=bad()>', content:'<script>bad()</script>', role:'user', ctime:'now'}]);
app.element('searchBox').value = '物流';
await app.context.doSearch();
assert.match(app.element('searchBody').innerHTML, /&lt;script&gt;/);
assert.doesNotMatch(app.element('searchBody').innerHTML, /<script>/);
});