Files
AgentLab/backend/tests/test_recipe_diff.py
T
ThearchyHelios 2d5e50fa88 Excel 导入改造期 2:配方引擎与确认向导——表从哪一行哪一列开始、哪些是合计、日期怎么补年份,都写进一份可校验的配方;首次导入经人确认,之后每期按配方重放并核对
配方语言(agentlab-recipe/2,新 recipe_types、recipe)
- 封闭、不含正则、保存时静态校验:常量只能从表里的候选文字里选,占位符只能映射成空值,单位从固定词表里取
- 交叉表和列表两种版式;分段各自定位、不依赖先后顺序;合计按标签语义区间核对,关系恒等式、口径不同的声明都写在配方里
- 固定的解析器集合(parsers/2):全角、各类横线、千分位、月日写法宽容归一,规范写法存库、原文另记

执行器(新 xlsx_cells、recipe_engine)
- 两遍读取逐行对账,每个非空格都要有去向(格子账);交叉表逆透视成长表,按统计期补年份并断言覆盖整期
- 合计行改作核对并另存一张表,不和明细混在一列;区域外文字、隐藏行列、合并单元格、空行之后的文字都单独报出
- STRICT 加主键写临时库;按锚点段记溯源,供后续证据下钻用;大表流式读取,扫描在 sheetData 处停止

校验与说明(新 recipe_checks、recipe_notes)
- SQL 核对分通过、不一致、无法核对三种结果,失败分结构、数据质量、需确认三类;结构类直接拒收
- 数据质量类可以写理由接受,接受不改库、只改状态和说明
- 表的说明按核对结果生成、不含数字,经过数字检查;没核对过的不写「已核对」

起草(新 recipe_suggest、recipe_ai)
- 先用规则起草:建议卡片加待确认问题(关系要不要登记、占位符表示什么)
- 规则起草不完整时才提示 AI 兜底:界面先展示要发出的全部文字,用户同意才发;只发结构,数字、隐藏行列、遮罩列不发,公式先脱敏;同意绑定模型和服务方;AI 只写配方,有限次修订,记 token 和费用;截断单独识别
- 无论规则还是 AI 起草,都必须人工确认才能启用

导入流程(新 recipe_imports、recipe_confirm、recipe_diff、api/table_imports)
- 暂存 → 起草 → 回答问题或改配方 → 试运行 → 勾确认项、写接受理由 → 确认并启用;接入期 1 的构建、导入记录、快照和运行固定版本
- 上传新一期按已确认的配方重放,不调用模型,出回执和差异卡,手动启用
- 改配方报破坏性变更(breaking:*)、单位变化、工作表改名的兜底,都要逐项勾选确认

前端导入向导(新 pages/import)
- 原始网格(行列号、去向着色)、建议卡片、配方面板(表单,不露正则)、试运行回执、确认清单、差异卡、AI 起草的同意对话框
2026-10-01 22:00:07 +08:00

