diff --git a/logistics_chat/business_service.py b/logistics_chat/business_service.py index 4b8efe0..5fab7f8 100644 --- a/logistics_chat/business_service.py +++ b/logistics_chat/business_service.py @@ -8,8 +8,17 @@ from .tracking import card_to_text def feedback(data): """收集 👍/👎 反馈,存到 feedback 表""" + if not isinstance(data, dict): + return {'ok': False, 'error': '评价格式无效。'}, 400 + raw_vote = data.get('vote') + votes = {'up': 1, 'down': -1, '1': 1, '-1': -1} + vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote + question, answer = data.get('question', ''), data.get('answer', '') + if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str): + return {'ok': False, 'error': '评价内容无效,请重试。'}, 400 conn = connect() - conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str())) + conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', + (question[:500], answer[:2000], vote, now_str())) conn.commit() conn.close() return {'ok': True} @@ -19,8 +28,8 @@ def admin_stats(): conn = connect() total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0] total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0] - good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0] - bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0] + good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0] + bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0] today = datetime.now().strftime('%Y-%m-%d') today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0] rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone() @@ -39,8 +48,16 @@ def my_orders(phone): def add_ticket(data): + if not isinstance(data, dict): + return {'ok': False, 'error': '留言格式无效。'}, 400 + content, phone = data.get('content'), data.get('phone', '') + if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000: + return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400 + if not isinstance(phone, str) or len(phone.strip()) > 32: + return {'ok': False, 'error': '联系电话格式无效。'}, 400 conn = connect() - conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending')) + conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', + (content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending')) conn.commit() conn.close() return {'ok': True} diff --git a/logistics_chat/routes/business.py b/logistics_chat/routes/business.py index d068b90..7607104 100644 --- a/logistics_chat/routes/business.py +++ b/logistics_chat/routes/business.py @@ -13,7 +13,7 @@ def my_orders(): @bp.route('/api/ticket', methods=['POST']) def add_ticket(): - return business_service.add_ticket(request.get_json() or {}) + return business_service.add_ticket(request.get_json(silent=True)) @bp.route('/api/tickets') diff --git a/logistics_chat/routes/chat.py b/logistics_chat/routes/chat.py index afbe356..e7a7aab 100644 --- a/logistics_chat/routes/chat.py +++ b/logistics_chat/routes/chat.py @@ -45,4 +45,4 @@ def stream_chat(): @bp.route('/api/feedback', methods=['POST']) def feedback(): - return business_service.feedback(request.get_json(silent=True) or {}) + return business_service.feedback(request.get_json(silent=True)) diff --git a/static/css/chat.css b/static/css/chat.css index a82a030..9735a2a 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -133,6 +133,8 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); } .msg-action-btn { min-height: 30px; border: 0; padding: 3px 8px; color: var(--secondary); background: transparent; border-radius: 5px; font-size: 12px; } .msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); } +.vote-status { align-self: center; color: var(--muted); font-size: 11px; } +.vote-status.is-error { color: var(--danger); } .error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); } .err-line { margin-bottom: 9px; } .retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; } diff --git a/static/js/chat.js b/static/js/chat.js index d6b33ce..6c169a8 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -28,7 +28,7 @@ let humanPollTimer = null; let humanSendingToken = null; let humanClosing = false; window.humanSid = 0; -SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); }); +const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); }); document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); }; function setComposerStatus(message, isError){ @@ -245,6 +245,9 @@ const I18N = { stop: "停止", copy: "复制", copied: "已复制", + copyFailed: "复制失败", + voteUp: "这条回复有帮助", + voteDown: "这条回复没有帮助", retry: "重答", retryAIOnly: "请先返回 AI 问答,再重答这条消息。", clickRetry: "点击重试", @@ -355,6 +358,9 @@ const I18N = { stop: "Stop", copy: "Copy", copied: "Copied", + copyFailed: "Copy failed", + voteUp: "This reply was helpful", + voteDown: "This reply was not helpful", retry: "Regenerate", retryAIOnly: "Return to AI chat before regenerating this answer.", clickRetry: "Retry", @@ -483,6 +489,7 @@ function applyLanguage(lang){ const n = b.dataset.action; if(n === "copy") b.textContent = dict.copy; if(n === "retry") b.textContent = dict.retry; + if(n === 'voteUp' || n === 'voteDown') b.setAttribute('aria-label', dict[n]); }); document.querySelectorAll(".retry-btn").forEach(function(b){ if(!b.dataset.keep) b.textContent = dict.clickRetry; @@ -824,6 +831,7 @@ function ensureConv(){ } function resetConversation(id){ + emojiPicker.close(); if(voiceInput) voiceInput.cancel(); if(cancelActive){ cancelActive(); cancelActive = null; } if(sseInstance){ sseInstance.close(); sseInstance = null; } @@ -846,6 +854,7 @@ function openConversation(id){ if(openingConv === 'clearing') return; if(openingConv === id) return; // 防连点 const version = resetConversation(id); + if(isNarrow()){ sideOpen = false; renderSidebar(); } openingConv = id; setComposerStatus(t('historyLoading')); renderSideHistory(); @@ -1185,7 +1194,7 @@ async function sendHumanMessage(content){ async function leaveOfflineMessage(){ const version = conversationVersion; addMsg(t('humanHours'), false); - const message = window.prompt(t('offlinePrompt')); + const message = (window.prompt(t('offlinePrompt')) || '').trim(); if(!message) return; try{ const data = await humanRequest('/api/ticket', {content:message, phone:''}); @@ -1193,8 +1202,8 @@ async function leaveOfflineMessage(){ if(version === conversationVersion) addMsg(t('offlineSaved'), false); }catch(error){ if(version !== conversationVersion) return; - if(!inputDom.value) inputDom.value = message; - setComposerStatus(t('networkFail'), true); + if(!inputDom.value){ inputDom.value = message; autoGrow(); } + setComposerStatus(error.status === 400 ? error.message : t('networkFail'), true); } } @@ -1391,26 +1400,34 @@ function attachActions(bubble, rawText, question, showRetry){ copyBtn.dataset.action = "copy"; copyBtn.textContent = t("copy"); copyBtn.onclick = function(){ + if(copyBtn.disabled) return; + copyBtn.disabled = true; const plain = mdToPlain(rawText); - const done = ()=> { - copyBtn.textContent = t("copied"); - setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200); + const done = function(copied){ + copyBtn.disabled = false; + copyBtn.textContent = t(copied ? 'copied' : 'copyFailed'); + setTimeout(()=>{ copyBtn.textContent = t("copy"); }, copied ? 1200 : 3000); }; if(navigator.clipboard && navigator.clipboard.writeText){ - navigator.clipboard.writeText(plain).then(done).catch(fallback); + return navigator.clipboard.writeText(plain).then(()=>done(true)).catch(fallback); }else{ - fallback(); + return fallback(); } function fallback(){ const ta = document.createElement("textarea"); ta.value = plain; + ta.readOnly = true; ta.style.position = "fixed"; ta.style.opacity = "0"; + const previousFocus = document.activeElement; document.body.appendChild(ta); - ta.select(); - try{ document.execCommand("copy"); }catch(e){} - document.body.removeChild(ta); - done(); + let copied = false; + try{ ta.focus({preventScroll:true}); ta.select(); copied = Boolean(document.execCommand("copy")); }catch(e){} + finally{ + ta.remove(); + if(previousFocus && previousFocus.isConnected) previousFocus.focus({preventScroll:true}); + } + done(copied); } }; bar.appendChild(copyBtn); @@ -1425,23 +1442,52 @@ function attachActions(bubble, rawText, question, showRetry){ } // 👍/👎 反馈 + let votePending = false; + const voteButtons = []; + const voteStatus = document.createElement('span'); + voteStatus.className = 'vote-status'; + voteStatus.setAttribute('role', 'status'); function addVote(vote, label){ const b = document.createElement("button"); b.className = "msg-action-btn"; b.textContent = label; - b.onclick = function(){ - if(b.classList.contains("voted")) return; - bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); }); - b.classList.add("voted"); - fetch("/api/feedback", { - method:"POST", headers:{"Content-Type":"application/json"}, - body: JSON.stringify({question: question, answer: rawText, vote: vote}) - }).catch(function(){}); + b.dataset.action = vote === 'up' ? 'voteUp' : 'voteDown'; + b.setAttribute('aria-label', t(vote === 'up' ? 'voteUp' : 'voteDown')); + b.setAttribute('aria-pressed', 'false'); + b.onclick = async function(){ + if(votePending || b.classList.contains('voted')) return; + votePending = true; + voteButtons.forEach(button => { button.disabled = true; }); + voteStatus.textContent = ''; + voteStatus.removeAttribute('data-i18n'); + voteStatus.classList.remove('is-error'); + try{ + const data = await requestJson('/api/feedback', { + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({question:question, answer:rawText, vote:vote}) + }); + if(!data.ok) throw new Error('Feedback failed'); + voteButtons.forEach(function(button){ + button.classList.toggle('voted', button === b); + button.setAttribute('aria-pressed', String(button === b)); + }); + voteStatus.textContent = t('ratingThanks'); + voteStatus.setAttribute('data-i18n', 'ratingThanks'); + }catch(error){ + voteStatus.textContent = t('ratingFailed'); + voteStatus.setAttribute('data-i18n', 'ratingFailed'); + voteStatus.classList.add('is-error'); + }finally{ + votePending = false; + voteButtons.forEach(button => { button.disabled = false; }); + } }; + voteButtons.push(b); bar.appendChild(b); } addVote("up", "👍"); addVote("down", "👎"); + bar.appendChild(voteStatus); bubble.appendChild(bar); } diff --git a/static/js/support.js b/static/js/support.js index 6e6dcea..b66c548 100644 --- a/static/js/support.js +++ b/static/js/support.js @@ -58,6 +58,10 @@ var SupportUI = { }, initEmoji(button, panel, input, onInsert){ const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚']; + function close(){ + panel.hidden = true; + button.setAttribute('aria-expanded', 'false'); + } button.onclick = function(){ panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); @@ -70,20 +74,24 @@ var SupportUI = { const start = input.selectionStart == null ? input.value.length : input.selectionStart; const end = input.selectionEnd == null ? start : input.selectionEnd; input.value = input.value.slice(0, start) + emoji + input.value.slice(end); - panel.hidden = true; - button.setAttribute('aria-expanded', 'false'); - input.focus(); + close(); + input.focus({preventScroll:true}); input.setSelectionRange(start + emoji.length, start + emoji.length); if(onInsert) onInsert(); }; panel.appendChild(choice); }); - panel.addEventListener('keydown', function(event){ - if(event.key === 'Escape'){ - panel.hidden = true; - button.setAttribute('aria-expanded', 'false'); - button.focus(); + ['pointerdown','mousedown'].forEach(function(name){ + document.addEventListener(name, function(event){ + if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close(); + }); + }); + document.addEventListener('keydown', function(event){ + if(event.key === 'Escape' && !panel.hidden){ + close(); + button.focus({preventScroll:true}); } }); + return {close:close}; } }; diff --git a/tests/frontend/admin.test.cjs b/tests/frontend/admin.test.cjs index f0ee812..f7014d8 100644 --- a/tests/frontend/admin.test.cjs +++ b/tests/frontend/admin.test.cjs @@ -32,6 +32,7 @@ async function workspace(compact = false){ 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); } + contains(target){ return this === target || this.children.some(child=>child.contains(target)); } addEventListener(name, callback){ this.listeners[name] = callback; } querySelectorAll(){ return []; } querySelector(selector){ @@ -47,7 +48,7 @@ async function workspace(compact = false){ 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); }, + getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); }, createElement(){ return new Element(); }, querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; }, querySelector(selector){ @@ -55,7 +56,7 @@ async function workspace(compact = false){ 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; }, + addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; }, }; const calls = []; const hooks = {}; diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 44382bb..3420226 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -13,7 +13,7 @@ async function customerPage(pageOptions = {}){ 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.focusCalls = []; + this.listeners = {}; this.focusCalls = []; this.isConnected = true; this.classList = { contains: name => this.className.split(' ').includes(name), add: name => { this.className += ' '+name; }, @@ -32,8 +32,10 @@ async function customerPage(pageOptions = {}){ 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)); } + contains(target){ return this === target || this.children.some(child=>child.contains(target)); } focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); } blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } } + select(){ document.activeElement=this; } querySelector(selector){ return this.querySelectorAll(selector)[0] || null; } @@ -50,21 +52,23 @@ async function customerPage(pageOptions = {}){ 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); } + remove(){ this.isConnected=false; 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); }; + element('emoji-panel').hidden=true; 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; }, + execCommand: () => pageOptions.copyResult !== false, + addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : 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, + document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true, FormData:class { append(){} }, requestAnimationFrame:callback => callback(), localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}}, @@ -95,7 +99,7 @@ async function customerPage(pageOptions = {}){ : 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} + : ['/api/rate', '/api/feedback', '/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}; }, @@ -112,6 +116,7 @@ async function customerPage(pageOptions = {}){ if(pageOptions.human !== false) await context.window.showHuman(); await flush(); return {context, element, calls, hooks, quickButtons, voiceOptions, + documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); }, resize:async height => { context.window.innerHeight=height; if(context.window.visualViewport) context.window.visualViewport.height=height; @@ -782,3 +787,95 @@ test('an AI regeneration is not forwarded to a human session', async () => { assert.match(app.element('chatDom').textContent,/测试 AI 回复/); assert.match(app.element('composer-status').textContent,/先返回 AI/); }); + +test('feedback waits for server confirmation, ignores rapid duplicates and allows retry after failure', async () => { + const app=await customerPage({human:false}); + await app.context.window.fillInput('反馈测试');await flush(); + const log=app.element('chatDom'), buttons=log.querySelectorAll('.msg-action-btn'); + const up=buttons.find(button=>button.textContent==='👍'), down=buttons.find(button=>button.textContent==='👎'); + let finish; + app.hooks['/api/feedback']=()=>new Promise(resolve=>{finish=resolve;}); + const sending=up.onclick(); + await flush(); + await down.onclick(); + assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,1); + assert.equal(up.classList.contains('voted'),false); + assert.ok(up.disabled && down.disabled); + finish(response({ok:false},503));await sending; + assert.equal(up.classList.contains('voted'),false); + assert.match(log.textContent,/评价未提交/); + assert.equal(up.disabled,false); + app.hooks['/api/feedback']=()=>response({ok:true}); + await up.onclick(); + assert.ok(up.classList.contains('voted')); + assert.equal(up.getAttribute('aria-pressed'),'true'); + assert.match(log.textContent,/感谢您的评价/); + await up.onclick(); + assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,2); +}); + +test('clipboard rejection and a failed fallback never report a successful copy', async () => { + const app=await customerPage({human:false,copyResult:false,clipboard:{writeText:()=>Promise.reject(new Error('denied'))}}); + await app.context.window.fillInput('复制测试');await flush(); + const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy'); + app.element('input-text').focus(); + await copy.onclick();await flush(); + assert.equal(copy.textContent,'复制失败'); + assert.equal(copy.disabled,false); + assert.equal(app.context.document.activeElement,app.element('input-text')); + assert.equal(app.element('input-text').focusCalls.at(-1).preventScroll,true); + assert.equal(app.context.document.body.children.length,0); + await app.runTimers(3000); + assert.equal(copy.textContent,'复制'); +}); + +test('successful clipboard fallback reports copied and keeps text focus stable', async () => { + const app=await customerPage({human:false}); + await app.context.window.fillInput('复制测试');await flush(); + const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy'); + await copy.onclick(); + assert.equal(copy.textContent,'已复制'); + assert.equal(copy.disabled,false); + await app.runTimers(1200); + assert.equal(copy.textContent,'复制'); +}); + +test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => { + const empty=await customerPage({human:false,hour:23,offlineMessage:' '}); + await empty.context.window.showHuman();await flush(); + assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0); + const app=await customerPage({human:false,hour:23,offlineMessage:'需要保留的留言'}); + app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400); + await app.context.window.showHuman();await flush(); + assert.equal(app.element('input-text').value,'需要保留的留言'); + assert.notEqual(app.element('input-text').style.height,''); + assert.match(app.element('composer-status').textContent,/1–2000/); +}); + +test('mobile history selection closes the drawer while desktop navigation stays visible', async () => { + for(const width of [390,1200]){ + const app=await customerPage({human:false,width,history:[{id:9,title:'历史会话',ctime:'now'}]}); + if(width===390) await app.click(app.element('.toggle-divider')); + assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),'true'); + await app.click(app.element('historyWrap').children[0]); + assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),String(width>900)); + } +}); + +test('emoji panel dismisses outside or on Escape, stays open for its own controls, and closes on conversation change', async () => { + const app=await customerPage({human:false,width:390}); + const toggle=app.element('emoji-toggle'), panel=app.element('emoji-panel'); + await app.click(toggle); + assert.equal(panel.hidden,false); + app.documentEvent('pointerdown',{target:panel.children[0]}); + assert.equal(panel.hidden,false); + app.documentEvent('pointerdown',{target:app.element('input-text')}); + assert.equal(panel.hidden,true); + assert.equal(toggle.getAttribute('aria-expanded'),'false'); + await app.click(toggle); + app.documentEvent('keydown',{key:'Escape'}); + assert.equal(panel.hidden,true); + await app.click(toggle); + await app.click(app.element('.new-chat-btn')); + assert.equal(panel.hidden,true); +}); diff --git a/tests/test_api.py b/tests/test_api.py index a70363f..30b05c6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -378,6 +378,42 @@ class ApiTests(unittest.TestCase): self.client.delete('/api/clear_all') self.assertEqual(self.client.get('/api/get_history').json, []) + def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self): + for vote in ('up', 'down', 1, -1): + self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200) + with self.app.app_context(): + connection=db.connect() + connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)]) + connection.commit(); connection.close() + stats=self.client.get('/api/admin/stats').json + self.assertEqual((stats['good'],stats['bad']),(3,3)) + + def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self): + for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'}, + {'vote':'up','answer':{}}, {'vote':'down','question':[]}): + with self.subTest(feedback=data): + response=self.client.post('/api/feedback',json=data) + self.assertEqual(response.status_code,400) + self.assertFalse(response.json['ok']) + for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001}, + {'content':'help','phone':[]}, {'content':'help','phone':'1'*33}): + with self.subTest(ticket=data): + response=self.client.post('/api/ticket',json=data) + self.assertEqual(response.status_code,400) + self.assertFalse(response.json['ok']) + for path in ('/api/feedback','/api/ticket'): + response=self.client.post(path,data='{bad',content_type='application/json') + self.assertEqual(response.status_code,400) + self.assertFalse(response.json['ok']) + with self.app.app_context(): + connection=db.connect() + for table in ('feedback','tickets'): + self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0) + connection.close() + self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200) + ticket=self.client.get('/api/tickets').json[0] + self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123')) + def test_business_routes_with_initialized_tables_and_legacy_auth(self): with self.app.app_context(): connection = db.connect()