Files
AgentLab/backend/tests/test_recipe_confirm.py
T
ThearchyHelios 2cdbf9a733 Excel 导入改造期 3:网格交互、自助修复、按期累积——每月的报表可以累积成一份跨月的表,漂移了点修复按钮就能接着导入,网格上框一块就能写进配方,版本页可以回滚、移除一期、清除原件
按期累积(新 recipe_accumulate;table_versions 扩展)
- 按统计期出的交叉表报表首次导入时默认「按期累积」,由人确认;一期一个构建,两期及以上物化成一份新的 SQLite 快照,不用视图
- 统计期完全相同要勾「替换该期」,部分重叠拒收,中间有空缺要确认;物化前逐期核对库哈希和行号,并集上另加行数守恒、主键唯一、日期落在所属统计期三条核对
- 配方演进分三档:新增列(早期各期为空值)、退役的列和表保留旧值(只在这次才退役时要勾)、不兼容变化只能显式「重新开始累积」
- 快照 id 在累积时含目标配方和模式;同样的输入物化两次库哈希相同,同一文件换名再传直接复用
- 说明按期合并,任何一期没核对过就不写「已核对」;各期维度取值不同、退役列、合计表都有提示,经过数字检查

修复按钮(新 recipe_fixes;执行器给问题带结构化参数)
- 加入标签、移除标签、声明为合计行、分段标题改名、编辑恒等式成员、按锚点忽略行/列/区域外文字、声明占位符、声明隐藏行处理、工作表改名
- 服务端算补丁,先预览新旧配方对照、再应用,可以撤销;补丁封闭,不引入正则、自由文本常量和写死坐标;修复进确认清单
- 只改分段标题、新标题仍含原来选的词时沿用已确认的常量,不算破坏性变更;修复后只查改动过的段的认领,没改的关系交给试运行核对

框选转锚点(新 recipe_select)
- 网格上框选(鼠标、键盘、区域输入),按八种用途换算成按文字认的锚点,立刻干跑比对;配方里不存坐标,泛化不了时列出原因
- 整张表挪位后同一配方照样认得;框进合计行、夹着小计、表下说明都有明确的处理

确认与对照(recipe_confirm、recipe_diff、新 recipe_compare)
- 改配方后确认清单只列有变化的项(同时按本期数据比较),不用每修一次重勾十来项;新旧配方对照界面;按规则重新起草并把名字对齐到现行配方
- 改配方后已体现在配方里的回答保留,矛盾的列出来请人重答
- 「排除的行」写进回执和导入清单:隐藏行、跳过的空行、按配方忽略的行、停止后的文字行、只核对不另存的合计行

版本页(新 source_versions、api/source_versions;前端 VersionsDialog)
- 快照与导入记录列表、启用旧版本(可写理由)、查看清单、移除一期、作废接受、清除原件;写操作带界面看到的当前版本,并发改动回 409,不静默丢期
- 除当前版本和被运行引用的版本外,再保留最近 3 个版本供回滚;遮罩列丢失要明确确认

期 1、期 2 遗留
- 重传撞上被改过的构建文件:能用本次上传恢复就隔离旧文件再恢复,否则回 409,不再是 500
- 跳过空行的列表不再把表下隔一行的说明读成数据;不像说明的单格行要人确认;受影响的配方构建换 id
- 版本存储加进程级文件锁:第二个进程拿不到锁时不清理、不回收,写操作回 503(部署本来就是单进程)
2026-10-02 07:38:44 +08:00

