# -*- coding: utf-8 -*- """Targeted, read-only DOM assertions for the archive/AI HTML shell.""" import json import os import sys from pathlib import Path from unittest.mock import patch os.environ.setdefault("QT_SCALE_FACTOR", "1") ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) sys.argv = [sys.argv[0], "--qt-smoke-test"] from PySide6.QtCore import QSize, QTimer from PySide6.QtWidgets import QApplication, QInputDialog from wechat_gui_qt import MainWindow app = QApplication.instance() or QApplication(sys.argv) window = MainWindow() window.setFixedSize(QSize(1630, 920)) window.show() results = {} def evaluate(name: str, page_index: int, script: str, done) -> None: window.show_page(page_index) def run() -> None: window.console_shell.page().runJavaScript(script, finish) def finish(value) -> None: if isinstance(value, str) and value: try: value = json.loads(value) except json.JSONDecodeError: pass results[name] = value done() QTimer.singleShot(650, run) COMMON = r""" const rect = selector => { const el = document.querySelector(selector); if (!el) return null; const box = el.getBoundingClientRect(); return {x:box.x,y:box.y,width:box.width,height:box.height}; }; """ ARCHIVE = """JSON.stringify((() => { try {\n""" + COMMON + r""" return { ok: true, view: STATE.view, title: document.querySelector('.page-head h1')?.textContent, filter: rect('.filter-row'), grid: rect('.archive-grid'), gridColumns: getComputedStyle(document.querySelector('.archive-grid')).gridTemplateColumns, bottom: rect('.archive-bottom'), sessionRows: document.querySelectorAll('[data-action="select-archive"]').length, filters: document.querySelectorAll('[data-action="archive-filter"]').length, searchMaxLength: document.querySelector('#archive-search')?.maxLength, actions: [...document.querySelectorAll('[data-action]')].map(x=>x.dataset.action), toastShown: document.querySelector('#toast')?.classList.contains('show') || false }; } catch (error) { return {ok:false,error:String(error),stack:error.stack}; } })())""" AI = """JSON.stringify((() => { try {\n""" + COMMON + r""" return { ok: true, view: STATE.view, title: document.querySelector('.page-head h1')?.textContent, tabs: rect('.tabs'), top: rect('.ai-top'), topColumns: getComputedStyle(document.querySelector('.ai-top')).gridTemplateColumns, bottom: rect('.ai-bottom'), bottomColumns: getComputedStyle(document.querySelector('.ai-bottom')).gridTemplateColumns, config: rect('.config-strip'), hiddenPanes: document.querySelectorAll('.ai-pane[hidden]').length, promptReadOnly: document.querySelector('#ai-prompt')?.readOnly === true, toolSwitches: document.querySelectorAll('[data-action="toggle-tool"]').length, tabsWithActions: document.querySelectorAll('[data-action="ai-tab"]').length, actions: [...document.querySelectorAll('[data-action]')].map(x=>x.dataset.action), toastShown: document.querySelector('#toast')?.classList.contains('show') || false }; } catch (error) { return {ok:false,error:String(error),stack:error.stack}; } })())""" RUNTIME_ARCHIVE = """JSON.stringify((() => { try {\n""" + COMMON + r""" STATE.demo = false; Object.assign(STATE, { view: 'archive', archiveFilter: '全部', archiveQuery: '', archiveDate: '', selectedArchive: 'archive-1', archives: [{key:'archive-1',name:'真实客户',avatar:'真',preview:'真实归档摘要',time:'16:31',badge:'AI 已回复',count:2}], chat: [{role:'user',time:'16:31:01',text:'真实消息'},{role:'assistant',time:'16:31:02',text:'真实回复'}], memory: {tags:['真实标签'],text:'真实客户记忆',rounds:2}, settle: [{title:'挂号登记',hint:'真实记录',value:'1 次'}], activities: [] }); render('archive'); const oldBridge = window.qtBridge; const calls = []; const stub = { exportArchive:()=>calls.push('exportArchive'), setArchiveFilter:key=>calls.push('setArchiveFilter:'+key), chooseArchiveDate:()=>calls.push('chooseArchiveDate'), selectArchive:key=>calls.push('selectArchive:'+key), refresh:()=>calls.push('refresh'), copyArchive:()=>calls.push('copyArchive') }; window.qtBridge = stub; const stubbed = window.qtBridge === stub; if (stubbed) { ['export-archive','archive-filter','archive-date','select-archive','refresh','copy-archive'] .forEach(action => document.querySelector(`[data-action="${action}"]`)?.click()); } window.qtBridge = oldBridge; return { ok:true, title:document.querySelector('.page-head h1')?.textContent, filter:rect('.filter-row'), grid:rect('.archive-grid'), bottom:rect('.archive-bottom'), gridColumns:getComputedStyle(document.querySelector('.archive-grid')).gridTemplateColumns, searchMaxLength:document.querySelector('#archive-search')?.maxLength, toastActions:document.querySelectorAll('[data-action="toast"]').length, stubbed, calls }; } catch (error) { return {ok:false,error:String(error),stack:error.stack}; } })())""" RUNTIME_AI = """JSON.stringify((() => { try {\n""" + COMMON + r""" STATE.demo = false; STATE.aiOk = true; STATE.aiTab = '基础设置'; STATE.ai = { name:'真实客服', prompt:'由安全模板生成的只读提示词', model:'real-model', backup:'云端策略', context:8, maxTokens:500, temperature:'0.35', replyLengthIndex:1, mcpRounds:5, knowledgeCount:'2', knowledgeConnected:true, syncLabel:'刚刚同步', tools:[true,false,true], toolCount:2, synced:true }; render('ai'); const oldBridge = window.qtBridge; const calls = []; const stub = { saveAi:payload=>calls.push('saveAi:'+payload), testAi:()=>calls.push('testAi'), pickModel:()=>calls.push('pickModel'), pickBackupModel:()=>calls.push('pickBackupModel'), syncKnowledge:()=>calls.push('syncKnowledge'), editKnowledge:()=>calls.push('editKnowledge'), toggleTool:key=>calls.push('toggleTool:'+key), setAiTab:key=>calls.push('setAiTab:'+key) }; window.qtBridge = stub; const stubbed = window.qtBridge === stub; if (stubbed) { ['save-ai','test-ai','pick-model','pick-backup','sync-knowledge','edit-knowledge','toggle-tool','ai-tab'] .forEach(action => document.querySelector(`[data-action="${action}"]`)?.click()); } window.qtBridge = oldBridge; let savedPayload = {}; const savedCall = calls.find(value=>value.startsWith('saveAi:')) || ''; try { savedPayload = JSON.parse(savedCall.slice(7) || '{}'); } catch (_error) {} return { ok:true, title:document.querySelector('.page-head h1')?.textContent, tabs:rect('.tabs'), top:rect('.ai-top'), bottom:rect('.ai-bottom'), config:rect('.config-strip'), topColumns:getComputedStyle(document.querySelector('.ai-top')).gridTemplateColumns, bottomColumns:getComputedStyle(document.querySelector('.ai-bottom')).gridTemplateColumns, hiddenPanes:document.querySelectorAll('.ai-pane[hidden]').length, promptReadOnly:document.querySelector('#ai-prompt')?.readOnly === true, toolSwitches:document.querySelectorAll('[data-action="toggle-tool"]').length, toastActions:document.querySelectorAll('[data-action="toast"]').length, stubbed, calls, savedPayload, savedPayloadHasPrompt:Object.hasOwn(savedPayload,'prompt') }; } catch (error) { return {ok:false,error:String(error),stack:error.stack}; } })())""" def finish() -> None: listing = window.business_page.recent_list first_item = listing.item(0) if listing.count() else None first_key = str(first_item.data(0x0100) or "") if first_item else "" results["archive_selection"] = { "existing": bool(first_key and window._select_archive_session(first_key)), "stale": window._select_archive_session("__missing_archive__"), } with patch.object(QInputDialog, "getText", return_value=("2026-08-18", True)): with patch.object(window.business_page, "_set_archive_date") as set_date: window.console_shell.bridge.chooseArchiveDate() results["archive_date"] = { "called": set_date.call_count == 1, "value": set_date.call_args.args[0] if set_date.call_args else "", } print(json.dumps(results, ensure_ascii=False, indent=2), flush=True) archive = results.get("archive") or {} ai = results.get("ai") or {} expected_archive = ( archive.get("ok") and archive.get("title") == "会话归档" and archive.get("gridColumns") == "333px 605px 437px" and round((archive.get("filter") or {}).get("height", 0)) == 64 and round((archive.get("grid") or {}).get("height", 0)) == 521 and round((archive.get("bottom") or {}).get("height", 0)) == 153 and archive.get("sessionRows") == 5 and archive.get("filters") == 4 and archive.get("searchMaxLength") == 80 and not archive.get("toastShown") and results["archive_selection"]["existing"] and not results["archive_selection"]["stale"] and results["archive_date"] == {"called": True, "value": "2026-08-18"} ) expected_ai = ( ai.get("ok") and ai.get("title") == "AI 设置" and ai.get("topColumns") == "856px 536px" and ai.get("bottomColumns") == "428px 428px 536px" and round((ai.get("tabs") or {}).get("height", 0)) == 58 and round((ai.get("top") or {}).get("height", 0)) == 330 and round((ai.get("bottom") or {}).get("height", 0)) == 244 and round((ai.get("config") or {}).get("height", 0)) == 110 and ai.get("hiddenPanes") == 0 and ai.get("promptReadOnly") and ai.get("toolSwitches") == 3 and ai.get("tabsWithActions") == 3 and not ai.get("toastShown") ) runtime_archive = results.get("runtime_archive") or {} expected_runtime_archive = ( runtime_archive.get("ok") and runtime_archive.get("title") == "会话归档" and runtime_archive.get("gridColumns") == "333px 605px 437px" and round((runtime_archive.get("filter") or {}).get("height", 0)) == 64 and round((runtime_archive.get("grid") or {}).get("height", 0)) == 521 and round((runtime_archive.get("bottom") or {}).get("height", 0)) == 153 and runtime_archive.get("searchMaxLength") == 80 and runtime_archive.get("toastActions") == 0 and runtime_archive.get("stubbed") and runtime_archive.get("calls") == [ "exportArchive", "setArchiveFilter:全部会话", "chooseArchiveDate", "selectArchive:archive-1", "refresh", "copyArchive", ] ) runtime_ai = results.get("runtime_ai") or {} expected_runtime_ai = ( runtime_ai.get("ok") and runtime_ai.get("title") == "AI 设置" and runtime_ai.get("topColumns") == "856px 536px" and runtime_ai.get("bottomColumns") == "428px 428px 536px" and round((runtime_ai.get("tabs") or {}).get("height", 0)) == 58 and round((runtime_ai.get("top") or {}).get("height", 0)) == 330 and round((runtime_ai.get("bottom") or {}).get("height", 0)) == 244 and round((runtime_ai.get("config") or {}).get("height", 0)) == 110 and runtime_ai.get("hiddenPanes") == 0 and runtime_ai.get("promptReadOnly") and runtime_ai.get("toolSwitches") == 3 and runtime_ai.get("toastActions") == 0 and runtime_ai.get("stubbed") and not runtime_ai.get("savedPayloadHasPrompt") and (runtime_ai.get("savedPayload") or {}).get("maxTokens") == 500 and runtime_ai.get("calls") == [ "saveAi:{\"name\":\"真实客服\",\"temperature\":0.35,\"context\":8,\"maxTokens\":500,\"mcpRounds\":5,\"replyLength\":1}", "testAi", "pickModel", "pickBackupModel", "syncKnowledge", "editKnowledge", "toggleTool:0", "setAiTab:基础设置", ] ) app.exit( 0 if expected_archive and expected_ai and expected_runtime_archive and expected_runtime_ai else 1 ) def run_runtime_ai() -> None: evaluate("runtime_ai", 3, RUNTIME_AI, finish) def run_runtime_archive() -> None: evaluate("runtime_archive", 2, RUNTIME_ARCHIVE, run_runtime_ai) def run_ai() -> None: evaluate("ai", 3, AI, run_runtime_archive) def run_archive() -> None: evaluate("archive", 2, ARCHIVE, run_ai) def wait_until_ready() -> None: if window.console_shell.page_ready: run_archive() else: QTimer.singleShot(100, wait_until_ready) QTimer.singleShot(100, wait_until_ready) QTimer.singleShot(20000, lambda: app.exit(2)) raise SystemExit(app.exec())