Files
AgentLab/backend/tests/test_recipe_fixes.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

580 lines
32 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_fixes,WP-2,P3-SPEC 第 3 节):九种提议各自出与不出的条件、补丁形状、应用后静态校验通过。
问题的 fix_args 按 3.2 的形状手写(执行器填写它是 WP-1 的事,这里不依赖);干跑用期 2 的执行器
(recipe_engine.execute)。D04、D09、D13、D18 在期 2 执行器上就能验证「修复后干跑通过」;D16、D21 要用到忽略规则的
执行语义(WP-1),这里只验证补丁和静态校验,干跑留给集成验收(WP-8 A6b)。夹具全部合成、假名。
"""
from __future__ import annotations
import copy
import datetime as dt
import json
import re
from dataclasses import asdict
from pathlib import Path
from typing import Any
import pytest
from app.data import recipe_engine, xlsx_cells, xlsx_scan
from app.data import recipe_fixes as F
from app.data.recipe import apply_patch, recipe_sha256, validate_recipe
from app.data.recipe_suggest import _measure_column, detect_facts
from app.data.recipe_types import FIX_KINDS, DraftFacts, Fact, FixProposal, Recipe
from tests.fixtures.xlsx.flow import flow_workbook
FLOW = Recipe.model_validate(json.loads((Path(__file__).parent / "fixtures" / "recipes" / "flow_recipe.json")
.read_text(encoding="utf-8"))).model_dump(mode="json")
SEP = dt.date(2026, 9, 1)
SHEET = "客流汇总"
S = "/sheets/0/blocks/0/segments"
def full() -> dict[str, Any]:
return copy.deepcopy(FLOW)
def book(variant: str):
raw, fn = flow_workbook(SEP, 30, variant=variant)
sc = xlsx_scan.scan(raw)
grids = {s.name: xlsx_cells.read_grid(raw, sc, s.name, max_rows=500) for s in sc.sheets if s.state == "visible"}
return raw, fn, sc, grids
def dry(variant: str, recipe: dict[str, Any]):
raw, fn, sc, _ = book(variant)
return recipe_engine.execute(Recipe.model_validate(recipe), raw, fn, sc, None)
def one(problem: dict[str, Any], recipe: dict[str, Any] | None = None) -> FixProposal:
got = F.propose_fixes(recipe or full(), [problem], [], None)
assert len(got) == 1, got
return got[0]
def apply(recipe: dict[str, Any], p: FixProposal, option: str, reason: str | None = None) -> dict[str, Any]:
r = F.fix_ops(recipe, p, option, reason)
assert r.ok, r.problems
new = apply_patch(recipe, r.ops)
assert r.recipe_sha256_after == recipe_sha256(Recipe.model_validate(new))
return new
def problems_after(new: dict[str, Any], facts: DraftFacts | None = None) -> list[tuple[str, str]]:
_r, probs = validate_recipe(new, facts=facts, origin="manual", base=FLOW)
return [(p.code, p.path) for p in probs]
P_D04 = {"code": "label_missing", "category": "structure", "message": "…", "cells": [f"{SHEET}!B10"],
"fix": "remove_label", "fix_args": {"segment": "日间", "labels": ["7-8"]}}
P_D09 = {"code": "title_not_found", "category": "structure", "message": "…", "cells": [], "fix": "rename_title",
"fix_args": {"segment": "日间", "title": "日间时段客流(人次)",
"candidates": [{"cell": f"{SHEET}!B9", "text": "日间分时段客流(人次)"}]}}
P_D13 = {"code": "label_unexpected", "category": "structure", "message": "…", "cells": [f"{SHEET}!B8"],
"fix": "add_label", "fix_args": {"segment": "日客流", "role": "measures",
"labels": [{"raw": "分区丙(人次)", "cell": f"{SHEET}!B8"}]}}
P_D18 = {"code": "label_unparsed", "category": "structure", "message": "…", "cells": [f"{SHEET}!B21"],
"fix": "declare_total", "fix_args": {"segment": "日间", "role": "dimension",
"labels": [{"raw": "7-18 时合计", "cell": f"{SHEET}!B21", "total": True}],
"at_end": True}}
P_D16 = {"code": "axis_extra_cells", "category": "structure", "message": "…", "cells": [f"{SHEET}!AG4"],
"fix": "ignore_cells", "fix_args": {"block": "交叉表", "how": "columns",
"headers": [{"raw": "合计", "cell": f"{SHEET}!AG4"}]}}
P_D21 = {"code": "row_unclaimed", "category": "structure", "message": "…", "cells": [f"{SHEET}!B32"],
"fix": "ignore_cells", "fix_args": {"block": "交叉表", "how": "rows",
"labels": [{"raw": "补录(人次)", "cell": f"{SHEET}!B32"}]}}
P_OUT = {"code": "outside_number", "category": "structure", "message": "…", "cells": [f"{SHEET}!C33"],
"fix": "ignore_cells", "fix_args": {"sheet": SHEET, "how": "outside", "rows": [
{"row": 33, "anchor": "补录说明", "anchor_cell": f"{SHEET}!B33", "cells": [f"{SHEET}!C33"]}]}}
P_PH = {"code": "value_not_number", "category": "structure", "message": "…", "cells": [f"{SHEET}!C11"],
"fix": "declare_placeholder", "fix_args": {"block": "交叉表", "texts": ["—"], "other": 0}}
P_HID = {"code": "hidden_rows", "category": "structure", "message": "…", "cells": [],
"fix": "declare_hidden", "fix_args": {"sheet": SHEET, "axis": "rows", "rows": [12], "autofilter": True}}
P_SHEET = {"code": "sheet_missing", "category": "structure", "message": "…", "cells": [],
"fix": "rename_sheet", "fix_args": {"sheet_id": "s1", "name": SHEET, "candidates": ["客流汇总(新)"]}}
# ==========================================================================
# 公共
# ==========================================================================
def test_ids_are_deterministic_and_prefixed():
a = F.propose_fixes(full(), [P_D04, P_D13], [], None)
b = F.propose_fixes(full(), [P_D04, P_D13], [], None)
assert [p.id for p in a] == [p.id for p in b]
assert all(re.fullmatch(r"fx-[0-9a-f]{12}", p.id) for p in a)
assert a[0].id != a[1].id
assert {p.kind for p in a} <= set(FIX_KINDS)
def test_problem_without_fix_args_gets_no_proposal():
"""期 2 的执行器只透传 fix、没有 fix_args:界面不能光靠 fix 字符串构造补丁,不出提议。"""
p = dict(P_D04)
p.pop("fix_args")
assert F.propose_fixes(full(), [p], [], None) == []
assert F.propose_fixes(full(), [dict(P_D04, fix_args={"labels": 3})], [], None) == []
def test_anchor_index_points_at_the_right_problem():
"""两个分段同时找不到标题:title_not_found 没有坐标,只有 anchor 下标分得清(评审三-B2)。"""
night = {"code": "title_not_found", "category": "structure", "message": "…", "cells": [], "fix": "rename_title",
"fix_args": {"segment": "夜间", "title": "夜间时段客流(人次)",
"candidates": [{"cell": f"{SHEET}!B21", "text": "夜间分时段客流(人次)"}]}}
other = {"code": "outside_digits", "category": "confirm", "message": "…", "cells": []}
got = F.propose_fixes(full(), [other, P_D09, night], [], None)
assert [(p.target["segment"], p.anchor.kind, p.anchor.index) for p in got] == [
("日间", "problem", 1), ("夜间", "problem", 2)]
def test_fix_ops_rejects_options_not_in_the_proposal_and_missing_reason():
p = one(P_D21)
with pytest.raises(F.EditRequestError) as e:
F.fix_ops(full(), p, "remove", None)
assert e.value.code == "edit_invalid"
with pytest.raises(F.EditRequestError) as e:
F.fix_ops(full(), p, "ignore", " ")
assert e.value.code == "reason_required"
with pytest.raises(F.EditRequestError) as e:
F.fix_ops(full(), p, "ignore", "长" * 201)
assert e.value.code == "edit_invalid"
# 客户端改了选项值里的文件原文(pick 的词):不在选项里,拒收
t = one(P_D09)
with pytest.raises(F.EditRequestError):
F.fix_ops(full(), t, f"{SHEET}!B9|pick:随便写的词", None)
def test_stale_target_returns_not_ok():
p = one(P_D04)
r = full()
r["sheets"][0]["blocks"][0]["segments"][1]["id"] = "白天"
r["sheets"][0]["blocks"][0]["segments"][3]["locate"]["segment"] = "夜间"
res = F.fix_ops(r, p, "remove", None)
assert res.ok is False and res.ops == [] and res.problems[0].code == "edit_not_applicable"
assert res.recipe_sha256_after is None
def test_patch_text_comes_only_from_file_text_rules_and_reason():
"""补丁里的文字只有三种来源:文件原文(fix_args)、系统按规则推出的名字、理由(3.1)。"""
for prob in (P_D04, P_D09, P_D13, P_D18, P_D16, P_D21, P_OUT, P_PH, P_HID, P_SHEET):
for p in F.propose_fixes(full(), [prob], [], None):
for o in p.options:
res = F.fix_ops(full(), p, o.value, "合成理由" if o.needs_reason else None)
blob = json.dumps(res.ops, ensure_ascii=False)
assert "随便写的词" not in blob
if not o.needs_reason:
assert "合成理由" not in blob
# ==========================================================================
# ① 移除标签(D04)
# ==========================================================================
def test_remove_label_dimension_d04_end_to_end():
p = one(P_D04)
assert p.kind == "remove_label" and p.problem_code == "label_missing"
assert p.title == "在分段「日间」的期望标签中去掉「7-8」" and p.cells == [f"{SHEET}!B10"]
assert p.target == {"segment": "日间", "labels": ["7-8"]}
assert [(o.value, o.label, o.breaking) for o in p.options] == [("remove", "去掉标签「7-8」", False)]
res = F.fix_ops(full(), p, "remove", None)
assert res.key == "remove_label:日间:7-8" and res.title == p.title
assert res.ops == [{"op": "replace", "path": f"{S}/1/labels/expect",
"value": ["8-9", "9-10", "10-11", "11-12", "12-13", "13-14", "14-15", "15-16", "16-17", "17-18"]}]
new = apply(full(), p, "remove")
assert problems_after(new) == []
ext = dry("D04", new)
assert ext.ok and ext.problems == []
assert ext.expected_rows == {"日客流": 30, "时段客流": 480, "时段客流_表内合计": 90}
def test_remove_label_measures_removes_column_and_unit():
prob = dict(P_D04, fix_args={"segment": "日客流", "labels": ["分区乙(人次)"]})
p = one(prob)
assert p.options[0].breaking is True and "破坏性变更" in p.options[0].detail
res = F.fix_ops(full(), p, "remove", None)
assert res.ops == [
{"op": "replace", "path": f"{S}/0/labels/expect", "value": ["全日客流(人次)", "分区甲(人次)"]},
{"op": "remove", "path": f"{S}/0/measures/分区乙(人次)"},
{"op": "remove", "path": "/tables/0/units/分区乙"}]
assert res.breaking is True
# 关系 R1 引用了去掉的列:静态校验报关系问题,接着出 ③(只剩一个成员,只能删掉或不登记)
new = apply_patch(full(), res.ops)
_r, rps = validate_recipe(new, origin="manual", base=FLOW)
assert ("column_unknown", "/relations/0/parts/1") in [(x.code, x.path) for x in rps]
# 本期没有能认领的恒等式(文件里没有分区乙了):不登记要认领一条系统发现,这里只能删掉这条关系,并且要写理由
m = F.propose_fixes(new, [], [asdict(x) for x in rps], DraftFacts())
assert [(x.kind, [(o.value, o.needs_reason) for o in x.options]) for x in m] == [
("edit_members", [("remove", True)])]
with pytest.raises(F.EditRequestError) as e:
F.fix_ops(new, m[0], "remove", None)
assert e.value.code == "reason_required"
gone = F.fix_ops(new, m[0], "remove", "合成理由:分区乙撤销")
assert gone.summary == ["删除关系「R1」(理由:合成理由:分区乙撤销)"]
assert problems_after(apply_patch(new, gone.ops)) == []
def test_remove_label_measures_then_edit_members_offers_dismiss_when_the_fact_is_still_there():
"""3.2 ① 的连带:去掉的组成列被 R1 引用、本期的事实 F1「全日客流 = 分区甲 + 分区乙」还在(文件没变,
只是不再导入分区乙)。update 给不出(分区乙映射不到列),③ 只给「不登记」,认领 F1,要写理由。
退回不要理由的「删除这条关系」的话,F1 的分段 id 是规则起草的写法、按全部标签又认不出,3.3 第 11 条判它
不在改动范围内,删掉 R1 以后连未认领都不报,核对和系统发现一起不声不响地消失。"""
prob = dict(P_D04, cells=[f"{SHEET}!B7"], fix_args={"segment": "日客流", "labels": ["分区乙(人次)"]})
new = Recipe.model_validate(apply_patch(full(), F.fix_ops(full(), one(prob), "remove", None).ops)) \
.model_dump(mode="json")
_raw, _fn, sc, grids = book("D01")
facts = detect_facts(grids, sc, recipe=new)
f1 = next(f for f in facts.facts if f.kind == "sum_eq")
assert f1.detail["parts"] == ["分区甲(人次)", "分区乙(人次)"]
_r, rps = validate_recipe(new, facts=facts, origin="manual", base=FLOW)
got = F.propose_fixes(new, [], [asdict(x) for x in rps], facts)
assert len(got) == 1 and got[0].kind == "edit_members"
m = got[0]
assert [(o.value, o.needs_reason) for o in m.options] == [("dismiss", True)]
assert m.target["fact"] == f1.id and "「分区乙(人次)」" in m.options[0].detail
dis = apply_patch(new, F.fix_ops(new, m, "dismiss", "合成理由:分区乙另行统计").ops)
assert dis["relations"][0] == {"id": "R1", "kind": "dismissed", "claims": f1.id, "reason": "合成理由:分区乙另行统计"}
assert problems_after(dis, facts) == []
# 绕过提议、直接删掉 R1:带现行配方校验时照样报未认领(F1 按现行配方认出落在改动过的「日客流」上)
bare = copy.deepcopy(new)
del bare["relations"][0]
assert ("fact_unclaimed", "/relations") in problems_after(bare, facts)
def test_remove_label_not_offered_when_nothing_would_remain_or_label_unknown():
r = full()
r["sheets"][0]["blocks"][0]["segments"][1]["labels"]["expect"] = ["7-8"]
assert F.propose_fixes(r, [P_D04], [], None) == []
assert F.propose_fixes(full(), [dict(P_D04, fix_args={"segment": "日间", "labels": ["6-7"]})], [], None) == []
assert F.propose_fixes(full(), [dict(P_D04, fix_args={"segment": "早间", "labels": ["7-8"]})], [], None) == []
# ==========================================================================
# ② 加入标签 → ③ 编辑恒等式成员(D13)
# ==========================================================================
def test_add_label_then_edit_members_d13_end_to_end():
p = one(P_D13)
assert p.kind == "add_label" and p.target["labels"] == [{"raw": "分区丙(人次)", "cell": f"{SHEET}!B8"}]
assert "新增列「分区丙」,单位「人次」" in p.options[0].detail
res = F.fix_ops(full(), p, "add", None)
assert res.ops == [
{"op": "replace", "path": f"{S}/0/labels/expect",
"value": ["全日客流(人次)", "分区甲(人次)", "分区乙(人次)", "分区丙(人次)"]},
{"op": "add", "path": f"{S}/0/measures/分区丙(人次)", "value": "分区丙"},
{"op": "add", "path": "/tables/0/units/分区丙", "value": "人次"}]
work = Recipe.model_validate(apply_patch(full(), res.ops)).model_dump(mode="json")
_raw, _fn, sc, grids = book("D13")
facts = detect_facts(grids, sc, recipe=work)
_r, rps = validate_recipe(work, facts=facts, origin="manual", base=FLOW)
assert [(x.code, x.path) for x in rps] == [("fact_claim_mismatch", "/relations/0/claims")]
fixes = F.propose_fixes(work, [], [asdict(x) for x in rps], facts)
assert len(fixes) == 1
m = fixes[0]
assert m.kind == "edit_members" and m.anchor.kind == "recipe_problem" and m.anchor.index == 0
assert m.target == {"relation": "R1", "fact": "F1", "total": "全日客流", "parts": ["分区甲", "分区乙", "分区丙"]}
assert [o.value for o in m.options] == ["update", "dismiss"]
assert m.options[0].label == "按系统发现更新为「全日客流 = 分区甲 + 分区乙 + 分区丙」"
assert m.options[1].needs_reason is True
upd = F.fix_ops(work, m, "update", None)
assert upd.key == "edit_members:R1" and upd.title == f"关系「R1」:{m.options[0].label}"
work2 = apply_patch(work, upd.ops)
_r, rps2 = validate_recipe(work2, facts=facts, origin="manual", base=FLOW)
assert rps2 == []
ext = dry("D13", work2)
assert ext.ok and ext.problems == []
# 不登记:整条换成 dismissed,认领本期的那条事实,理由进配方
dis = apply_patch(work, F.fix_ops(work, m, "dismiss", "合成理由:分区丙另有口径").ops)
assert dis["relations"][0] == {"id": "R1", "kind": "dismissed", "claims": "F1", "reason": "合成理由:分区丙另有口径"}
assert validate_recipe(dis, facts=facts, origin="manual", base=FLOW)[1] == []
def test_add_label_column_name_collision_gets_suffix():
prob = dict(P_D13, fix_args={"segment": "日客流", "role": "measures",
"labels": [{"raw": "分区甲(人)", "cell": f"{SHEET}!B8"}]})
p = one(prob)
res = F.fix_ops(full(), p, "add", None)
assert {"op": "add", "path": f"{S}/0/measures/分区甲(人)", "value": "分区甲_2"} in res.ops
assert {"op": "add", "path": "/tables/0/units/分区甲_2", "value": "人"} in res.ops
def test_add_label_unit_outside_vocabulary_keeps_the_raw_text_like_the_drafter():
"""括号里的单位不在词表中:照起草器第 8 条(_measure_column)列名保留原文、不记单位,与起草器、框选的分段
同一规则;选项和摘要写明单位没有登记,兄弟列都是「人次」时不会看起来同名同口径。"""
odd = dict(P_D13, fix_args={"segment": "日客流", "role": "measures",
"labels": [{"raw": "分区丁(千人次)", "cell": f"{SHEET}!B8"}]})
p = one(odd)
assert "「千人次」不在单位词表里" in p.options[0].detail
res = F.fix_ops(full(), p, "add", None)
assert res.ops[1:] == [{"op": "add", "path": f"{S}/0/measures/分区丁(千人次)",
"value": _measure_column("分区丁(千人次)", 4)[0]}]
assert res.ops[1]["value"] == "分区丁_千人次"
assert any("没有登记单位" in x for x in res.summary)
assert problems_after(apply_patch(full(), res.ops)) == []
def test_add_label_not_offered_for_known_label_or_role_mismatch():
already = dict(P_D13, fix_args={"segment": "日客流", "role": "measures",
"labels": [{"raw": "分区甲(人次)", "cell": f"{SHEET}!B6"}]})
assert F.propose_fixes(full(), [already], [], None) == []
wrong = dict(P_D13, fix_args={**P_D13["fix_args"], "role": "dimension"})
assert F.propose_fixes(full(), [wrong], [], None) == []
def test_edit_members_without_a_replacing_fact_offers_only_removal():
"""本期没有可以替换、也没有可以认领的恒等式(新恒等式有一天不成立,_sum_holds 不出 sum_eq):update、dismiss
都给不出,只能删掉这条关系;删掉一条数据质量核对要写理由。"""
work = apply_patch(full(), F.fix_ops(full(), one(P_D13), "add", None).ops)
rp = {"path": "/relations/0/claims", "code": "fact_claim_mismatch", "message": "…"}
ne = Fact("F1", "not_equal_sum", SHEET, "…", {"a": ["日间", "夜间"], "b_segment": "日客流", "b": "全日客流(人次)"})
got = F.propose_fixes(work, [], [rp], DraftFacts(facts=[ne]))
assert len(got) == 1 and [(o.value, o.needs_reason) for o in got[0].options] == [("remove", True)]
assert "本期未发现可以替换或认领的恒等式" in got[0].options[0].detail
new = apply_patch(work, F.fix_ops(work, got[0], "remove", "合成理由").ops)
assert [r["id"] for r in new["relations"]] == ["R2"]
def test_edit_members_dismiss_skips_a_fact_claimed_by_another_relation():
"""可以认领的事实已经被别的关系认领:再认领会报 fact_claimed_twice,不拿它当「不登记」的认领对象。"""
prob = dict(P_D04, cells=[f"{SHEET}!B7"], fix_args={"segment": "日客流", "labels": ["分区乙(人次)"]})
new = apply_patch(full(), F.fix_ops(full(), one(prob), "remove", None).ops)
sum_eq = Fact("F1", "sum_eq", SHEET, "…", {"segment": "客流汇总_按日", "total": "全日客流(人次)",
"parts": ["分区甲(人次)", "分区乙(人次)"]})
rp = {"path": "/relations/0/parts/1", "code": "column_unknown", "message": "…"}
got = F.propose_fixes(new, [], [rp], DraftFacts(facts=[sum_eq]))
assert [o.value for o in got[0].options] == ["dismiss"] and got[0].target["fact"] == "F1"
taken = copy.deepcopy(new)
taken["relations"].append({"id": "R9", "kind": "dismissed", "claims": "F1", "reason": "合成理由"})
got = F.propose_fixes(taken, [], [rp], DraftFacts(facts=[sum_eq]))
assert [o.value for o in got[0].options] == ["remove"]
def test_edit_members_only_for_sum_eq_relations():
rp = {"path": "/relations/1/claims", "code": "fact_claim_mismatch", "message": "…"}
assert F.propose_fixes(full(), [], [rp], DraftFacts()) == []
assert F.propose_fixes(full(), [], [{"path": "/relations/0", "code": "fact_unclaimed", "message": "…"}],
DraftFacts()) == []
# ==========================================================================
# ④ 分段标题改名(D09)
# ==========================================================================
def test_rename_title_keep_d09_end_to_end():
p = one(P_D09)
assert p.kind == "rename_title" and p.cells == [f"{SHEET}!B9"]
values = [o.value for o in p.options]
assert values[0] == f"{SHEET}!B9|keep" and f"{SHEET}!B9|pick:日间分" in values
keep = p.options[0]
assert keep.label == "改为「日间分时段客流(人次)」,「时段类别」沿用「日间」" and keep.breaking is False
pick = next(o for o in p.options if o.value.endswith("pick:日间分"))
assert pick.breaking is True and "这是破坏性变更:这一列的值从「日间」变为「日间分」" in pick.detail
res = F.fix_ops(full(), p, keep.value, None)
assert res.ops == [{"op": "replace", "path": f"{S}/1/locate/title", "value": "日间分时段客流(人次)"}]
assert res.title == "分段「日间」的分段标题改为「日间分时段客流(人次)」,「时段类别」沿用「日间」"
new = apply_patch(full(), res.ops)
# 3.3 (b):沿用现行配方的 pick,静态校验通过;没有 base 时照期 2 报 const_not_candidate
assert problems_after(new) == []
assert [p.code for p in validate_recipe(new, origin="manual")[1]] == ["const_not_candidate"]
ext = dry("D09", new)
assert ext.ok and ext.problems == []
# pick 选项:再改常量
res2 = F.fix_ops(full(), p, pick.value, None)
assert res2.ops[1] == {"op": "replace", "path": f"{S}/1/const/时段类别/pick", "value": "日间分"}
assert res2.breaking is True
def test_rename_title_keep_only_when_pick_is_substring():
prob = dict(P_D09, fix_args={**P_D09["fix_args"],
"candidates": [{"cell": f"{SHEET}!B9", "text": "白天时段客流(人次)"}]})
p = one(prob)
assert all("|pick:" in o.value for o in p.options)
def test_rename_title_skips_overlong_candidates():
prob = dict(P_D09, fix_args={**P_D09["fix_args"],
"candidates": [{"cell": f"{SHEET}!B9", "text": "日间" + "长" * 40}]})
assert F.propose_fixes(full(), [prob], [], None) == []
# ==========================================================================
# ⑤ 声明为合计行(D18)
# ==========================================================================
def test_declare_total_keep_d18_end_to_end():
p = one(P_D18)
assert [o.value for o in p.options] == ["keep", "check_only"]
assert p.options[0].label == "改作合计核对,原值另存表「时段客流_表内合计」"
res = F.fix_ops(full(), p, "keep", None)
assert res.ops[0] == {"op": "add", "path": f"{S}/1/stop_parser", "value": "hour_range_total"}
assert res.ops[1]["path"] == f"{S}/2"
seg = res.ops[1]["value"]
assert seg["id"] == "日间合计" and seg["locate"]["segment"] == "日间"
assert seg["labels"] == {"expect": ["7-18 时合计"]}
assert seg["verify"] == {"kind": "label_range_sum", "against_table": "时段客流", "value": "客流"}
# 同一块里已有另存到同一张基表的合计表:沿用,不新建表
assert seg["keep_as"] == {"table": "时段客流_表内合计", "dim": "合计项",
"derive": {"起始小时": "start", "结束小时": "end"}, "value": "客流"}
assert len(res.ops) == 2
new = apply_patch(full(), res.ops)
assert problems_after(new) == []
ext = dry("D18", new)
assert ext.ok and ext.expected_rows == {"日客流": 30, "时段客流": 510, "时段客流_表内合计": 120}
# 只核对不另存
co = apply_patch(full(), F.fix_ops(full(), p, "check_only", None).ops)
assert co["sheets"][0]["blocks"][0]["segments"][2]["keep_as"] is None
assert problems_after(co) == []
def test_declare_total_new_table_when_no_sibling_total():
r = full()
del r["sheets"][0]["blocks"][0]["segments"][3] # 去掉夜间合计
r["sheets"][0]["blocks"][0]["segments"][2]["stop_parser"] = None
r["tables"] = r["tables"][:2]
res = F.fix_ops(r, one(P_D18, r), "keep", None)
assert res.ops[-1] == {"op": "add", "path": "/tables/-", "value": {
"name": "时段客流_表内合计", "grain": ["日期", "合计项"], "kind": "reported_total", "units": {"客流": "人次"},
"note": ""}}
assert problems_after(apply_patch(r, res.ops)) == []
@pytest.mark.parametrize("change", ["role", "not_total", "not_at_end", "no_derive", "derived_after"])
def test_declare_total_five_preconditions(change):
prob = copy.deepcopy(P_D18)
r = full()
args = prob["fix_args"]
if change == "role":
args["role"] = "measures"
elif change == "not_total":
args["labels"][0]["total"] = False
elif change == "not_at_end":
args["at_end"] = False
elif change == "no_derive":
r["sheets"][0]["blocks"][0]["segments"][1]["dim"]["derive"] = {"起始小时": "start"}
else:
args["segment"] = "夜间" # 夜间后面已经有「夜间合计」
assert F.propose_fixes(r, [prob], [], None) == []
# ==========================================================================
# ⑥ 忽略(D16 列、D21 行、区域外)
# ==========================================================================
def test_ignore_columns_d16():
p = one(P_D16)
assert p.kind == "ignore_cells" and p.title == "按表头忽略「合计」这一列"
assert p.options[0].needs_reason is True
res = F.fix_ops(full(), p, "ignore", "右侧合计列不导入")
assert res.key == "ignore_cells:columns:交叉表:合计"
assert res.ops == [{"op": "add", "path": "/sheets/0/blocks/0/ignore_columns/-",
"value": {"header": "合计", "reason": "右侧合计列不导入"}}]
assert problems_after(apply_patch(full(), res.ops)) == []
def test_ignore_rows_d21():
p = one(P_D21)
assert p.title == "按行标签忽略「补录(人次)」这一行"
res = F.fix_ops(full(), p, "ignore", "补录行不导入")
assert res.ops == [{"op": "add", "path": "/sheets/0/blocks/0/ignore_rows/-",
"value": {"label": "补录(人次)", "reason": "补录行不导入"}}]
assert problems_after(apply_patch(full(), res.ops)) == []
def test_ignore_outside():
p = one(P_OUT)
assert p.title == "第 33 行有文字「补录说明」:忽略这一行导入区域之外的数字"
res = F.fix_ops(full(), p, "ignore", "说明行的数字不导入")
assert res.ops == [{"op": "add", "path": "/sheets/0/ignore_outside/-",
"value": {"anchor": "补录说明", "reason": "说明行的数字不导入"}}]
assert problems_after(apply_patch(full(), res.ops)) == []
no_anchor = copy.deepcopy(P_OUT)
no_anchor["fix_args"]["rows"][0]["anchor"] = None
assert F.propose_fixes(full(), [no_anchor], [], None) == []
def test_ignore_anchor_with_digits_is_not_offered():
"""锚点含数字时这一项不给(3.2 ⑥):全部含数字时不出提议(不出一个没有选项的修复按钮);部分含数字时只给
其余的,含数字的写进选项的说明。三种形状都一样。"""
prob = copy.deepcopy(P_D21)
prob["fix_args"]["labels"] = [{"raw": "注:9月补录", "cell": f"{SHEET}!B32"}]
assert F.propose_fixes(full(), [prob], [], None) == []
cols = copy.deepcopy(P_D16)
cols["fix_args"]["headers"] = [{"raw": "第3周合计", "cell": f"{SHEET}!AG4"}]
out = copy.deepcopy(P_OUT)
out["fix_args"]["rows"][0]["anchor"] = "9月补录说明"
assert F.propose_fixes(full(), [cols, out], [], None) == []
prob["fix_args"]["labels"].append({"raw": "补录(人次)", "cell": f"{SHEET}!B33"})
p2 = one(prob)
assert p2.target["labels"] == ["补录(人次)"] and "「注:9月补录」含数字" in p2.options[0].detail
def test_ignore_columns_not_offered_for_non_text_or_date_headers():
for head in (None, "9月31日", ""):
prob = copy.deepcopy(P_D16)
prob["fix_args"]["headers"] = [{"raw": head, "cell": f"{SHEET}!AG4"}]
assert F.propose_fixes(full(), [prob], [], None) == [], head
def test_ignore_rows_not_offered_without_label_or_on_list_block():
prob = copy.deepcopy(P_D21)
prob["fix_args"]["labels"] = [{"raw": None, "cell": f"{SHEET}!B32"}]
assert F.propose_fixes(full(), [prob], [], None) == []
# ==========================================================================
# ⑦ 声明占位符、⑧ 隐藏行列、⑨ 工作表改名
# ==========================================================================
def test_declare_placeholder():
p = one(P_PH)
assert [o.value for o in p.options] == ["no_data", "not_applicable"]
assert p.options[0].label == "把「—」也声明为无数据(存为空值)"
res = F.fix_ops(full(), p, "not_applicable", None)
assert res.ops == [{"op": "add", "path": "/sheets/0/blocks/0/values/placeholders/-",
"value": {"text": "—", "meaning": "不适用"}}]
assert problems_after(apply_patch(full(), res.ops)) == []
for texts in ([], ["123"], ["·"], ["太长的占位符文字超过"]):
prob = copy.deepcopy(P_PH)
prob["fix_args"]["texts"] = texts
assert F.propose_fixes(full(), [prob], [], None) == [], texts
def test_declare_hidden_applies_on_full_form_defaults():
"""⑧ 在 hidden 取默认值的配方上也能应用:入参是完整形式,hidden.rows 总在(评审二-M1)。"""
canonical_like = Recipe.model_validate(full()).model_dump(mode="json", exclude_defaults=True)
assert "hidden" not in canonical_like["sheets"][0]
p = one(P_HID)
assert [o.value for o in p.options] == ["include", "exclude"]
assert "可能是筛选隐藏的行" in p.options[0].detail
res = F.fix_ops(full(), p, "exclude", None)
assert res.ops == [{"op": "replace", "path": "/sheets/0/hidden/rows", "value": "exclude"}]
assert apply_patch(full(), res.ops)["sheets"][0]["hidden"]["rows"] == "exclude"
cols = copy.deepcopy(P_HID)
cols["fix_args"] = {"sheet": SHEET, "axis": "cols", "cols": [5], "autofilter": False}
assert [o.value for o in one(cols).options] == ["include"]
def test_rename_sheet_from_missing_and_from_fallback():
p = one(P_SHEET)
assert [o.value for o in p.options] == ["客流汇总(新)"]
res = F.fix_ops(full(), p, "客流汇总(新)", None)
assert res.ops == [{"op": "replace", "path": "/sheets/0/match/name", "value": "客流汇总(新)"}]
assert res.key == "rename_sheet:s1"
got = F.propose_fixes(full(), [], [], None, {SHEET: "9月客流"})
assert len(got) == 1 and got[0].anchor.kind == "sheet_renamed" and got[0].anchor.sheet == "9月客流"
assert got[0].problem_code is None
assert "下一期很可能又会改名" in got[0].options[0].detail
assert problems_after(apply_patch(full(), F.fix_ops(full(), got[0], "9月客流", None).ops)) == []
def test_proposal_round_trips_through_json():
for prob in (P_D09, P_D18, P_HID):
p = one(prob)
back = F.proposal_from_dict(json.loads(json.dumps(asdict(p), ensure_ascii=False)))
assert back == p