779 lines
42 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_confirm)的测试:7.5 每行一正一反、AI 类、破坏性变更、切换、本期类。
回执和配方都是手写的(假名、假数),构造器在 test_recipe_diff 里;不依赖执行器、核对、静态校验(各包的桩
都不需要:confirm_items 只读回执和配方)。
"""
from __future__ import annotations
import copy
from typing import Any
import pytest
from app.data.recipe_confirm import (
EXTRACTION_KEYS, ConfirmContext, breaking_changes, confirm_items, recipe_differs, switch_changes, table_changes,
)
from app.data.recipe_diff import ReceiptIncomplete, diff_reports
from app.data.recipe_types import DiffItem, Recipe
from app.data.recipe_parsers import match_key, period_residue
from tests.test_recipe_diff import (
AUG, BASE, D00_CONFIRMS, FLOW, SEP, SHEET, add_outside, extraction_of, file_name, flow_doc, sep, set_period_cell,
)
LIST: dict[str, Any] = {
"recipe_format": "agentlab-recipe/2",
"sheets": [{"id": "s1", "match": {"name": "销售"}, "blocks": [{
"id": "列表1", "layout": "list", "table": "销售",
"columns": [{"header": "地区", "name": "地区", "type": "TEXT"},
{"header": "产品", "name": "产品", "type": "TEXT"},
{"header": "日期", "name": "日期", "type": "DATE"},
{"header": "销量(件)", "name": "销量", "type": "INTEGER"},
{"header": "金额(元)", "name": "金额", "type": "REAL"}],
"rows": {"total_row": {"label_column": "地区", "pick": "合计", "keep_as": "销售_表内合计"}},
}]}],
"tables": [{"name": "销售", "grain": ["地区", "产品"], "units": {"销量": "件", "金额": "元"}},
{"name": "销售_表内合计", "grain": ["合计项"], "kind": "reported_total",
"units": {"销量": "件", "金额": "元"}}],
}
def flow(**patch: Any) -> dict[str, Any]:
r = copy.deepcopy(FLOW)
for path, value in patch.items():
cur = r
keys = path.split("__")
for k in keys[:-1]:
cur = cur[int(k)] if k.isdigit() else cur[k]
last = keys[-1]
cur[int(last) if last.isdigit() else last] = value
return r
def lst(**patch: Any) -> dict[str, Any]:
r = copy.deepcopy(LIST)
block = r["sheets"][0]["blocks"][0]
for k, v in patch.items():
block[k] = v
return r
def list_ex(**extra: Any) -> dict[str, Any]:
ex = {"ok": True, "problems": [], "tables": [], "placeholders": {}, "labels": [], "outside_text": [],
"sheets": {"matched": {"s1": "销售"}, "renamed": {}, "other_visible": [], "skipped_hidden": []},
"derived": [{"kind": "column_sum", "segment": "列表1", "sheet": "销售", "cell": "F12"}],
"axes": [], "hidden": {}, "ignored_columns": {}, "formula_cells_accepted": 0, "blank_rows_skipped": 0,
"period": None, "lineage": {}}
ex.update(extra)
return ex
def ids(ctx: ConfirmContext) -> set[str]:
return {i.id for i in confirm_items(ctx)}
def first(recipe: dict[str, Any], ex: dict[str, Any] | None = None, **kw: Any) -> ConfirmContext:
kw.setdefault("checks", [])
return ConfirmContext(kind=kw.pop("kind", "first"), recipe=recipe,
extraction=ex if ex is not None else extraction_of(BASE[0]), **kw)
def label(ctx: ConfirmContext, item_id: str) -> str:
return next(i.label for i in confirm_items(ctx) if i.id == item_id)
# --------------------------------------------------------------------------
# 配方类:每行一正一反
# --------------------------------------------------------------------------
def test_reference_recipe_first_import_exact():
items = confirm_items(first(FLOW))
assert {i.id for i in items} == D00_CONFIRMS
got = {i.id: i for i in items}
assert got["placeholder:·"].label == "「·」存为空值(表示无数据,本期 31 格)"
assert got["derived:夜间合计"].label == "第 28–30 行改作核对,原值另存表「时段客流_表内合计」"
assert got["relation:R1"].label == "每期核对「全日客流 = 分区甲 + 分区乙」(表「日客流」)"
assert got["unit:日客流.全日客流"].label == "「全日客流(人次)」→ 表「日客流」的列「全日客流」,单位 人次"
assert got["mode"].label == "导入模式:每期替换"
assert all(i.required and i.source == "recipe" for i in items)
def test_placeholder_counts_and_absence():
r = flow()
r["sheets"][0]["blocks"][0]["values"]["placeholders"] = []
assert not any(i.startswith("placeholder:") for i in ids(first(r)))
r["sheets"][0]["blocks"][0]["values"]["placeholders"] = [{"text": "·"}, {"text": "-", "meaning": "不适用"}]
ctx = first(r)
assert label(ctx, "placeholder:-") == "「-」存为空值(表示不适用,本期 0 格)" # 配置了就出,个数可以是 0
@pytest.mark.parametrize("field,value,item", [
("blank", "null", "blank_null:交叉表"),
("text_number", "parse_thousands", "text_number:交叉表"),
("formula", "accept_cached", "formula_cached:交叉表"),
])
def test_crosstab_value_relaxations(field, value, item):
assert item not in ids(first(FLOW))
r = flow()
r["sheets"][0]["blocks"][0]["values"][field] = value
assert item in ids(first(r))
def test_formula_cached_list_only_when_formulas_seen():
assert "formula_cached:列表1" not in ids(first(LIST, list_ex()))
ctx = first(LIST, list_ex(formula_cells_accepted=3))
assert label(ctx, "formula_cached:列表1") == "「列表1」中的公式格按文件里保存的值导入(本期 3 格)"
assert "formula_cached:列表1" not in ids(first(lst(values={"formula": "reject"}), list_ex(formula_cells_accepted=3)))
def test_merged_fill():
assert "merged_fill:列表1" not in ids(first(LIST, list_ex()))
assert "merged_fill:列表1" in ids(first(lst(merged_data="fill"), list_ex()))
def test_text_number_list():
assert "text_number:列表1" in ids(first(lst(values={"text_number": "parse_thousands"}), list_ex()))
def test_checks_relaxed():
assert not any(i.startswith("checks_relaxed:") for i in ids(first(FLOW)))
r = flow()
r["sheets"][0]["blocks"][0]["axis"]["checks"] = ["contiguous"]
assert label(first(r), "checks_relaxed:交叉表") == "「交叉表」不检查日期「恰好覆盖统计期」"
r["sheets"][0]["blocks"][0]["axis"]["checks"] = []
assert "「逐日连续」、「恰好覆盖统计期」" in label(first(r), "checks_relaxed:交叉表")
def test_cross_check_off_uses_actual_sheet_name():
assert not any(i.startswith("cross_check_off:") for i in ids(first(FLOW)))
r = flow()
r["sheets"][0]["context"][0]["cross_check"] = "none"
assert "cross_check_off:客流汇总" in ids(first(r))
ex = extraction_of(BASE[0])
ex["sheets"]["matched"] = {"s1": "汇总"}
assert "cross_check_off:汇总" in ids(first(r, ex))
def test_ignored_columns_only_when_something_ignored():
assert "ignored_columns:列表1" not in ids(first(lst(extra_columns="ignore"), list_ex()))
ex = list_ex(ignored_columns={"列表1": ["备注", "经办人"]})
assert label(first(lst(extra_columns="ignore"), ex), "ignored_columns:列表1") == "「列表1」不导入这些列:「备注」「经办人」"
assert "ignored_columns:列表1" not in ids(first(LIST, ex))
def test_blank_skip():
assert "blank_skip:列表1" not in ids(first(LIST, list_ex()))
r = lst(rows={"blank_rows": "skip"})
assert label(first(r, list_ex(blank_rows_skipped=2)), "blank_skip:列表1") == "「列表1」跳过数据中间的空行(本期 2 行)"
# 配置了就出,本期 0 行也出:首期恰好没有空行时不出,之后几期真跳过了行、配方又没变,就没人确认过了
assert label(first(r, list_ex(blank_rows_skipped=0)), "blank_skip:列表1") == "「列表1」跳过数据中间的空行(本期 0 行)"
def test_total_row_and_derived():
ctx = first(LIST, list_ex())
assert label(ctx, "total_row:列表1") == "「列表1」的合计行(第 12 行)改作核对,原值另存表「销售_表内合计」"
detail = next(i.detail for i in confirm_items(ctx) if i.id == "total_row:列表1")
assert detail == "按明细核对的列:销量、金额;其余列合计行里的内容不核对" # SE-10
assert "total_row:列表1" not in ids(first(lst(rows={}), list_ex()))
r = flow()
r["sheets"][0]["blocks"][0]["segments"][3]["keep_as"] = None
r["tables"].pop()
assert label(first(r), "derived:夜间合计").endswith("改作核对,原值不另存")
r["sheets"][0]["blocks"][0]["segments"].pop()
r["relations"] = []
assert "derived:夜间合计" not in ids(first(r))
def test_year_from():
assert "year_from:交叉表" in ids(first(FLOW))
ex = extraction_of(BASE[0])
ex["axes"][0]["form"] = "date" # 日期格自带年份
assert "year_from:交叉表" not in ids(first(FLOW, ex))
r = flow()
r["sheets"][0]["blocks"][0]["axis"]["year_from"] = None
assert "year_from:交叉表" not in ids(first(r))
def test_relations_and_dismissed():
r = flow()
r["relations"] = [{"id": "R1", "kind": "dismissed", "claims": "F1", "reason": "巧合"}]
facts = {"facts": [{"id": "F1", "kind": "sum_eq", "sheet": SHEET, "text": "第 5 行 = 第 6 行 + 第 7 行", "detail": {}}]}
got = ids(first(r, facts=facts))
assert "dismissed:F1" in got and not any(i.startswith("relation:") for i in got)
assert label(first(r, facts=facts), "dismissed:F1") == "不登记「第 5 行 = 第 6 行 + 第 7 行」(理由:巧合)"
assert label(first(r), "dismissed:F1") == "不登记系统发现的关系 F1(理由:巧合)"
assert "relation:R2" in ids(first(FLOW))
assert "口径不同" in label(first(FLOW), "relation:R2")
def test_units_each_column():
r = flow()
for t in r["tables"]:
t["units"] = {}
assert not any(i.startswith("unit:") for i in ids(first(r)))
list_ids = ids(first(LIST, list_ex()))
assert {"unit:销售.销量", "unit:销售.金额", "unit:销售_表内合计.销量", "unit:销售_表内合计.金额"} <= list_ids
def test_hidden_policy():
assert not any(i.startswith("hidden:") for i in ids(first(FLOW)))
r = flow()
r["sheets"][0]["hidden"] = {"rows": "exclude", "cols": "include"}
ex = extraction_of(BASE[0])
ex["hidden"] = {SHEET: {"rows": [4, 9], "cols": [3], "policy_rows": "exclude", "policy_cols": "include"}}
assert label(first(r, ex), "hidden:客流汇总") == "工作表「客流汇总」的隐藏行:排除(第 4、9 行);隐藏列:包含(C 列)"
r["sheets"][0]["hidden"] = {"rows": "include"}
assert "本期没有隐藏行" in label(first(r), "hidden:客流汇总")
def test_recipe_class_only_when_recipe_differs():
ex = extraction_of(sep()[0])
# 同一份配方(一份写全默认值、一份省略):canonical 相同,配方类全不出
explicit = Recipe.model_validate(FLOW).model_dump(mode="json")
compact = Recipe.model_validate(FLOW).model_dump(mode="json", exclude_defaults=True)
assert not recipe_differs("reupload", explicit, compact)
assert ids(ConfirmContext(kind="reupload", recipe=explicit, old_recipe=compact, extraction=ex, checks=[], diff=[],
prev=BASE[0])) == set()
# 改了配方:期 3 起只列签名有变化的配方类项(P3-SPEC 6.2),没变的已在现行配方提交时确认过
r = flow()
r["sheets"][0]["blocks"][0]["values"]["blank"] = "null"
got = ids(ConfirmContext(kind="redraft", recipe=r, old_recipe=FLOW, extraction=ex, checks=[], diff=[],
prev=BASE[0]))
assert got == {"blank_null:交叉表"}
# 采用按规则重新起草的配方:按首次导入列全部,另出 redraft_adopted
got = ids(ConfirmContext(kind="redraft", recipe=r, old_recipe=FLOW, extraction=ex, checks=[], diff=[],
prev=BASE[0], recipe_origin="rules_redraft"))
assert D00_CONFIRMS | {"blank_null:交叉表", "redraft_adopted"} <= got
# 首次、切换、没有现行配方都算不同
assert recipe_differs("first", FLOW, FLOW) and recipe_differs("switch", FLOW, None)
assert recipe_differs("reupload", FLOW, None)
# --------------------------------------------------------------------------
# AI 类
# --------------------------------------------------------------------------
def test_ai_origin_items():
r = flow()
r["sheets"][0]["blocks"][0]["segments"][0]["measures"]["全日客流(人次)"] = "全天客流"
r["tables"][0]["units"] = {"全天客流": "人次", "分区甲": "人次", "分区乙": "人次"}
r["relations"][0]["total"] = "全天客流"
r["relations"][1]["b"]["value"] = "全天客流"
got = ids(first(r, recipe_origin="ai"))
assert {"ai_origin", "rename:日客流.全天客流", "columns:日客流", "columns:时段客流", "columns:时段客流_表内合计"} <= got
assert not any(i.startswith("rename:日客流.分区") for i in got) # 按标签推出来的名字不算改名
assert label(first(r, recipe_origin="ai"), "rename:日客流.全天客流") == "原标签「全日客流(人次)」改名为列「全天客流」"
# 每列带类型和单位(SE-10)
assert label(first(r, recipe_origin="ai"), "columns:时段客流") == (
"表「时段客流」的列:日期(日期)、时段(文字)、时段类别(文字)、起始小时(整数)、结束小时(整数)、客流(整数,人次)")
assert "ai_origin" in ids(first(r, recipe_origin="mixed"))
assert not {i for i in ids(first(r, recipe_origin="rules")) if i.startswith(("ai_", "rename:", "columns:"))}
assert not {i for i in ids(first(r, recipe_origin="manual")) if i.startswith(("ai_", "rename:", "columns:"))}
def test_ai_items_not_repeated_on_reupload():
# 现行配方出自 AI:每月重传配方没变,不再要求 AI 类确认
got = ids(ConfirmContext(kind="reupload", recipe=FLOW, old_recipe=FLOW, extraction=extraction_of(sep()[0]),
checks=[], diff=[], prev=BASE[0], recipe_origin="ai"))
assert "ai_origin" not in got
def test_ai_rename_list_columns():
r = lst()
r["sheets"][0]["blocks"][0]["columns"][4]["name"] = "销售额"
r["tables"][0]["units"] = {"销量": "件", "销售额": "元"}
r["tables"][1]["units"] = {"销量": "件", "销售额": "元"}
got = ids(first(r, list_ex(), recipe_origin="ai"))
assert "rename:销售.销售额" in got
assert "rename:销售.金额" not in got and "rename:销售.销量" not in got # 「销量(件)」→ 销量 不算改名
# --------------------------------------------------------------------------
# 破坏性变更
# --------------------------------------------------------------------------
def test_breaking_changes_scope():
new = flow()
new["sheets"][0]["blocks"][0]["values"]["type"] = "REAL" # 类型
new["tables"][1]["units"] = {"客流": "人"} # 单位
new["tables"][0]["grain"] = ["日期", "全日客流"] # 主键
new["tables"][2]["kind"] = "data" # 种类
new["sheets"][0]["blocks"][0]["values"]["placeholders"] = [{"text": "·", "meaning": "不适用"}] # 占位符含义
out = breaking_changes(FLOW, new)
assert "列「全日客流」类型 INTEGER → REAL" in out["日客流"]
assert "列「客流」的单位 人次 → 人" in out["时段客流"]
assert "主键 日期 → 日期、全日客流" in out["日客流"]
assert "表的种类 原表合计 → 明细数据" in out["时段客流_表内合计"]
assert "值列的占位符「·」含义 无数据 → 不适用" in out["时段客流"]
assert breaking_changes(FLOW, FLOW) == {}
def test_breaking_changes_const_pick():
"""分段标题改了写法、常量只能另选候选词(D09:「日间分时段客流」的候选词只有「日间分」):列名、类型都没变,
这一列的值却整列变了。必须进 breaking:<表> 让人确认,不能静默换值。"""
new = flow()
seg = new["sheets"][0]["blocks"][0]["segments"][1]
seg["locate"]["title"] = "日间分时段客流(人次)"
seg["const"]["时段类别"]["pick"] = "日间分"
out = breaking_changes(FLOW, new)
assert out == {"时段客流": [f"分段「{seg['id']}」的列「时段类别」取值「日间」→「日间分」"]}
assert [c.kind for c in table_changes(FLOW, new)] == ["const_value"]
ex = extraction_of(sep()[0])
got = [i.id for i in confirm_items(ConfirmContext(kind="redraft", recipe=new, old_recipe=FLOW, extraction=ex,
checks=[], diff=[], prev=BASE[0]))]
assert "breaking:时段客流" in got
# 只改了标题、pick 不变:不算
same = flow()
same["sheets"][0]["blocks"][0]["segments"][1]["locate"]["title"] = "日间时段客流"
assert breaking_changes(FLOW, same) == {}
def test_ai_columns_label_shows_text_type_of_a_numeric_column():
"""SE-10:AI 把列表的金额列声明成 TEXT 能过静态校验:columns:<表> 的文案里看得见「文字」。"""
r = copy.deepcopy(LIST)
col = next(c for c in r["sheets"][0]["blocks"][0]["columns"] if c["name"] == "金额")
col["type"] = "TEXT"
text = label(first(r, list_ex(), recipe_origin="ai"), "columns:销售")
assert "金额(文字,元)" in text and "销量(整数,件)" in text
def test_dismissed_reason_marked_as_possibly_ai_written():
"""SE-10:AI(或 AI 起草后改过的)配方里,dismissed 的理由标明可能出自 AI;规则起草的不标。"""
r = flow()
r["relations"][0] = {"id": "R1", "kind": "dismissed", "claims": "F1", "reason": "巧合,无需核对"}
for origin, marked in (("ai", True), ("mixed", True), ("rules", False), ("manual", False)):
text = next(i.label for i in confirm_items(first(r, recipe_origin=origin)) if i.id == "dismissed:F1")
assert ("可能由 AI 填写" in text) is marked, (origin, text)
def test_mask_lost_when_switch_or_redraft_drops_a_masked_column():
"""AU-6:源上遮罩的列在新结构里没有同名列(不区分大小写)→ 必勾 mask_lost:<列>;旧结构里本来就没有的不报。"""
old_cache = {"tables": {"销售": {"columns": [{"name": "地区", "type": "TEXT"}, {"name": "金额_万元", "type": "REAL"}]}}}
ctx = first(LIST, list_ex(), kind="switch", old_schema_cache=old_cache, mask_columns=["金额_万元", "不存在的列"])
items = {i.id: i for i in confirm_items(ctx)}
assert "mask_lost:金额_万元" in items and items["mask_lost:金额_万元"].source == "switch"
assert "mask_lost:不存在的列" not in items
# 新结构里有同名列(大小写不同也算):不报
ctx = first(LIST, list_ex(), kind="switch", old_schema_cache=old_cache, mask_columns=["金额", "地区"])
assert not {i for i in ids(ctx) if i.startswith("mask_lost:")}
# 改配方把列改了名
new = copy.deepcopy(LIST)
next(c for c in new["sheets"][0]["blocks"][0]["columns"] if c["name"] == "金额")["name"] = "销售额"
new["tables"][0]["units"] = {"销量": "件", "销售额": "元"}
ex = list_ex()
ctx = ConfirmContext(kind="redraft", recipe=new, old_recipe=LIST, extraction=ex, checks=[], diff=[],
prev={"receipt": {"tables": []}}, mask_columns=["金额"])
assert "mask_lost:金额" in ids(ctx)
def test_breaking_changes_column_source_swapped():
"""AU-2:交叉表两列的来源标签对调、列表两列的表头对调:列名类型都没变,内容整列换了,要进 breaking。"""
new = flow()
m = new["sheets"][0]["blocks"][0]["segments"][0]["measures"]
m["分区甲(人次)"], m["分区乙(人次)"] = "分区乙", "分区甲"
out = breaking_changes(FLOW, new)
assert "列「分区甲」的来源「分区甲(人次)」→「分区乙(人次)」" in out["日客流"]
assert "列「分区乙」的来源「分区乙(人次)」→「分区甲(人次)」" in out["日客流"]
ex = extraction_of(sep()[0])
got = [i.id for i in confirm_items(ConfirmContext(kind="redraft", recipe=new, old_recipe=FLOW, extraction=ex,
checks=[], diff=[], prev=BASE[0]))]
assert "breaking:日客流" in got
lst2 = copy.deepcopy(LIST)
cols = lst2["sheets"][0]["blocks"][0]["columns"]
cols[0]["name"], cols[1]["name"] = cols[1]["name"], cols[0]["name"]
kinds = [c.kind for c in table_changes(LIST, lst2)]
assert kinds.count("source") == 2
# 只是写法(空白、全角)不同:match_key 相同,不算
same = flow()
seg = same["sheets"][0]["blocks"][0]["segments"][0]
seg["labels"]["expect"] = [x.replace("(", "(").replace(")", ")") for x in seg["labels"]["expect"]]
seg["measures"] = {k.replace("(", "(").replace(")", ")"): v for k, v in seg["measures"].items()}
assert not [c for c in table_changes(FLOW, same) if c.kind == "source"]
def test_breaking_changes_const_pick_with_segment_id_changed():
"""AU-2:分段 id 跟着 pick 一起改(把新文件的规则草稿粘进来):按 (表, 列) 比全部取值,照样报 const_value。"""
new = flow()
seg = new["sheets"][0]["blocks"][0]["segments"][1]
old_id = seg["id"]
seg["id"] = old_id + "分段"
seg["locate"]["title"] = "日间分时段客流(人次)"
seg["const"]["时段类别"]["pick"] = "日间分"
for other in new["sheets"][0]["blocks"][0]["segments"]:
if (other.get("locate") or {}).get("segment") == old_id:
other["locate"]["segment"] = seg["id"]
changes = [c for c in table_changes(FLOW, new) if c.kind == "const_value"]
assert len(changes) == 1 and "日间分" in changes[0].message and "日间" in changes[0].message
def test_breaking_changes_text_store():
"""AU-2:列表文字列的存法(规范写法 / 原文)显式改了要报;缺省与显式写成同一个生效值不报。"""
base = copy.deepcopy(LIST)
base["tables"][0]["grain"] = ["地区"] # 产品不在主键里:缺省按原文存
raw_explicit = copy.deepcopy(base)
raw_explicit["sheets"][0]["blocks"][0]["columns"][1]["store"] = "raw"
assert not [c for c in table_changes(base, raw_explicit) if c.kind == "store"]
canon_ = copy.deepcopy(base)
canon_["sheets"][0]["blocks"][0]["columns"][1]["store"] = "canonical"
ch = [c for c in table_changes(base, canon_) if c.kind == "store"]
assert len(ch) == 1 and ch[0].message == "列「产品」的文字存法 原文 → 规范写法"
def test_breaking_changes_removed_and_added():
new = flow()
seg = new["sheets"][0]["blocks"][0]["segments"][0]
seg["labels"]["expect"] = ["全日客流(人次)", "分区甲(人次)", "分区丙(人次)"]
seg["measures"] = {"全日客流(人次)": "全日客流", "分区甲(人次)": "分区甲", "分区丙(人次)": "分区丙"}
new["sheets"][0]["blocks"][0]["segments"].pop() # 夜间合计没了,表内合计表随之消失
out = breaking_changes(FLOW, new)
assert out["日客流"] == ["删除或改名了列「分区乙」"] # 新增的「分区丙」不算
assert out["时段客流_表内合计"] == ["表「时段客流_表内合计」不再产出(删除或改名)"]
# 反过来:表内合计表是新增的,不算;分区丙被删了,照报
assert breaking_changes(new, FLOW) == {"日客流": ["删除或改名了列「分区丙」"]}
def test_breaking_changes_text_date():
new = lst()
new["sheets"][0]["blocks"][0]["columns"][2]["type"] = "TEXT"
out = breaking_changes(LIST, new)
assert out == {"销售": ["列「日期」的写法 DATE → TEXT(日期存为 YYYY-MM-DD 文本)"]}
assert [c.kind for c in table_changes(LIST, new)] == ["type"]
def test_breaking_and_unit_changed_items():
new = flow()
new["tables"][0]["units"] = {"全日客流": "人", "分区甲": "人次", "分区乙": "人次"}
ex = extraction_of(sep()[0])
items = confirm_items(ConfirmContext(kind="redraft", recipe=new, old_recipe=FLOW, extraction=ex, checks=[], diff=[],
prev=BASE[0]))
assert items[0].id == "unit_changed:日客流.全日客流" # 排最前
assert "人次 → 人" in items[0].label
assert not any(i.id.startswith("breaking:") for i in items) # 只变了单位:不重复出 breaking
new["sheets"][0]["blocks"][0]["values"]["type"] = "REAL"
got = [i.id for i in confirm_items(ConfirmContext(kind="redraft", recipe=new, old_recipe=FLOW, extraction=ex,
checks=[], diff=[], prev=BASE[0]))]
assert got[0] == "unit_changed:日客流.全日客流"
assert {"breaking:日客流", "breaking:时段客流", "breaking:时段客流_表内合计"} <= set(got)
# 首次导入没有现行配方,不报破坏性变更
assert not any(i.startswith(("breaking:", "unit_changed:")) for i in ids(first(new)))
def test_switch_from_simple():
cache = {"tables": {
"客流汇总": {"columns": [{"name": "项目", "type": "TEXT"}]},
"日客流": {"columns": [{"name": "日期", "type": "TEXT"}, {"name": "全日客流", "type": "TEXT"},
{"name": "备注", "type": "TEXT"}, {"name": "分区甲", "type": "BIGINT"}]},
}}
lines = switch_changes(cache, FLOW)
assert lines == ["表「客流汇总」将消失", "表「日客流」:列「全日客流」类型 TEXT → INTEGER、列「备注」将消失"]
items = confirm_items(ConfirmContext(kind="switch", recipe=FLOW, old_schema_cache=cache,
extraction=extraction_of(BASE[0]), checks=[], prev=None))
sw = next(i for i in items if i.id == "switch_from_simple")
assert sw.source == "switch" and "表「客流汇总」将消失" in sw.label
assert D00_CONFIRMS <= {i.id for i in items} # 切换时配方类照出
assert not any(i.id.startswith("breaking:") for i in items)
assert "switch_from_simple" not in ids(first(FLOW))
keep = confirm_items(ConfirmContext(kind="switch", recipe=FLOW, old_schema_cache={"tables": {}},
extraction=extraction_of(BASE[0]), checks=[]))
assert next(i for i in keep if i.id == "switch_from_simple").label.endswith("原有的表和列都保留")
# --------------------------------------------------------------------------
# 本期类
# --------------------------------------------------------------------------
def reupload(cur_doc: dict[str, Any], prev_doc: dict[str, Any] | None = BASE[0], **kw: Any) -> ConfirmContext:
kw.setdefault("diff", [])
return ConfirmContext(kind="reupload", recipe=FLOW, old_recipe=FLOW, extraction=kw.pop("ex", extraction_of(cur_doc)),
checks=cur_doc["checks"], prev=prev_doc, **kw)
def test_outside_digits_plain_text_with_digits_every_period():
cur, _ = sep()
add_outside(cur, "B32", "注:9月15日闸机故障,当日客流为估算值")
item = next(i for i in confirm_items(reupload(cur)) if i.id == "outside_digits:客流汇总!B32")
assert item.source == "outside" and "注:9月15日" in item.label
# 下一期同一格同一句(只是日期变了):不是统计期来源,仍要确认
nxt = flow_doc(dt_oct(), 31)
add_outside(nxt, "B32", "注:10月3日闸机故障,当日客流为估算值")
assert "outside_digits:客流汇总!B32" in ids(reupload(nxt, cur))
# 纯文字不出
assert not any(i.startswith("outside_digits:") for i in ids(reupload(sep()[0])))
def dt_oct():
import datetime as dt
return dt.date(2026, 10, 1)
def test_outside_digits_exempt_a_title_gained_period():
for text in ("客流汇总表(2026年9月)", "客流汇总表(2026年9月1日至2026年9月30日)"):
cur, _ = sep()
set_period_cell(cur, "B3", text, annotated=True)
assert "outside_digits:客流汇总!B3" not in ids(reupload(cur)) # D17、D27
assert "outside_digits:客流汇总!B3" in ids(first(FLOW, extraction_of(cur))) # 首次没有上一期,不免
def test_outside_digits_exempt_a_requires_same_text():
cur, _ = sep()
set_period_cell(cur, "B3", "客流汇总初步表(2026年9月)", annotated=True)
assert "outside_digits:客流汇总!B3" in ids(reupload(cur))
def test_outside_digits_exempt_a_requires_prev_plain_text():
# 上一期这一格就含数字(不是纯文字):(a) 不适用,即使附加文字和它相同
prev = copy.deepcopy(BASE[0])
prev["receipt"]["outside_text"] = [{"sheet": SHEET, "cell": "B3", "text": "客流汇总表3号门", "kind": "text_digits",
"period_source": False}]
cur, _ = sep()
set_period_cell(cur, "B3", "客流汇总表3号门(2026年9月)", annotated=True)
assert "outside_digits:客流汇总!B3" in ids(reupload(cur, prev))
def test_outside_digits_exempt_only_for_period_source_cells():
# 上一期 B3 是纯文字「客流汇总表」,本期 B3 加了年月,但没被认成统计期来源(kind=text_digits、
# period_source=False、不在 PeriodOut.texts 里):(a) 不适用,要确认。去掉「只有统计期来源格才能免」的
# 入口判断,这里就会被 (a) 免掉(D15、E7)
cur, _ = sep()
cur["receipt"]["outside_text"] = [o for o in cur["receipt"]["outside_text"] if o["cell"] != "B3"]
add_outside(cur, "B3", "客流汇总表(2026年9月)")
assert match_key(period_residue("客流汇总表(2026年9月)")) == match_key("客流汇总表") # 真走到 (a) 是会免的
assert "outside_digits:客流汇总!B3" in ids(reupload(cur))
def test_outside_digits_d28b_not_exempt():
cur, _ = sep()
set_period_cell(cur, "B32", "注:2026年9月数据为初步统计,待修订", annotated=True)
assert "outside_digits:客流汇总!B32" in ids(reupload(cur))
def test_outside_digits_exempt_b_same_sentence_monthly():
prev, _ = sep()
set_period_cell(prev, "B32", "注:2026年9月数据为初步统计,待修订", annotated=True)
cur = flow_doc(dt_oct(), 31)
set_period_cell(cur, "B32", "注:2026年10月数据为初步统计,待修订", annotated=True)
assert "outside_digits:客流汇总!B32" not in ids(reupload(cur, prev))
set_period_cell(cur, "B32", "注:2026年10月数据为正式统计", annotated=True)
assert "outside_digits:客流汇总!B32" in ids(reupload(cur, prev))
def test_outside_digits_exempt_b_other_digits_changed():
# AU-3:附加文字里统计期以外的数字(口径)变了,遮盖模板相同也不算同一句,要确认
prev, _ = sep()
set_period_cell(prev, "B32", "注:2026年9月数据只含3个分区", annotated=True)
cur = flow_doc(dt_oct(), 31)
set_period_cell(cur, "B32", "注:2026年10月数据只含2个分区", annotated=True)
assert "outside_digits:客流汇总!B32" in ids(reupload(cur, prev))
set_period_cell(cur, "B32", "注:2026年10月数据只含3个分区", annotated=True)
assert "outside_digits:客流汇总!B32" not in ids(reupload(cur, prev))
def test_outside_digits_prev_simple_import_not_exempt():
cur, _ = sep()
set_period_cell(cur, "B3", "客流汇总表(2026年9月)", annotated=True)
simple = {"receipt": {"tables": [{"name": "客流汇总", "rows": 29}],
"outside_text": [{"sheet": SHEET, "cell": "B3", "text": "客流汇总表", "kind": "text"}]},
"checks": [], "period": None}
assert "outside_digits:客流汇总!B3" in ids(reupload(cur, simple))
def test_rows_after_stop():
prob = {"code": "rows_after_stop", "category": "confirm", "message": "第 14 行起有 2 行文字在空行之后,没有导入",
"cells": ["销售!C14", "销售!D14", "销售!C15", "销售!D15"]}
ctx = first(LIST, list_ex(problems=[prob]))
item = next(i for i in confirm_items(ctx) if i.id == "rows_after_stop:列表1")
assert item.label == prob["message"] and item.source == "outside" and "销售!C14" in item.detail
assert not any(i.startswith("rows_after_stop:") for i in ids(first(LIST, list_ex())))
def test_empty_list_block_needs_confirmation():
"""AU-1:执行器报 list_empty(列表块只有表头)→ 必勾 empty_block:<块>,首次、重传都出。"""
prob = {"code": "list_empty", "category": "confirm", "cells": ["销售!A3"],
"message": "工作表「销售」的列表「列表1」的表头之下没有数据行:本期这张表将导入 0 行。"}
item = next(i for i in confirm_items(first(LIST, list_ex(problems=[prob]))) if i.id == "empty_block:列表1")
assert item.required and item.source == "outside" and "0 行" in item.label and "表「销售」" in item.label
assert not any(i.startswith("empty_block:") for i in ids(first(LIST, list_ex())))
def test_rows_after_stop_two_blocks_uses_lineage():
r = copy.deepcopy(LIST)
r["sheets"][0]["blocks"][0]["rows"] = {}
second = copy.deepcopy(r["sheets"][0]["blocks"][0])
second.update(id="列表2", table="费用", after_title="二、费用")
r["sheets"][0]["blocks"].append(second)
r["tables"] = [{"name": "销售"}, {"name": "费用"}]
lineage = {"销售": {"地区": [[1, "销售", "C3", 4, "down"]], "金额": [[1, "销售", "G3", 4, "down"]]},
"费用": {"地区": [[1, "销售", "C12", 3, "down"]], "金额": [[1, "销售", "G12", 3, "down"]]}}
prob = {"code": "rows_after_stop", "category": "confirm", "message": "第 16 行起有 1 行文字在空行之后,没有导入",
"cells": ["销售!C16", "销售!D16"]}
got = ids(first(r, list_ex(problems=[prob], lineage=lineage, derived=[])))
assert "rows_after_stop:列表2" in got and "rows_after_stop:列表1" not in got
prob["cells"] = ["销售!C8", "销售!D8"]
got = ids(first(r, list_ex(problems=[prob], lineage=lineage, derived=[])))
assert "rows_after_stop:列表1" in got
def test_rows_after_stop_from_a_skip_list_goes_to_the_block_named_in_the_message():
"""跳过空行的列表按表下说明收尾、那格文字不像说明时也报 rows_after_stop(WP-8 评审意见 4)。同一工作表上另有一个
stop 模式的列表:只按 blank_rows=stop 找候选会把它归到列表1;消息里写着「列表「列表2」」,确认项按它记在列表2 上。"""
r = copy.deepcopy(LIST)
r["sheets"][0]["blocks"][0]["rows"] = {}
second = copy.deepcopy(r["sheets"][0]["blocks"][0])
second.update(id="列表2", table="费用", after_title="二、费用", rows={"blank_rows": "skip"})
r["sheets"][0]["blocks"].append(second)
r["tables"] = [{"name": "销售"}, {"name": "费用"}]
lineage = {"销售": {"地区": [[1, "销售", "C3", 4, "down"]]}, "费用": {"地区": [[1, "销售", "C12", 3, "down"]]}}
prob = {"code": "rows_after_stop", "category": "confirm", "cells": ["销售!C16"],
"message": "工作表「销售」的列表「列表2」:第 16 行只有首列文字(C16「地区丁」),已当作表下说明,没有导入。"}
got = ids(first(r, list_ex(problems=[prob], lineage=lineage, derived=[])))
assert "rows_after_stop:列表2" in got and "rows_after_stop:列表1" not in got
def test_relation_observed():
cur, _ = sep()
assert not any(i.startswith("relation_observed:") for i in ids(reupload(cur))) # 30 天中 0 天相等
for c in cur["checks"]:
if c["id"] == "R2":
c["failed"] = 0
lab = label(reupload(cur), "relation_observed:R2")
assert lab == "表「时段客流」与表「日客流」声明为口径不同,但本期按「日期」分组的 30 组全部相等"
def test_sheet_extra():
cur, _ = sep()
cur["receipt"]["sheets"]["other_visible"] = ["说明"]
assert "sheet_extra:说明" in ids(first(FLOW, extraction_of(cur))) # 首次就有
prev = copy.deepcopy(BASE[0])
prev["receipt"]["sheets"]["other_visible"] = ["说明"]
assert "sheet_extra:说明" not in ids(reupload(cur, prev)) # 上一期也有
cur["receipt"]["sheets"]["other_visible"] = ["说明", "备注"]
got = ids(reupload(cur, prev))
assert "sheet_extra:备注" in got and "sheet_extra:说明" not in got
def test_sheet_renamed():
cur, _ = sep()
cur["receipt"]["sheets"].update(matched={"s1": "汇总"}, renamed={"客流汇总": "汇总"})
item = next(i for i in confirm_items(reupload(cur)) if i.id == "sheet_renamed:s1")
assert item.label == "工作表「客流汇总」现在叫「汇总」" and item.source == "sheet"
assert not any(i.startswith("sheet_renamed:") for i in ids(reupload(sep()[0])))
def test_context_human():
cur, _ = sep()
cur["period"] = {"start": "2026-09-01", "end": "2026-09-30", "source": "human", "cells": [],
"signed_by": "测试员甲", "texts": {}, "annotated": {}}
item = next(i for i in confirm_items(reupload(cur)) if i.id == "context_human:统计期")
assert item.label == "本期统计期为人工录入:2026-09-01 至 2026-09-30" and item.source == "context"
assert "测试员甲" in item.detail
assert "context_human:统计期" not in ids(reupload(sep()[0]))
def test_diff_items_collected():
diff = [DiffItem("label_writing", "分段「日间」的标签写法变了", requires_confirm=True,
confirm_id="diff:label_writing:日间"),
DiffItem("period", "统计期变了")]
items = confirm_items(reupload(sep()[0], diff=diff))
assert [(i.id, i.source) for i in items] == [("diff:label_writing:日间", "diff")]
# dict 形状也收
items = confirm_items(reupload(sep()[0], diff=[{"kind": "row_order", "label": "x", "detail": "",
"requires_confirm": True, "confirm_id": "diff:row_order:日客流"}]))
assert [i.id for i in items] == ["diff:row_order:日客流"]
def test_accepts_dataclass_extraction_and_checks():
from app.data.recipe_types import CheckResult, Extraction, OutsideText, SheetsOut
ex = Extraction(ok=True, outside_text=[OutsideText(SHEET, "B32", "注:9月15日", "text_digits")],
sheets=SheetsOut(matched={"s1": SHEET}))
checks = [CheckResult("R2", "relation_not_comparable", "R2", "info", "info", checked=30, failed=0)]
got = ids(ConfirmContext(kind="reupload", recipe=FLOW, old_recipe=FLOW, extraction=ex, checks=checks, diff=[],
prev=BASE[0]))
assert got == {"outside_digits:客流汇总!B32", "relation_observed:R2"}
def test_end_to_end_with_diff_reports():
# D28:差异卡和确认项一起跑,必勾集合正好是 outside_digits + diff:outside
cur, name = sep()
add_outside(cur, "B32", "注:9月15日闸机故障,当日客流为估算值")
diff = diff_reports(BASE[0], cur, prev_file_name=BASE[1], cur_file_name=name, recipe=FLOW, recipe_changed=False)
got = ids(reupload(cur, diff=diff))
assert got == {"outside_digits:客流汇总!B32", "diff:outside:客流汇总!B32"}
assert file_name(AUG, 31) == BASE[1] and SEP.month == 9
# --------------------------------------------------------------------------
# 输入不全就报错:漏一个键、一个参数,对应的必勾项会静默消失,提交时重算也发现不了
# --------------------------------------------------------------------------
@pytest.mark.parametrize("kind", ["reupload", "redraft"])
def test_old_recipe_required_for_reupload_and_redraft(kind):
# 漏传现行配方:早先 breaking:*、unit_changed:* 整段跳过(万元 → 元 静默放过)
new = flow()
new["tables"][0]["units"] = {"全日客流": "人", "分区甲": "人次", "分区乙": "人次"}
ctx = ConfirmContext(kind=kind, recipe=new, old_recipe=None, extraction=extraction_of(sep()[0]), checks=[],
diff=[], prev=BASE[0])
with pytest.raises(ValueError, match="现行配方"):
confirm_items(ctx)
ctx.old_recipe = FLOW
assert "unit_changed:日客流.全日客流" in ids(ctx)
@pytest.mark.parametrize("kind", ["reupload", "redraft"])
def test_prev_required_for_reupload_and_redraft(kind):
ctx = ConfirmContext(kind=kind, recipe=FLOW, old_recipe=FLOW, extraction=extraction_of(sep()[0]), checks=[],
diff=[], prev=None)
with pytest.raises(ValueError, match="上一期"):
confirm_items(ctx)
def test_unknown_kind_raises():
with pytest.raises(ValueError, match="未知的导入方式"):
confirm_items(first(FLOW, kind="upload"))
def test_checks_and_diff_required():
with pytest.raises(ValueError, match="核对结果"):
confirm_items(ConfirmContext(kind="first", recipe=FLOW, extraction=extraction_of(BASE[0])))
with pytest.raises(ValueError, match="差异卡"):
confirm_items(ConfirmContext(kind="reupload", recipe=FLOW, old_recipe=FLOW, extraction=extraction_of(sep()[0]),
checks=[], prev=BASE[0]))
# 首次没有上一期,不用给差异卡
assert ids(ConfirmContext(kind="first", recipe=FLOW, extraction=extraction_of(BASE[0]), checks=[])) == D00_CONFIRMS
@pytest.mark.parametrize("key", EXTRACTION_KEYS)
def test_missing_extraction_key_raises(key):
# 例如 extraction 没有 problems:rows_after_stop 早先直接没了
ex = extraction_of(BASE[0])
del ex[key]
with pytest.raises(ReceiptIncomplete) as err:
confirm_items(first(FLOW, ex))
assert err.value.side == "extraction" and err.value.missing == [key]
def test_missing_extraction_raises():
with pytest.raises(ReceiptIncomplete, match="extraction"):
confirm_items(ConfirmContext(kind="first", recipe=FLOW, checks=[]))
def test_partial_extraction_raises():
ex = extraction_of(BASE[0])
ex["partial"] = True
with pytest.raises(ReceiptIncomplete, match="partial"):
confirm_items(first(FLOW, ex))
@pytest.mark.parametrize("key", ["outside_text", "sheets"])
def test_prev_receipt_missing_key_raises(key):
prev = copy.deepcopy(BASE[0])
del prev["receipt"][key]
with pytest.raises(ReceiptIncomplete) as err:
confirm_items(reupload(sep()[0], prev))
assert err.value.side == "prev" and err.value.missing == [key]