Files
AgentLab/backend/tests/test_analysis_pages.py
T
ThearchyHelios cb2fdd860f 证据接入按 W1-a、W1-c 的实际接口对齐:复算就是真的 app.analysis.recompute——load 交查询快照工件的原内容、load_result 交上一层结论(只认封存范围内交回过的查询、台账记过的结论,取不回照实说取不回哪一份),方法升没升版按结论里的 method 和现在的 contract.METHODS 比(升了版、复算成立的写结果同不同,现在的方法复算不了的写明原因),去掉为算子没落地准备的那一套;关键数的代入式按真的具名的数写(乘积拆解补上,波动基线按变化率比的按百分比写,负数加括号)。口径卡指标上的可比性结论(不进台账、只从卡上引用)点得开:证据面板的指标步骤带它整条出处链(判定 → 方法 → 计算 → 输入 → 复算 → 封存,封存按卡上记的 sha256 认),报告导出收在一个可展开的块里,可验证报告随附带的卡一起附上、④ 按已核对的卡认它、数字详情里展示各项检查和服务端复算;裁判的指标摘录、写作目录照卡上记的判定语写。现查结果分清按运行当天现算的界、一列一个值都没有、表的范围;可验证报告里分析工具的 tool.end、分析卡的 analysis.end 和结论同一个认法才带内容,输入的角色写叫法。测试改用真算的结论(run 交的输入照分析入口的写法,复算交工件原内容),下钻、总数闭合、方法升版、可比性端到端都过真的复算
2026-10-21 13:49:09 +08:00

