Files
kefu/wechat_rpa/tmp/test_archive_ai_shell.py
T
2026-08-19 17:35:59 +08:00

293 lines
12 KiB
Python

# -*- 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())