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, '需要恢复的多行草稿'); });