mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:42:00 +00:00
2d5e50fa88
配方语言(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 起草的同意对话框
899 lines
41 KiB
Python
899 lines
41 KiB
Python
"""差异卡(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
|