899 lines
41 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.
"""差异卡(recipe_diff)的测试。
回执一律手写(结构照 P2-SPEC 9.1 的「结构仿照客流表」夹具,假名、假数),不依赖执行器(WP-2)和夹具
生成器(WP-1):差异卡是纯函数,输入就是回执 dict。WP-7 会再用真夹具把 9.2 的 31 例跑一遍。
本文件还放着两个测试文件共用的回执构造器(flow_doc 等),test_recipe_confirm 从这里导入。
"""
from __future__ import annotations
import copy
import datetime as dt
import json
from pathlib import Path
from typing import Any, Callable
import pytest
from app.data import recipe_diff as D
from app.data.recipe_confirm import ConfirmContext, confirm_items
from app.data.recipe_parsers import match_key, period_residue
FLOW: dict[str, Any] = json.loads(
(Path(__file__).parent / "fixtures" / "recipes" / "flow_recipe.json").read_text(encoding="utf-8"))
SHEET = "客流汇总"
AUG = dt.date(2026, 8, 1)
SEP = dt.date(2026, 9, 1)
OCT = dt.date(2026, 10, 1)
DAILY = ["全日客流(人次)", "分区甲(人次)", "分区乙(人次)"]
DAY = ["7-8", "8-9", "9-10", "10-11", "11-12", "12-13", "13-14", "14-15", "15-16", "16-17", "17-18"]
NIGHT = ["18-19", "19-20", "20-21", "21-22", "22-23", "23-24"]
TOTAL_RAW = ["18-22 时合计", "22-24 时合计", "18-24 时合计"]
TOTAL_CAN = ["18-22时合计", "22-24时合计", "18-24时合计"]
# --------------------------------------------------------------------------
# 回执构造器(两个测试文件共用)
# --------------------------------------------------------------------------
def b2_text(start: dt.date, end: dt.date, *, omit_year: bool = False) -> str:
second = f"{end.month}月{end.day}日" if omit_year else f"{end.year}年{end.month}月{end.day}日"
return f"统计时间范围:{start.year}年{start.month}月{start.day}日至{second}"
def file_name(start: dt.date, days: int) -> str:
end = start + dt.timedelta(days=days - 1)
return f"月报导出_{start.isoformat()}_{end.isoformat()}.xlsx"
def _col(name: str, type_: str, role: str, header: str | None = None, unit: str | None = None) -> dict[str, Any]:
return {"name": name, "type": type_, "header": header, "unit": unit, "role": role}
def _seg(segment: str, raw: list[str], canonical: list[str], rows: list[int]) -> dict[str, Any]:
return {"segment": segment, "raw": list(raw), "canonical": list(canonical), "rows": list(rows)}
def _check(cid: str, kind: str, status: str, *, checked: int = 0, failed: int = 0,
category: str = "info") -> dict[str, Any]:
return {"id": cid, "kind": kind, "title": cid, "status": status, "category": category, "checked": checked,
"failed": failed, "unverifiable": 0, "sql": None, "params": [], "details": [], "cells": [],
"acceptable": False, "reasons": {}}
def flow_doc(start: dt.date = AUG, days: int = 31) -> dict[str, Any]:
"""「导入回执」:{"receipt": 回执, "checks": [...], "period": PeriodOut},按参考配方、参考布局手写。"""
end = start + dt.timedelta(days=days - 1)
period = {"start": start.isoformat(), "end": end.isoformat(), "source": "cells", "cells": [f"{SHEET}!B2"],
"signed_by": None, "texts": {f"{SHEET}!B2": b2_text(start, end)}, "annotated": {}}
tables = [
{"name": "日客流", "sheet": SHEET, "kind": "data", "grain": ["日期"], "rows": days,
"columns": [_col("日期", "TEXT", "axis"), _col("全日客流", "INTEGER", "measure", DAILY[0], "人次"),
_col("分区甲", "INTEGER", "measure", DAILY[1], "人次"),
_col("分区乙", "INTEGER", "measure", DAILY[2], "人次")],
"sources": ["s1/交叉表/日客流"]},
{"name": "时段客流", "sheet": SHEET, "kind": "data", "grain": ["日期", "时段"], "rows": days * 17,
"columns": [_col("日期", "TEXT", "axis"), _col("时段", "TEXT", "dim"), _col("时段类别", "TEXT", "const"),
_col("起始小时", "INTEGER", "derive"), _col("结束小时", "INTEGER", "derive"),
_col("客流", "INTEGER", "value", None, "人次")],
"sources": ["s1/交叉表/日间", "s1/交叉表/夜间"]},
{"name": "时段客流_表内合计", "sheet": SHEET, "kind": "reported_total", "grain": ["日期", "合计项"],
"rows": days * 3,
"columns": [_col("日期", "TEXT", "axis"), _col("合计项", "TEXT", "dim"), _col("起始小时", "INTEGER", "derive"),
_col("结束小时", "INTEGER", "derive"), _col("客流", "INTEGER", "value", None, "人次")],
"sources": ["s1/交叉表/夜间合计"]},
]
receipt = {
"ledger": [{"sheet": SHEET, "nonempty_scan": 20 * days + 3 * days + 47,
"nonempty_read": 20 * days + 3 * days + 47, "roles": {}, "unclaimed": 0}],
"tables": tables,
"period": copy.deepcopy(period),
"axes": [{"block": "交叉表", "row": 4, "first": f"{start.month}月{start.day}日",
"last": f"{end.month}月{end.day}日", "count": days, "form": "text"}],
"placeholders": {"·": days},
"canonicalized": [],
"canonicalized_total": 0,
"labels": [_seg("日客流", DAILY, [match_key(x) for x in DAILY], [5, 6, 7]),
_seg("日间", DAY, DAY, list(range(10, 21))),
_seg("夜间", NIGHT, NIGHT, list(range(22, 28))),
_seg("夜间合计", TOTAL_RAW, TOTAL_CAN, [28, 29, 30])],
"outside_text": [{"sheet": SHEET, "cell": "B3", "text": "客流汇总表", "kind": "text", "period_source": False}],
"hidden": {},
"sheets": {"matched": {"s1": SHEET}, "renamed": {}, "other_visible": [], "skipped_hidden": []},
"derived_form": {"夜间合计": "formula"},
"block_order": {SHEET: ["日客流", "日间", "夜间", "夜间合计"]},
"ignored_columns": {},
"full_calc_on_load": False,
"formula_cells_accepted": 0,
"blank_rows_skipped": 0,
"derived": [],
"lineage": {},
"db_sha256": "0" * 64,
"table_hashes": {},
"timings": {},
}
checks = [
_check("C1", "context_agree", "passed", checked=1),
_check("C2", "filename_period", "passed", checked=1),
_check("K1", "derived_sum", "passed", checked=3 * days, category="structure"),
_check("G1", "formula_refs", "passed", checked=3 * days, category="structure"),
_check("R1", "relation_sum_eq", "passed", checked=days, category="data_quality"),
_check("R2", "relation_not_comparable", "info", checked=days, failed=days),
]
for i, t in enumerate(("日客流", "时段客流", "时段客流_表内合计"), 1):
checks.append(_check(f"N{i}", "row_count", "passed", checked=1, category="structure"))
checks.append(_check(f"P{i}", "pk_unique", "passed", checked=1, category="structure"))
return {"receipt": receipt, "checks": checks, "period": period}
def extraction_of(doc: dict[str, Any], problems: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""回执 → 确认项要的 Extraction dict 形状(回执就是 Extraction 去掉 problems 和 C1、C2)。"""
ex = copy.deepcopy(doc["receipt"])
ex["period"] = copy.deepcopy(doc["period"])
ex["problems"] = list(problems or [])
ex["ok"] = True
return ex
def set_period_cell(doc: dict[str, Any], cell: str, text: str, *, annotated: bool) -> None:
"""把 cell 设成统计期来源;annotated=True 时同时记成区域外文字(kind=text_digits、period_source)。"""
key = f"{SHEET}!{cell}"
for p in (doc["period"], doc["receipt"]["period"]):
if key not in p["cells"]:
p["cells"].append(key)
p["texts"][key] = text
if annotated:
p["annotated"][key] = period_residue(text)
out = [o for o in doc["receipt"]["outside_text"] if o["cell"] != cell]
if annotated:
out.append({"sheet": SHEET, "cell": cell, "text": text, "kind": "text_digits", "period_source": True})
doc["receipt"]["outside_text"] = out
def add_outside(doc: dict[str, Any], cell: str, text: str, kind: str = "text_digits") -> None:
doc["receipt"]["outside_text"].append({"sheet": SHEET, "cell": cell, "text": text, "kind": kind,
"period_source": False})
def set_check(doc: dict[str, Any], cid: str, status: str) -> None:
for c in doc["checks"]:
if c["id"] == cid:
c["status"] = status
def set_labels(doc: dict[str, Any], segment: str, raw: list[str] | None = None,
canonical: list[str] | None = None, rows: list[int] | None = None) -> None:
for s in doc["receipt"]["labels"]:
if s["segment"] == segment:
if raw is not None:
s["raw"] = list(raw)
if canonical is not None:
s["canonical"] = list(canonical)
if rows is not None:
s["rows"] = list(rows)
def kinds(items: list[Any]) -> set[str]:
return {i.kind for i in items}
def sep() -> tuple[dict[str, Any], str]:
return flow_doc(SEP, 30), file_name(SEP, 30)
def run(prev: tuple[dict[str, Any], str] | None, cur: tuple[dict[str, Any], str], **kw: Any) -> list[Any]:
"""默认按参考配方、配方没变(上传新一期)调用;要测别的就显式传。"""
kw.setdefault("recipe", FLOW)
kw.setdefault("recipe_changed", False)
return D.diff_reports(prev[0] if prev else None, cur[0], prev_file_name=prev[1] if prev else None,
cur_file_name=cur[1], **kw)
BASE = (flow_doc(AUG, 31), file_name(AUG, 31))
# --------------------------------------------------------------------------
# 9.2 的 31 例:差异的精确集合、本期类与 diff 确认项的精确集合
# --------------------------------------------------------------------------
def _d00():
return flow_doc(AUG, 31), file_name(AUG, 31)
def _d02():
return flow_doc(OCT, 31), file_name(OCT, 31)
def _d03():
return flow_doc(OCT, 15), file_name(OCT, 15)
def _d03b():
return flow_doc(OCT, 7), file_name(OCT, 7)
def _d12():
doc, name = sep()
text = b2_text(SEP, dt.date(2026, 9, 30), omit_year=True)
doc["period"]["texts"][f"{SHEET}!B2"] = text
doc["receipt"]["period"]["texts"][f"{SHEET}!B2"] = text
return doc, name
def _d14():
# 去掉第 8 行空行:第 8 行以下整体上移一行,标签的行号都变了,顺序没变
doc, name = sep()
for s in doc["receipt"]["labels"]:
s["rows"] = [r - 1 if r > 8 else r for r in s["rows"]]
return doc, name
def _d17():
doc, name = sep()
set_period_cell(doc, "B3", "客流汇总表(2026年9月)", annotated=True)
return doc, name
def _d23():
doc, _ = sep()
set_check(doc, "C2", "info")
return doc, "9月客流.xlsx"
def _d25():
doc, name = sep()
doc["receipt"]["full_calc_on_load"] = True
return doc, name
def _d27():
doc, name = sep()
set_period_cell(doc, "B3", "客流汇总表(2026年9月1日至2026年9月30日)", annotated=True)
return doc, name
def _d05():
doc, name = sep()
doc["receipt"]["block_order"][SHEET] = ["日客流", "夜间", "夜间合计", "日间"]
set_labels(doc, "夜间", rows=list(range(10, 16)))
set_labels(doc, "夜间合计", rows=[16, 17, 18])
set_labels(doc, "日间", rows=list(range(21, 32)))
return doc, name
def _d06():
doc, name = sep()
doc["receipt"]["block_order"][SHEET] = ["日间", "夜间", "夜间合计", "日客流"]
add_outside(doc, "B28", "日客流(人次)", kind="text")
return doc, name
def _d07():
doc, name = sep()
doc["receipt"]["derived_form"]["夜间合计"] = "literal"
doc["checks"] = [c for c in doc["checks"] if c["id"] != "G1"]
return doc, name
def _d10():
doc, name = sep()
doc["receipt"]["axes"][0]["form"] = "date"
return doc, name
def _d11():
doc, name = sep()
set_labels(doc, "日间", raw=[f"{int(a):02d}:00-{int(b):02d}:00" for a, b in (x.split("-") for x in DAY)])
set_labels(doc, "夜间", raw=[f"{int(a):02d}:00-{int(b):02d}:00" for a, b in (x.split("-") for x in NIGHT)])
return doc, name
def _d19():
doc, name = sep()
order = [DAILY[1], DAILY[2], DAILY[0]]
set_labels(doc, "日客流", raw=order, canonical=[match_key(x) for x in order])
return doc, name
def _d22():
doc, name = sep()
set_labels(doc, "夜间合计", raw=["18:00-22:00合计", "22:00-24:00合计", "18:00-24:00合计"])
return doc, name
def _d24():
doc, name = sep()
set_labels(doc, "日间", raw=[x.replace("-", "–") for x in DAY])
set_labels(doc, "夜间", raw=[x.replace("-", "–") for x in NIGHT])
return doc, name
def _d20():
doc, name = sep()
doc["receipt"]["sheets"]["other_visible"] = ["说明"]
return doc, name
def _d28():
doc, name = sep()
add_outside(doc, "B32", "注:9月15日闸机故障,当日客流为估算值")
return doc, name
def _d28b():
doc, name = sep()
set_period_cell(doc, "B32", "注:2026年9月数据为初步统计,待修订", annotated=True)
return doc, name
def _d26():
doc, name = sep()
set_check(doc, "R1", "mismatch")
return doc, name
#: 代号 → (构造, 差异的精确集合, 本期类与 diff 确认项的精确集合;带 * 的按前缀)。P2-SPEC 9.2 的表
DRIFT: dict[str, tuple[Callable[[], tuple[dict[str, Any], str]], set[str], set[str]]] = {
"D01": (sep, {"period", "rows"}, set()),
"D02": (_d02, {"period"}, set()),
"D03": (_d03, {"period", "rows"}, set()),
"D03b": (_d03b, {"period", "rows"}, set()),
"D12": (_d12, {"period", "rows", "context_text"}, set()),
"D14": (_d14, {"period", "rows"}, set()),
"D15": (sep, {"period", "rows"}, set()),
"D17": (_d17, {"period", "rows", "context_source_added"}, set()),
"D23": (_d23, {"period", "rows", "file_name", "checks"}, set()),
"D25": (_d25, {"period", "rows", "full_calc"}, set()),
"D27": (_d27, {"period", "rows", "context_source_added"}, set()),
"D05": (_d05, {"period", "rows", "block_order"}, {"diff:block_order:客流汇总"}),
"D06": (_d06, {"period", "rows", "block_order", "outside_added"},
{"diff:block_order:客流汇总", "diff:outside:客流汇总!B*"}),
"D07": (_d07, {"period", "rows", "checks", "derived_form"}, {"diff:derived_form:夜间合计"}),
"D10": (_d10, {"period", "rows", "axis_form"}, {"diff:axis_form:交叉表"}),
"D11": (_d11, {"period", "rows", "label_writing"}, {"diff:label_writing:日间", "diff:label_writing:夜间"}),
"D19": (_d19, {"period", "rows", "row_order"}, {"diff:row_order:日客流"}),
"D22": (_d22, {"period", "rows", "label_writing"}, {"diff:label_writing:夜间合计"}),
"D24": (_d24, {"period", "rows", "label_writing"}, {"diff:label_writing:日间", "diff:label_writing:夜间"}),
"D20": (_d20, {"period", "rows"}, {"sheet_extra:说明"}),
"D28": (_d28, {"period", "rows", "outside_added"}, {"outside_digits:客流汇总!B32", "diff:outside:客流汇总!B32"}),
"D28b": (_d28b, {"period", "rows", "outside_added"},
{"outside_digits:客流汇总!B32", "diff:outside:客流汇总!B32"}),
"D26": (_d26, {"period", "rows", "checks"}, set()),
}
#: 拒收的 7 例:试运行就拒收了,9.2 不给差异和确认项的期望(diff_kinds=None),这里不跑
REJECTED = {"D04", "D09", "D13", "D16", "D18", "D21", "D08"}
#: 9.1:D00 首次导入时的确认项(精确集合)
D00_CONFIRMS = {
"placeholder:·", "derived:夜间合计", "year_from:交叉表", "relation:R1", "relation:R2",
"unit:日客流.全日客流", "unit:日客流.分区甲", "unit:日客流.分区乙", "unit:时段客流.客流",
"unit:时段客流_表内合计.客流", "mode",
}
def match_ids(actual: set[str], expected: set[str]) -> None:
"""精确集合比对;expected 里以 * 结尾的按前缀匹配(至少命中一个)。"""
exact = {e for e in expected if not e.endswith("*")}
prefixes = [e[:-1] for e in expected if e.endswith("*")]
rest = actual - exact
assert exact <= actual, f"缺:{exact - actual}"
for p in prefixes:
assert any(a.startswith(p) for a in rest), f"没有以 {p} 开头的"
stray = {a for a in rest if not any(a.startswith(p) for p in prefixes)}
assert not stray, f"多出:{stray}"
def test_drift_table_covers_all_31_cases():
assert len(DRIFT) + len(REJECTED) + 1 == 31 # + D00
def test_d00_first_import():
doc, name = _d00()
assert run(None, (doc, name)) == []
items = confirm_items(ConfirmContext(kind="first", recipe=FLOW, extraction=extraction_of(doc),
checks=doc["checks"], diff=[], prev=None, recipe_origin="rules"))
assert {i.id for i in items} == D00_CONFIRMS
assert all(i.required for i in items)
def test_d00_same_file_again_has_no_diff():
assert run(BASE, _d00()) == []
@pytest.mark.parametrize("code", sorted(DRIFT))
def test_drift_case(code):
build, want_kinds, want_confirms = DRIFT[code]
cur = build()
diff = run(BASE, cur, recipe=FLOW)
assert kinds(diff) == want_kinds
items = confirm_items(ConfirmContext(kind="reupload", recipe=FLOW, old_recipe=FLOW,
extraction=extraction_of(cur[0]), checks=cur[0]["checks"], diff=diff,
prev=BASE[0], recipe_origin="rules"))
match_ids({i.id for i in items}, want_confirms)
def test_drift_outside_added_cell_for_d06():
diff = run(BASE, _d06())
assert [d.confirm_id for d in diff if d.kind == "outside_added"] == ["diff:outside:客流汇总!B28"]
# --------------------------------------------------------------------------
# 数字遮盖后的模板
# --------------------------------------------------------------------------
def test_digit_template():
# 先 canon(NFKC 会把全角冒号变成半角),再遮盖
assert D.digit_template("统计时间范围:2026年9月1日至2026年9月30日") == "统计时间范围:{}年{}月{}日至{}年{}月{}日"
assert D.digit_template("统计时间范围:2026年9月1日至9月30日") == "统计时间范围:{}年{}月{}日至{}月{}日"
assert D.digit_template("月报导出_2026-08-01_2026-08-31.xlsx") == "月报导出_{}-{}-{}_{}-{}-{}.xlsx"
# 全角数字先经 canon 变成 ASCII,再遮盖;连续的一段数字只算一个 {}
assert D.digit_template("2026年9月") == "{}年{}月"
assert D.digit_template("12345") == "{}"
assert D.digit_template("第 3 号") != D.digit_template("第 3 号门")
assert D.digit_template(None) == ""
# --------------------------------------------------------------------------
# 各 kind 一正一反
# --------------------------------------------------------------------------
def test_first_import_returns_empty():
assert run(None, sep()) == []
def test_period_and_rows():
diff = run(BASE, sep())
p = next(d for d in diff if d.kind == "period")
assert "2026-08-01 至 2026-08-31" in p.label and "2026-09-01 至 2026-09-30" in p.label
r = next(d for d in diff if d.kind == "rows")
assert "「日客流」31 → 30" in r.label and "「时段客流」527 → 510" in r.label
assert "占位符「·」31 → 30 格" in r.detail
assert not p.requires_confirm and not r.requires_confirm
assert kinds(run(BASE, _d02())) == {"period"} # 行数相同不出 rows
def test_rows_lists_added_and_removed_tables():
cur = sep()
cur[0]["receipt"]["tables"].pop()
cur[0]["receipt"]["tables"].append({"name": "新表", "rows": 3, "columns": []})
r = next(d for d in run(BASE, cur) if d.kind == "rows")
assert "「新表」本期新增,3 行" in r.label and "「时段客流_表内合计」本期没有,上一期 93 行" in r.label
def test_placeholders_set_not_count():
assert "placeholders" not in kinds(run(BASE, sep())) # 31 → 30 只是个数
cur = sep()
cur[0]["receipt"]["placeholders"] = {"·": 30, "-": 2}
item = next(d for d in run(BASE, cur) if d.kind == "placeholders")
assert "新出现「-」" in item.label and not item.requires_confirm
cur[0]["receipt"]["placeholders"] = {"-": 2, "·": 0}
item = next(d for d in run(BASE, cur) if d.kind == "placeholders")
assert "不再出现「·」" in item.label
def test_checks_set_not_count():
assert "checks" not in kinds(run(BASE, sep())) # K1 93 → 90 只是核对数
item = next(d for d in run(BASE, _d23()) if d.kind == "checks")
assert "C2 通过 → 提示" in item.label
item = next(d for d in run(BASE, _d07()) if d.kind == "checks")
assert "G1 本期不再出现" in item.label
def test_file_name_template():
assert "file_name" not in kinds(run(BASE, sep()))
item = next(d for d in run(BASE, _d23()) if d.kind == "file_name")
assert "9月客流.xlsx" in item.label and not item.requires_confirm
# 目录不同不算,只看文件名本身
assert "file_name" not in kinds(D.diff_reports(BASE[0], sep()[0], prev_file_name="a/" + BASE[1],
cur_file_name="b\\" + sep()[1], recipe=FLOW,
recipe_changed=False))
assert "file_name" not in kinds(D.diff_reports(BASE[0], sep()[0], prev_file_name=None, cur_file_name="x.xlsx",
recipe=FLOW, recipe_changed=False))
def test_full_calc_whenever_set_this_period():
# 规格 7.6:「这一期设了打开时重算」就出 info,不论上一期设没设
item = next(d for d in run(BASE, _d25()) if d.kind == "full_calc")
assert not item.requires_confirm and "上一期没有设置" in item.detail
prev = (copy.deepcopy(BASE[0]), BASE[1])
prev[0]["receipt"]["full_calc_on_load"] = True
item = next(d for d in run(prev, _d25()) if d.kind == "full_calc")
assert "上一期也设置了" in item.detail
assert "full_calc" not in kinds(run(BASE, sep()))
assert "full_calc" not in kinds(run(prev, sep())) # 上一期设了、本期没设:不出
def test_context_text():
item = next(d for d in run(BASE, _d12()) if d.kind == "context_text")
assert "客流汇总!B2" in item.label and not item.requires_confirm
cur = _d12()
set_check(cur[0], "C1", "mismatch")
assert "context_text" not in kinds(run(BASE, cur))
assert "context_text" not in kinds(run(BASE, sep())) # 只有日期变了
def test_context_source_added_d17_d27():
for build in (_d17, _d27):
diff = run(BASE, build())
item = next(d for d in diff if d.kind == "context_source_added")
assert not item.requires_confirm and "客流汇总!B3" in item.label
assert not any(d.kind.startswith("outside_") for d in diff)
def test_context_source_added_needs_same_residue():
# 附加文字不是上一期的标题:按区域外文字改了处理,要确认
cur = sep()
set_period_cell(cur[0], "B3", "客流汇总初步表(2026年9月)", annotated=True)
diff = run(BASE, cur)
assert "context_source_added" not in kinds(diff)
item = next(d for d in diff if d.kind == "outside_changed")
assert item.confirm_id == "diff:outside:客流汇总!B3"
def test_context_source_added_needs_c1_passed():
cur = _d17()
set_check(cur[0], "C1", "mismatch")
diff = run(BASE, cur)
assert "context_source_added" not in kinds(diff)
assert "outside_changed" in kinds(diff)
def test_context_source_added_pure_period_cell():
# B3 换成只是在说统计期的格:不在区域外文字里,C1 一致就是 context_source_added
cur = sep()
set_period_cell(cur[0], "B3", "2026年9月1日至2026年9月30日", annotated=False)
diff = run(BASE, cur)
assert "context_source_added" in kinds(diff) and "outside_removed" not in kinds(diff)
set_check(cur[0], "C1", "mismatch")
diff = run(BASE, cur)
assert [d.confirm_id for d in diff if d.kind == "outside_changed"] == ["diff:outside:客流汇总!B3"]
def _list_doc(order: list[str], headers: dict[str, str] | None = None) -> dict[str, Any]:
"""一张列表表(销售),lineage 里每列一段向下的溯源,列号按 order 排。"""
doc = flow_doc(SEP, 30)
headers = headers or {}
cols = ["地区", "产品", "销量", "金额"]
doc["receipt"]["tables"].append({
"name": "销售", "sheet": "销售", "kind": "data", "grain": ["地区", "产品"], "rows": 4,
"columns": [_col(c, "TEXT" if c in ("地区", "产品") else "INTEGER", "text" if c in ("地区", "产品") else "measure",
headers.get(c, c)) for c in cols]})
doc["receipt"]["lineage"]["销售"] = {c: [[1, "销售", f"{chr(67 + order.index(c))}6", 4, "down"]] for c in cols}
return doc
LIST_RECIPE: dict[str, Any] = {
"recipe_format": "agentlab-recipe/2",
"sheets": [{"id": "s2", "match": {"name": "销售"}, "blocks": [{
"id": "列表1", "layout": "list", "table": "销售",
"columns": [{"header": "地区", "name": "地区", "type": "TEXT"}, {"header": "产品", "name": "产品", "type": "TEXT"},
{"header": "销量", "name": "销量", "type": "INTEGER"}, {"header": "金额", "name": "金额", "type": "INTEGER"}],
}]}],
"tables": [{"name": "销售", "grain": ["地区", "产品"]}],
}
def test_column_order():
prev = (_list_doc(["地区", "产品", "销量", "金额"]), "a.xlsx")
cur = (_list_doc(["产品", "地区", "销量", "金额"]), "a.xlsx")
item = next(d for d in run(prev, cur, recipe=LIST_RECIPE) if d.kind == "column_order")
assert "「销售」" in item.label and not item.requires_confirm
assert "column_order" not in kinds(run(prev, (_list_doc(["地区", "产品", "销量", "金额"]), "a.xlsx"),
recipe=LIST_RECIPE))
# 交叉表的表不比列顺序(它们的列来自配方,不是工作表上的位置)
assert "column_order" not in kinds(run(BASE, _d19()))
def test_header_writing_keyed_by_block():
prev = (_list_doc(["地区", "产品", "销量", "金额"]), "a.xlsx")
cur = (_list_doc(["地区", "产品", "销量", "金额"], {"金额": "金 额", "地区": "地区 "}), "a.xlsx")
item = next(d for d in run(prev, cur, recipe=LIST_RECIPE) if d.kind == "header_writing")
assert item.requires_confirm and item.confirm_id == "diff:header_writing:列表1"
assert "「金额」→「金 额」" in item.label
assert "header_writing" not in kinds(run(prev, prev, recipe=LIST_RECIPE))
# 配方里找不到写入这张表的列表块(传错了配方):报错,不拿表名凑一个键
with pytest.raises(ValueError, match="列表块"):
run(prev, cur, recipe=FLOW)
# 逐项比较时不给配方:报错
with pytest.raises(ValueError, match="本期配方"):
run(prev, cur, recipe=None)
def test_label_writing_and_row_order():
diff = run(BASE, _d11())
lw = {d.confirm_id for d in diff if d.kind == "label_writing"}
assert lw == {"diff:label_writing:日间", "diff:label_writing:夜间"}
assert "row_order" not in kinds(diff)
item = next(d for d in diff if d.confirm_id == "diff:label_writing:日间")
assert "「7-8」→「07:00-08:00」" in item.label
diff = run(BASE, _d19())
assert "label_writing" not in kinds(diff)
assert [d.confirm_id for d in diff if d.kind == "row_order"] == ["diff:row_order:日客流"]
def test_label_writing_compares_raw_exactly():
# en dash canon 之后与「-」相同,仍要报(E3);只是行号变了不报
assert "label_writing" in kinds(run(BASE, _d24()))
assert "label_writing" not in kinds(run(BASE, _d14()))
def test_canon_values():
prev = (_list_doc(["地区", "产品", "销量", "金额"]), "a.xlsx")
prev[0]["receipt"]["canonicalized"] = [{"table": "销售", "column": "地区", "raw": "华 东", "canonical": "华 东",
"count": 1, "first_cell": "销售!C7"}]
cur = copy.deepcopy(prev)
assert "canon_values" not in kinds(run(prev, cur, recipe=LIST_RECIPE))
cur[0]["receipt"]["canonicalized"].append({"table": "销售", "column": "地区", "raw": "华北 ",
"canonical": "华北", "count": 2, "first_cell": "销售!C9"})
item = next(d for d in run(prev, cur, recipe=LIST_RECIPE) if d.kind == "canon_values")
assert item.confirm_id == "diff:canon:销售.地区" and item.requires_confirm
# 标签的写法变化不进 canonicalized,所以 D11 没有 canon_values
assert "canon_values" not in kinds(run(BASE, _d11()))
def test_block_order():
diff = run(BASE, _d05())
assert [d.confirm_id for d in diff if d.kind == "block_order"] == ["diff:block_order:客流汇总"]
assert "block_order" not in kinds(run(BASE, _d14()))
def test_block_order_follows_renamed_sheet():
cur = _d05()
rec = cur[0]["receipt"]
rec["block_order"] = {"汇总": rec["block_order"][SHEET]}
rec["sheets"]["matched"] = {"s1": "汇总"}
item = next(d for d in run(BASE, cur) if d.kind == "block_order")
assert item.confirm_id == "diff:block_order:汇总"
def test_derived_and_axis_form():
item = next(d for d in run(BASE, _d07()) if d.kind == "derived_form")
assert item.confirm_id == "diff:derived_form:夜间合计" and "由公式改为写死的数" in item.label
item = next(d for d in run(BASE, _d10()) if d.kind == "axis_form")
assert item.confirm_id == "diff:axis_form:交叉表" and "由文字改为日期格" in item.label
assert not kinds(run(BASE, sep())) & {"derived_form", "axis_form"}
def test_outside_added_removed_changed():
cur = _d28()
item = next(d for d in run(BASE, cur) if d.kind == "outside_added")
assert item.confirm_id == "diff:outside:客流汇总!B32" and "注:9月15日" in item.label
# 下一期同一句、只是日期变了:模板相同,不算改
nxt = flow_doc(OCT, 31)
add_outside(nxt, "B32", "注:10月3日闸机故障,当日客流为估算值")
assert not kinds(run(cur, (nxt, file_name(OCT, 31)))) & {"outside_added", "outside_removed", "outside_changed"}
# 写法变了:要确认
add_outside(nxt, "B33", "x")
nxt["receipt"]["outside_text"][-2]["text"] = "注:10月3日闸机检修,当日客流为估算值"
diff = run(cur, (nxt, file_name(OCT, 31)))
assert {d.confirm_id for d in diff if d.kind == "outside_changed"} == {"diff:outside:客流汇总!B32"}
assert {d.confirm_id for d in diff if d.kind == "outside_added"} == {"diff:outside:客流汇总!B33"}
# 删掉了
item = next(d for d in run(cur, sep()) if d.kind == "outside_removed")
assert item.confirm_id == "diff:outside:客流汇总!B32"
def test_outside_pairs_full_text_first():
# 只是位置变了:info,不要求确认
prev = (copy.deepcopy(BASE[0]), BASE[1])
add_outside(prev[0], "B32", "注:本表为测试")
cur = sep()
add_outside(cur[0], "B33", "注:本表为测试")
diff = run(prev, cur)
moved = [d for d in diff if d.kind == "outside_moved"]
assert len(moved) == 1 and not moved[0].requires_confirm
assert not kinds(diff) & {"outside_added", "outside_removed", "outside_changed"}
def test_outside_full_coordinates_accepted():
# OutsideText.cell 已经带工作表名时,确认项 id 一样
cur = sep()
cur[0]["receipt"]["outside_text"].append({"sheet": SHEET, "cell": f"{SHEET}!B32", "text": "注:9月15日",
"kind": "text_digits", "period_source": False})
item = next(d for d in run(BASE, cur) if d.kind == "outside_added")
assert item.confirm_id == "diff:outside:客流汇总!B32"
def test_annotated_cell_monthly_same_sentence():
# 上一期、本期同一格都是带附加文字的统计期格、句式相同:不出差异
prev = sep()
set_period_cell(prev[0], "B32", "注:2026年9月数据为初步统计,待修订", annotated=True)
cur = (flow_doc(OCT, 31), file_name(OCT, 31))
set_period_cell(cur[0], "B32", "注:2026年10月数据为初步统计,待修订", annotated=True)
diff = run(prev, cur)
assert not kinds(diff) & {"outside_added", "outside_removed", "outside_changed", "context_text"}
set_period_cell(cur[0], "B32", "注:2026年10月数据为正式统计", annotated=True)
assert "outside_changed" in kinds(run(prev, cur))
def test_annotated_cell_other_digits_changed():
# AU-3:两期都是带附加文字的统计期格,统计期以外的数字变了(只含3个分区 → 只含2个分区)要出 outside_changed
prev = sep()
set_period_cell(prev[0], "B32", "注:2026年9月数据只含3个分区", annotated=True)
cur = (flow_doc(OCT, 31), file_name(OCT, 31))
set_period_cell(cur[0], "B32", "注:2026年10月数据只含2个分区", annotated=True)
diff = run(prev, cur)
assert [d.confirm_id for d in diff if d.kind == "outside_changed"] == ["diff:outside:客流汇总!B32"]
set_period_cell(cur[0], "B32", "注:2026年10月数据只含3个分区", annotated=True)
assert "outside_changed" not in kinds(run(prev, cur))
def test_same_period_sentence():
assert D.same_period_sentence("注:2026年9月数据为初步统计", "注:2026年10月数据为初步统计")
assert not D.same_period_sentence("注:2026年9月数据只含3个分区", "注:2026年10月数据只含2个分区")
# 认不出统计期(没有或有两段)就不算同一句
assert not D.same_period_sentence("注:数据只含3个分区", "注:数据只含4个分区")
assert not D.same_period_sentence("2026年9月与2026年8月对比", "2026年10月与2026年9月对比")
def test_pure_period_cell_becomes_annotated():
cur = sep()
set_period_cell(cur[0], "B2", b2_text(SEP, dt.date(2026, 9, 30)) + "(初步)", annotated=True)
diff = run(BASE, cur)
assert [d.confirm_id for d in diff if d.kind == "outside_changed"] == ["diff:outside:客流汇总!B2"]
assert "context_text" not in kinds(diff)
def test_ignored_columns():
prev = (copy.deepcopy(BASE[0]), BASE[1])
prev[0]["receipt"]["ignored_columns"] = {"列表1": ["备注"]}
cur = sep()
cur[0]["receipt"]["ignored_columns"] = {"列表1": ["备 注"]}
assert "ignored_columns" not in kinds(run(prev, cur)) # 只是空白
cur[0]["receipt"]["ignored_columns"] = {"列表1": ["备注", "经办人"]}
item = next(d for d in run(prev, cur) if d.kind == "ignored_columns")
assert item.confirm_id == "diff:ignored:列表1" and "「经办人」" in item.label
def test_hidden():
prev = (copy.deepcopy(BASE[0]), BASE[1])
prev[0]["receipt"]["hidden"] = {SHEET: {"rows": [4], "cols": [], "policy_rows": "include",
"policy_cols": "reject_if_any"}}
cur = sep()
cur[0]["receipt"]["hidden"] = {SHEET: {"rows": [4], "cols": [], "policy_rows": "include",
"policy_cols": "reject_if_any"}}
assert "hidden" not in kinds(run(prev, cur))
cur[0]["receipt"]["hidden"][SHEET]["rows"] = [4, 9]
item = next(d for d in run(prev, cur) if d.kind == "hidden")
assert item.confirm_id == "diff:hidden:客流汇总" and "第 4、9 行" in item.label
# exclude 下被排除的行变了同样要确认(会改变导入的数据)
for doc in (prev[0], cur[0]):
doc["receipt"]["hidden"][SHEET]["policy_rows"] = "exclude"
assert "hidden" in kinds(run(prev, cur))
# 隐藏列(include)
cur[0]["receipt"]["hidden"][SHEET].update(rows=[4], policy_cols="include", cols=[3])
prev[0]["receipt"]["hidden"][SHEET].update(policy_cols="include", cols=[])
item = next(d for d in run(prev, cur) if d.kind == "hidden")
assert "C 列" in item.label
def test_limited_when_prev_is_simple_import():
# 期 1 的 TableBuild.report:没有 ledger,只有表和行数
prev = ({"receipt": {"tables": [{"name": "客流汇总", "rows": 29, "columns": []}]}, "checks": [], "period": None},
"客流.xlsx")
diff = run(prev, _d28())
assert kinds(diff) == {"period", "rows"}
assert all(not d.requires_confirm for d in diff)
def test_limited_when_recipe_changed():
assert kinds(run(BASE, _d11(), recipe_changed=True)) == {"period", "rows"}
prev = (dict(BASE[0], recipe_sha256="a" * 64), BASE[1])
cur = _d11()
assert kinds(run(prev, (dict(cur[0], recipe_sha256="b" * 64), cur[1]), recipe_changed=None)) == {"period", "rows"}
assert "label_writing" in kinds(run(prev, (dict(cur[0], recipe_sha256="a" * 64), cur[1]), recipe_changed=None))
def test_recipe_sha_from_manifest_shape():
# 导入清单(7.7)把配方哈希放在 recipe.sha256:直接拿清单当 prev 也认得
manifest = dict(copy.deepcopy(BASE[0]), recipe={"id": "r1", "sha256": "a" * 64, "origin": "rules"})
cur = _d11()
changed = dict(cur[0], recipe={"sha256": "b" * 64})
assert kinds(run((manifest, BASE[1]), (changed, cur[1]), recipe_changed=None)) == {"period", "rows"}
same = dict(cur[0], recipe_sha256="a" * 64)
assert "label_writing" in kinds(run((manifest, BASE[1]), (same, cur[1]), recipe_changed=None))
def test_recipe_changed_undecidable_raises():
# 没传 recipe_changed、两边又没都带哈希:不默认「没变」去跨配方逐项比
manifest = dict(copy.deepcopy(BASE[0]), recipe={"sha256": "a" * 64})
with pytest.raises(ValueError, match="recipe_changed"):
run((manifest, BASE[1]), _d11(), recipe_changed=None)
with pytest.raises(ValueError, match="recipe_changed"):
run(BASE, _d11(), recipe_changed=None)
# 传了、两边哈希也都在,但互相矛盾
cur = _d11()
with pytest.raises(ValueError, match="不一致"):
run((manifest, BASE[1]), (dict(cur[0], recipe_sha256="b" * 64), cur[1]), recipe_changed=False)
with pytest.raises(ValueError, match="不一致"):
run((manifest, BASE[1]), (dict(cur[0], recipe_sha256="a" * 64), cur[1]), recipe_changed=True)
# 上一期是简单导入:只给 rows、period,用不着判断配方
simple = ({"receipt": {"tables": [{"name": "客流汇总", "rows": 29, "columns": []}]}, "checks": [], "period": None},
"客流.xlsx")
assert kinds(run(simple, _d11(), recipe=None, recipe_changed=None)) == {"period", "rows"}
@pytest.mark.parametrize("key", D.RECEIPT_KEYS)
@pytest.mark.parametrize("side", ["prev", "cur"])
def test_missing_receipt_key_raises(side, key):
# 早先缺键按空处理:prev 少了 axes 配 D10、少了 block_order 配 D05……需确认的项就静默消失了
prev, cur = copy.deepcopy(BASE[0]), _d10()[0]
del (prev if side == "prev" else cur)["receipt"][key]
with pytest.raises(D.ReceiptIncomplete) as err:
run((prev, BASE[1]), (cur, "a.xlsx"))
assert err.value.side == side and err.value.missing == [key]
@pytest.mark.parametrize("side", ["prev", "cur"])
def test_missing_checks_or_receipt_raises(side):
prev, cur = copy.deepcopy(BASE[0]), sep()[0]
del (prev if side == "prev" else cur)["checks"]
with pytest.raises(D.ReceiptIncomplete, match="checks"):
run((prev, BASE[1]), (cur, "a.xlsx"))
prev, cur = copy.deepcopy(BASE[0]), sep()[0]
del (prev if side == "prev" else cur)["receipt"]
with pytest.raises(D.ReceiptIncomplete, match="receipt"):
run((prev, BASE[1]), (cur, "a.xlsx"))
# 只给行数和统计期时(简单导入)回执里也得有 tables
simple = {"receipt": {"warnings": []}, "checks": [], "period": None}
with pytest.raises(D.ReceiptIncomplete, match="tables"):
run((simple, "a.xlsx"), sep())
def test_truncated_canonicalized_raises():
# TrialOut.receipt 的 canonicalized 只有前 50 条:拿它当 cur 会漏掉新写法
cur = sep()
cur[0]["receipt"]["canonicalized_total"] = 51
cur[0]["receipt"]["canonicalized"] = [{"table": "销售", "column": "地区", "raw": f"华{i} ", "canonical": f"华{i}",
"count": 1, "first_cell": "销售!C7"} for i in range(50)]
with pytest.raises(D.ReceiptIncomplete, match="canonicalized_total"):
run(BASE, cur)
cur[0]["receipt"]["canonicalized_total"] = 50
assert "canon_values" in kinds(run(BASE, cur))
def test_partial_receipt_raises():
cur = sep()
cur[0]["receipt"]["partial"] = True
with pytest.raises(D.ReceiptIncomplete, match="partial"):
run(BASE, cur)
def test_requires_confirm_first():
cur = _d06()
diff = run(BASE, cur)
flags = [d.requires_confirm for d in diff]
assert flags == sorted(flags, reverse=True) and flags[0] and not flags[-1]
for d in diff:
assert d.kind in D.DIFF_KINDS
assert (d.confirm_id is not None) == d.requires_confirm
if d.requires_confirm:
assert d.confirm_id.startswith(D.CONFIRM_PREFIX[d.kind])
def test_accepts_dataclass_inputs():
from app.data.recipe_types import ColumnOut, Extraction, PeriodOut, TableOut
def doc(rows: int, start: str) -> dict[str, Any]:
cols = [ColumnOut("日期", "TEXT", role="axis")]
ex = Extraction(ok=True, tables=[TableOut("日客流", SHEET, "data", cols, ["日期"], rows)],
period=PeriodOut(start, start, "cells"), ledger=[])
return {"receipt": ex, "checks": [], "period": ex.period}
diff = D.diff_reports(doc(31, "2026-08-01"), doc(30, "2026-09-01"), prev_file_name="a", cur_file_name="a",
recipe=FLOW, recipe_changed=False)
assert kinds(diff) == {"period", "rows"}
def test_period_missing_sides():
cur = sep()
cur[0]["period"] = None
cur[0]["receipt"]["period"] = None
item = next(d for d in run(BASE, cur) if d.kind == "period")
assert "本期未能确定" in item.label