203 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分析结论在两份自包含页面里的渲染(第 1 波 W1-b):报告导出(report_export/runtime.js)点开判定语、分析范围里的编号;
可验证报告(bundle/verifier.js)点开判定语看分析结论一节、正文后面的分析范围。
浏览器里的完整检查(check-report-export、check-bundle)第 2 波由编排者跑;这里在 node 里用一个最小的 DOM 替身把两份脚本
真正跑一遍,挡住渲染分析结论那几步时的脚本错误(引用了不存在的文案、函数),并核对面板里写出来的字。
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
import app.bundle
import app.report_export
from app.main import app as asgi
from tests.test_analysis_bundle import bundle_of, card_bundle
from tests.test_analysis_evidence_api import LAST_WEEK, SCOPE, TEXT, THIS_WEEK, comparability_card, sealed_run, seg_id
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的脚本")
RUNTIME = Path(app.report_export.__file__).parent / "runtime.js"
VERIFIER = Path(app.bundle.__file__).parent / "verifier.js"
#: 最小的 DOM 替身:元素、文字、片段,getElementById 按 id 建元素;textContent 递归拼接
DOM = r"""
const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
class Node_ {
constructor() { this.children = []; this.parent = null }
append(...kids) {
for (const k of kids) {
const n = typeof k === 'string' ? new Text_(k) : k
if (n instanceof Frag_) { for (const c of n.children) { c.parent = this; this.children.push(c) } n.children = [] }
else { n.parent = this; this.children.push(n) }
}
}
replaceChildren(...kids) { this.children = []; this.append(...kids) }
get textContent() { return this.children.map((c) => c.textContent).join('') }
set textContent(v) { this.children = [new Text_(String(v))] }
walk(f) { f(this); for (const c of this.children) c.walk(f) }
}
class Text_ extends Node_ {
constructor(t) { super(); this.text = t }
get textContent() { return this.text }
set textContent(v) { this.text = String(v) }
}
class Frag_ extends Node_ {}
class El_ extends Node_ {
constructor(tag) {
super(); this.tag = tag; this.attrs = {}; this.dataset = {}; this.style = {}; this.hidden = false; this.listeners = {}
this.className = ''; const cls = new Set(); this.classList = { add: (c) => cls.add(c), remove: (c) => cls.delete(c), contains: (c) => cls.has(c) }
}
setAttribute(k, v) { this.attrs[k] = String(v); if (k.startsWith('data-')) this.dataset[camel(k.slice(5))] = String(v) }
getAttribute(k) { return this.attrs[k] ?? null }
removeAttribute(k) { delete this.attrs[k] }
addEventListener(t, f) { (this.listeners[t] ||= []).push(f) }
focus() {} scrollIntoView() {}
getBoundingClientRect() { return { top: 0, bottom: 0 } }
querySelector() { return new El_('span') }
closest() { return this }
}
globalThis.Node = Node_
const ids = {}
globalThis.window = { scrollBy() {} }
globalThis.document = {
readyState: 'complete', activeElement: null, documentElement: new El_('html'),
getElementById(id) { return ids[id] ||= new El_('div') },
createElement(tag) { return new El_(tag) }, createTextNode(t) { return new Text_(t) },
createDocumentFragment() { return new Frag_() }, addEventListener() {}, querySelectorAll() { return [] },
}
const fs = require('fs')
const input = JSON.parse(fs.readFileSync(0, 'utf8'))
const find = (root, pred) => { const out = []; root.walk((n) => { if (n instanceof El_ && pred(n)) out.push(n) }); return out }
"""
RUN_RUNTIME = DOM + r"""
document.getElementById('report-data').textContent = input.data
new Function(fs.readFileSync(process.argv[1], 'utf8'))()
const out = {}
for (const seg of input.segs) {
const el = new El_('button')
for (const [k, v] of Object.entries(seg)) el.setAttribute(`data-${k}`, v)
el.textContent = seg.text
for (const f of document.getElementById('report').listeners.click || []) f({ target: el })
out[seg.seg] = { title: document.getElementById('detail-title').textContent,
body: document.getElementById('detail-body').textContent,
steps: find(document.getElementById('detail-body'), (n) => n.attrs['data-step']).map((n) => n.attrs['data-step']) }
}
process.stdout.write(JSON.stringify(out))
"""
RUN_VERIFIER = DOM + r"""
document.getElementById('bundle-data').textContent = input.data
document.getElementById('seal-hash').textContent = input.seal
new Function(fs.readFileSync(process.argv[1], 'utf8'))()
setTimeout(() => {
const reports = document.getElementById('reports')
const out = { scope: find(reports, (n) => 'data-scope' in n.attrs).map((n) => n.textContent), opened: {} }
for (const btn of find(reports, (n) => n.attrs['data-num'])) {
for (const f of btn.listeners.click || []) f()
out.opened[btn.attrs['data-num']] = document.getElementById('detail-body').textContent
}
out.verified = document.documentElement.attrs['data-verified'] ?? null
process.stdout.write(JSON.stringify(out))
}, 200)
"""
def run_node(script: str, path: Path, payload: dict[str, Any]) -> Any:
done = subprocess.run([NODE, "-e", script, str(path)], input=json.dumps(payload), capture_output=True, text=True,
timeout=120)
assert done.returncode == 0, done.stderr[-3000:]
return json.loads(done.stdout)
async def test_the_export_page_opens_the_analysis_steps_and_the_scope_numbers():
made = await sealed_run(TEXT, doc_extra=SCOPE)
async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as client:
html = (await client.get(f"/api/runs/{made['run']}/report.html")).text
data = re.search(r'<script type="application/json" id="report-data">(.*?)</script>', html, re.S).group(1)
phrase = seg_id(made["doc"], "a:A1")
cell = seg_id(made["doc"], "v:A1.r0.delta")
segs = [{"seg": phrase, "kind": "analysis", "state": "deterministic", "text": made["result"]["phrase"]},
{"seg": cell, "kind": "number", "state": "deterministic", "text": "-120"},
{"seg": "scope:A1", "kind": "analysis", "state": "deterministic", "text": "A1"}]
# 现查结果的三种写法:按运行当天现算的界、一列一个值都没有、表的范围(结论里的 probes 原样交给页面)
parsed = json.loads(data)
method = next(s for s in parsed["chains"][parsed["refs"][phrase]] if s["step"] == "analysis_method")
method["probes"] = [
{"source": "shop", "expr": "date('now', '-7 days')", "value": "2026-10-14", "basis": "live",
"at": "2026-10-21T09:00:00"},
{"source": "shop", "table": "orders", "column": "refund_date", "min": None, "max": None, "basis": "live",
"at": "2026-10-21T09:00:00"},
{"source": "shop", "table": "orders", "column": "order_date", "min": "2025-01-01", "max": "2026-10-20",
"basis": "profile", "at": "2026-10-21T09:00:00"}]
out = run_node(RUN_RUNTIME, RUNTIME, {"data": json.dumps(parsed, ensure_ascii=False), "segs": segs})
opened = out[phrase]
assert opened["steps"] == ["analysis", "analysis_method", "analysis_calc", "analysis_input", "query",
"analysis_recompute", "analysis_seal"]
assert opened["title"].startswith("判定「销售额下降")
body = opened["body"]
for text in ("判定标准一段占总变化的一半以上算主要来源", "Agent 写的假设华东拖累", "实际用的阈值", "「主要来源」的占比",
"华东的占比 = 华东的变化 ÷ 总变化 = -120 ÷ (-194) = 61.9%", "结果表(列出 4 行,共 4 行)",
"复算一致:按封存的输入重新计算,结论逐项相同", "封存范围内的台账记过这份分析结论",
"按运行当天现算的界 date('now', '-7 days') = 2026-10-14(现查 · 2026-10-21T09:00:00)",
"orders.refund_date:这一列一个值都没有(现查", "orders.order_date:2025-01-01 至 2026-10-20(剖析"):
assert text in body, text
assert "结果表第 1 行「delta」" in out[cell]["body"]
assert out["scope:A1"]["steps"] == opened["steps"]
async def test_the_bundle_page_shows_the_analysis_detail_and_the_scope():
bundle, _, _ = bundle_of(doc_extra=SCOPE)
out = run_node(RUN_VERIFIER, VERIFIER, {"data": json.dumps(bundle), "seal": bundle["meta"]["manifest_hash"]})
assert out["verified"] == ""
[scope] = out["scope"]
assert scope.startswith("分析范围") and "现象A1:销售额下降" in scope and "拆过A1(按地区):主要来自华东" in scope
assert "没拆渠道" in scope and "这次做了 2 次差异检验" in scope
phrase = next(text for text in out["opened"].values() if "出处分析结论 A1 的判定语" in text)
assert "核对结果✓ 通过" in phrase and "分析结论里的值「销售额下降 194(-8.7%)" in phrase
assert "判定语销售额下降 194(-8.7%)" in phrase and "方法与版本decompose.additive@1" in phrase
assert "输入数据 · Q1" in phrase # 输入的角色写叫法,不写 data
assert "复算一致:按封存的输入重新计算,结论逐项相同(导出时服务端按封存的输入复算的结论;本页只核哈希" in phrase
value = next(text for text in out["opened"].values() if "具名的数「top_share」" in text)
assert "引用的值0.618556701031" in value and "正文显示61.9%(与按显示规则渲染的结果一致)" in value
scope_item = next(text for key, text in out["opened"].items() if key.endswith("|scope:A1"))
assert "核对结果✓ 通过" in scope_item and "正文显示" not in scope_item
async def test_the_export_page_opens_the_comparability_on_a_metric():
"""口径卡指标上的可比性:指标那一步里写判定,点得开那份可比性结论(同分析结论的几步,收在一个可展开的块里)。"""
card, result = await comparability_card()
made = await sealed_run("环比 [[m:wow]]。", card=card, queries=[LAST_WEEK, THIS_WEEK])
async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as client:
html = (await client.get(f"/api/runs/{made['run']}/report.html")).text
data = re.search(r'<script type="application/json" id="report-data">(.*?)</script>', html, re.S).group(1)
wow = seg_id(made["doc"], "m:wow")
out = run_node(RUN_RUNTIME, RUNTIME, {"data": data, "segs": [
{"seg": wow, "kind": "number", "state": "deterministic", "text": "-0.0874"}]})
opened = out[wow]
assert opened["steps"][:1] == ["metric"]
assert opened["steps"][opened["steps"].index("analysis"):] [:3] == ["analysis", "analysis_method", "analysis_calc"]
for text in ("两期可比性", "展开这份可比性结论", "可比性检查", "口径卡执行时自动做的可比性检查", result["phrase"],
"同口径", "复算一致:按封存的输入重新计算,结论逐项相同", "记在口径卡的指标集里,随卡封存(不进台账)"):
assert text in opened["body"], text
def test_the_bundle_page_shows_the_comparability_in_the_metric_detail():
bundle, _, _, _ = card_bundle()
out = run_node(RUN_VERIFIER, VERIFIER, {"data": json.dumps(bundle), "seal": bundle["meta"]["manifest_hash"]})
assert out["verified"] == ""
[detail] = out["opened"].values()
for text in ("两期可比性", "判定两期可比", "判定标准只差日期", "方法与版本comparable@1", "各项检查", "同口径",
"复算一致:按封存的输入重新计算,结论逐项相同(导出时服务端按封存的输入复算的结论"):
assert text in detail, text