mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:01:58 +00:00
2cdbf9a733
按期累积(新 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(部署本来就是单进程)
358 lines
18 KiB
Python
358 lines
18 KiB
Python
"""配方静态校验的期 3 改动(WP-2,P3-SPEC 2.2、3.3、9.2):按期累积的资格、沿用已确认的常量(L3)、认领只查改动过的
|
||
部分、period_literal 的扩展范围、忽略规则的 ignore_conflict / ignore_duplicate。
|
||
|
||
只用契约里的参考配方(flow_recipe.json)和手写的小配方,不读 Excel。每条规则一正一反。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.data.recipe import canonical_recipe, validate_recipe
|
||
from app.data.recipe_types import DraftFacts, Fact, Recipe
|
||
|
||
FLOW = json.loads((Path(__file__).parent / "fixtures" / "recipes" / "flow_recipe.json").read_text(encoding="utf-8"))
|
||
SEG = "/sheets/0/blocks/0/segments"
|
||
F1 = Fact("F1", "sum_eq", "客流汇总", "第 5 行 = 第 6 行 + 第 7 行(30 列中 30 列成立)",
|
||
{"segment": "日客流", "total": "全日客流(人次)", "parts": ["分区甲(人次)", "分区乙(人次)"], "rows": [5, 6, 7]})
|
||
F2 = Fact("F2", "not_equal_sum", "客流汇总", "日间、夜间各行之和 与 第 5 行(30 列中 0 列相等)",
|
||
{"a": ["日间", "夜间"], "b_segment": "日客流", "b": "全日客流(人次)", "equal": 0, "checked": 30})
|
||
#: D13 加了分区丙之后本期的系统发现:全日 = 甲 + 乙 + 丙
|
||
F1_C = Fact("F1", "sum_eq", "客流汇总", "第 5 行 = 第 6 行 + 第 7 行 + 第 8 行(30 列中 30 列成立)",
|
||
{"segment": "日客流", "total": "全日客流(人次)",
|
||
"parts": ["分区甲(人次)", "分区乙(人次)", "分区丙(人次)"], "rows": [5, 6, 7, 8]})
|
||
|
||
|
||
def flow() -> dict:
|
||
return copy.deepcopy(FLOW)
|
||
|
||
|
||
def segs(r: dict) -> list[dict]:
|
||
return r["sheets"][0]["blocks"][0]["segments"]
|
||
|
||
|
||
def codes(data, **kw) -> list[tuple[str, str]]:
|
||
_r, problems = validate_recipe(data, **kw)
|
||
return [(p.code, p.path) for p in problems]
|
||
|
||
|
||
def list_recipe() -> dict:
|
||
return {
|
||
"recipe_format": "agentlab-recipe/2",
|
||
"sheets": [{"id": "s1", "match": {"name": "月报"}, "blocks": [{
|
||
"id": "列表1", "layout": "list", "table": "销售",
|
||
"columns": [{"header": "地区", "name": "地区", "type": "TEXT"},
|
||
{"header": "销量", "name": "销量", "type": "INTEGER"}]}]}],
|
||
"tables": [{"name": "销售", "grain": ["地区"]}],
|
||
}
|
||
|
||
|
||
# ==========================================================================
|
||
# 2.2 按期累积的资格(mode_unsupported 不再产生)
|
||
# ==========================================================================
|
||
|
||
|
||
def test_accumulate_eligible_reference_recipe_passes():
|
||
r = flow()
|
||
r["mode"] = "accumulate"
|
||
assert codes(r, facts=DraftFacts(facts=[F1, F2])) == []
|
||
|
||
|
||
def test_accumulate_without_covers_context_reports_unkeyed_on_every_written_table():
|
||
r = flow()
|
||
r["mode"] = "accumulate"
|
||
r["sheets"][0]["blocks"][0]["axis"]["checks"] = ["contiguous"]
|
||
got = codes(r)
|
||
assert ("accumulate_unkeyed", "/tables/0") in got and ("accumulate_unkeyed", "/tables/2") in got
|
||
assert all(c != "mode_unsupported" for c, _ in got)
|
||
# 每期替换时同一份配方只报 checks_relaxed 那一类(确认项),静态校验不报资格
|
||
r["mode"] = "replace"
|
||
assert codes(r) == []
|
||
|
||
|
||
def test_accumulate_list_table_message_does_not_promise_future_versions():
|
||
r = list_recipe()
|
||
r["mode"] = "accumulate"
|
||
r["sheets"][0]["context"] = [{}]
|
||
_rec, problems = validate_recipe(r)
|
||
assert [p.code for p in problems] == ["accumulate_unkeyed"]
|
||
assert "列表形态的表目前只支持每期替换" in problems[0].message and "后续版本" not in problems[0].message
|
||
|
||
|
||
# ==========================================================================
|
||
# 3.3 沿用已确认的常量(L3)
|
||
# ==========================================================================
|
||
|
||
|
||
def d09(pick: str = "日间") -> dict:
|
||
"""D09:日间的分段标题改成「日间分时段客流(人次)」,常量 pick 照旧或改掉。"""
|
||
r = flow()
|
||
segs(r)[1]["locate"]["title"] = "日间分时段客流(人次)"
|
||
segs(r)[1]["const"]["时段类别"]["pick"] = pick
|
||
return r
|
||
|
||
|
||
def test_const_rule_a_candidate_word_still_passes():
|
||
# (a) 期 2 原规则:pick 是新标题的候选词(「日间分」)
|
||
assert codes(d09("日间分"), origin="manual") == []
|
||
|
||
|
||
def test_const_rule_a_negative_without_base():
|
||
# 没有 base、不是重放:沿用「日间」不在候选词里,照期 2 报 const_not_candidate
|
||
assert codes(d09(), origin="manual") == [("const_not_candidate", f"{SEG}/1/const/时段类别/pick")]
|
||
|
||
|
||
def test_const_rule_b_carried_from_base_passes():
|
||
# (b) 沿用:现行配方里同一分段同一常量列的 pick 逐字相同,且仍是新标题的子串
|
||
assert codes(d09(), origin="manual", base=flow()) == []
|
||
# base 给 canonical 形式也一样
|
||
assert codes(d09(), origin="manual", base=canonical_recipe(Recipe.model_validate(flow()))) == []
|
||
|
||
|
||
def test_const_rule_b_negative_when_base_pick_differs():
|
||
base = flow()
|
||
segs(base)[1]["const"]["时段类别"]["pick"] = "白天"
|
||
segs(base)[1]["locate"]["title"] = "白天时段客流(人次)"
|
||
assert codes(d09(), origin="manual", base=base) == [("const_not_candidate", f"{SEG}/1/const/时段类别/pick")]
|
||
|
||
|
||
def test_const_rule_b_negative_when_pick_not_in_new_title():
|
||
r = flow()
|
||
segs(r)[1]["locate"]["title"] = "白天时段客流(人次)"
|
||
# 现行配方的 pick「日间」逐字相同,但已不是新标题的子串:不放行
|
||
assert codes(r, origin="manual", base=flow()) == [("const_not_candidate", f"{SEG}/1/const/时段类别/pick")]
|
||
|
||
|
||
def test_const_rule_b_negative_when_base_segment_id_differs():
|
||
base = flow()
|
||
segs(base)[1]["id"] = "白天段"
|
||
segs(base)[3]["locate"]["segment"] = "夜间"
|
||
assert codes(d09(), origin="manual", base=base) == [("const_not_candidate", f"{SEG}/1/const/时段类别/pick")]
|
||
|
||
|
||
def test_const_rule_c_replay_substring_passes_and_negative():
|
||
# (c) 重放:修复后的配方下个月按 replay 校验、又没有 base
|
||
assert codes(d09(), origin="replay") == []
|
||
r = flow()
|
||
segs(r)[1]["locate"]["title"] = "白天时段客流(人次)"
|
||
assert codes(r, origin="replay") == [("const_not_candidate", f"{SEG}/1/const/时段类别/pick")]
|
||
|
||
|
||
@pytest.mark.parametrize("kw", [{"origin": "replay"}, {"origin": "manual", "base": FLOW}])
|
||
def test_const_with_digits_still_period_literal(kw):
|
||
r = flow()
|
||
segs(r)[1]["locate"]["title"] = "日间9时段客流(人次)"
|
||
segs(r)[1]["const"]["时段类别"]["pick"] = "日间9"
|
||
base = copy.deepcopy(r) if "base" in kw else None
|
||
kw = {**kw, "base": base} if base is not None else kw
|
||
assert codes(r, **kw) == [("period_literal", f"{SEG}/1/const/时段类别/pick")]
|
||
|
||
|
||
# ==========================================================================
|
||
# 3.3 第 11 条:认领只查改动过的部分
|
||
# ==========================================================================
|
||
|
||
|
||
def test_unchanged_measures_do_not_check_claims_with_base():
|
||
"""D04 的修复改的是 dimension 段:本期 R1 有一天不成立、系统发现里没有 F1 时,R1 的认领不查。"""
|
||
r = flow()
|
||
segs(r)[1]["labels"]["expect"] = segs(r)[1]["labels"]["expect"][1:] # 去掉 7-8
|
||
# 本期只发现了口径不同那一条,编号还错开成了 F1
|
||
f_ne = Fact("F1", "not_equal_sum", "客流汇总", F2.text, dict(F2.detail))
|
||
facts = DraftFacts(facts=[f_ne])
|
||
assert codes(r, facts=facts, origin="manual", base=flow()) == []
|
||
# 没有 base(首次导入)照期 2 全查
|
||
got = codes(r, facts=facts, origin="manual")
|
||
assert ("fact_claim_mismatch", "/relations/0/claims") in got
|
||
|
||
|
||
def test_changed_measures_still_check_claims_with_base():
|
||
"""D13 给日客流加了分区丙:R1 的成员落在改动过的段上,照查,对不上本期的新事实。"""
|
||
r = flow()
|
||
seg = segs(r)[0]
|
||
seg["labels"]["expect"].append("分区丙(人次)")
|
||
seg["measures"]["分区丙(人次)"] = "分区丙"
|
||
r["tables"][0]["units"]["分区丙"] = "人次"
|
||
got = codes(r, facts=DraftFacts(facts=[F1_C, F2]), origin="manual", base=flow())
|
||
assert got == [("fact_claim_mismatch", "/relations/0/claims")]
|
||
# 按事实更新成员之后通过
|
||
r["relations"][0]["parts"] = ["分区甲", "分区乙", "分区丙"]
|
||
assert codes(r, facts=DraftFacts(facts=[F1_C, F2]), origin="manual", base=flow()) == []
|
||
|
||
|
||
def test_measures_locate_change_alone_still_checks_claims_with_base():
|
||
"""measures 段只改了定位方式(expect、measures 都没变,改按分段标题定位):仍算改动过的段(3.3 第 11 条的
|
||
「locate 任一不同」),这一段上的关系认领照查。本期的发现里没有 R1 认领的那条 sum_eq(编号错开成口径不同那条)。"""
|
||
r = flow()
|
||
assert segs(r)[0]["locate"]["by"] == "labels"
|
||
segs(r)[0]["locate"] = {"by": "section_title", "title": "日客流汇总", "segment": None}
|
||
f_ne = Fact("F1", "not_equal_sum", "客流汇总", F2.text, dict(F2.detail))
|
||
facts = DraftFacts(facts=[f_ne])
|
||
got = codes(r, facts=facts, origin="manual", base=flow())
|
||
assert ("fact_claim_mismatch", "/relations/0/claims") in got
|
||
assert ("fact_claim_mismatch", "/relations/1/claims") in got
|
||
# 对照:定位方式没变时这两条关系按重放处理,不查认领
|
||
assert codes(flow(), facts=facts, origin="manual", base=flow()) == []
|
||
|
||
|
||
def test_fact_with_a_removed_label_is_located_through_the_base_recipe():
|
||
"""去掉了「分区乙」(① remove_label):本期的事实 F1 还写着分区乙,分段 id 又是规则起草的写法,在工作配方里
|
||
按 id、按全部标签都认不出。按现行配方认出它落在「日客流」上、这一段改动过,F1 在改动范围内:没人认领时报
|
||
fact_unclaimed,不让它连同被删掉的关系一起不声不响地消失。"""
|
||
r = flow()
|
||
seg = segs(r)[0]
|
||
seg["labels"]["expect"].remove("分区乙(人次)")
|
||
del seg["measures"]["分区乙(人次)"]
|
||
del r["tables"][0]["units"]["分区乙"]
|
||
del r["relations"][0]
|
||
drafted = Fact("F1", "sum_eq", "客流汇总", F1.text, {**F1.detail, "segment": "客流汇总_按日"})
|
||
f_ne = Fact("F2", "not_equal_sum", "客流汇总", F2.text, dict(F2.detail))
|
||
got = codes(r, facts=DraftFacts(facts=[drafted, f_ne]), origin="manual", base=flow())
|
||
assert got == [("fact_unclaimed", "/relations")]
|
||
# 分区甲、分区乙都换成了分区丙:工作配方里只剩「全日客流」一个标签对得上,过半命中也认不出,只能靠现行配方
|
||
seg["labels"]["expect"] = ["全日客流(人次)", "分区丙(人次)"]
|
||
seg["measures"] = {"全日客流(人次)": "全日客流", "分区丙(人次)": "分区丙"}
|
||
r["tables"][0]["units"] = {"全日客流": "人次", "分区丙": "人次"}
|
||
got = codes(r, facts=DraftFacts(facts=[drafted, f_ne]), origin="manual", base=flow())
|
||
assert got == [("fact_unclaimed", "/relations")]
|
||
|
||
|
||
def test_fact_located_by_most_labels_when_neither_recipe_has_all_of_them():
|
||
"""同时加了「分区丙」、去掉了「分区乙」:事实「全日 = 甲 + 乙 + 丙」在工作配方和现行配方里都按全部标签认不出,
|
||
按「过半标签命中」认出落在改动过的「日客流」上,没人认领时报 fact_unclaimed;只命中一个标签时不认,
|
||
按重放处理(不在改动范围内,不报)。"""
|
||
r = flow()
|
||
seg = segs(r)[0]
|
||
seg["labels"]["expect"] = ["全日客流(人次)", "分区甲(人次)", "分区丙(人次)"]
|
||
seg["measures"] = {"全日客流(人次)": "全日客流", "分区甲(人次)": "分区甲", "分区丙(人次)": "分区丙"}
|
||
r["tables"][0]["units"] = {"全日客流": "人次", "分区甲": "人次", "分区丙": "人次"}
|
||
del r["relations"][0]
|
||
f_ne = Fact("F2", "not_equal_sum", "客流汇总", F2.text, dict(F2.detail))
|
||
mixed = Fact("F7", "sum_eq", "客流汇总", "…", {"segment": "客流汇总_按日", "total": "全日客流(人次)",
|
||
"parts": ["分区甲(人次)", "分区乙(人次)", "分区丙(人次)"]})
|
||
assert codes(r, facts=DraftFacts(facts=[mixed, f_ne]), origin="manual", base=flow()) == [
|
||
("fact_unclaimed", "/relations")]
|
||
stray = Fact("F7", "sum_eq", "客流汇总", "…", {"segment": "别处", "total": "全日客流(人次)",
|
||
"parts": ["别处甲", "别处乙"]})
|
||
assert codes(r, facts=DraftFacts(facts=[stray, f_ne]), origin="manual", base=flow()) == []
|
||
|
||
|
||
def test_changed_relation_is_checked_even_if_segments_unchanged():
|
||
r = flow()
|
||
r["relations"][0]["claims"] = "F9"
|
||
got = codes(r, facts=DraftFacts(facts=[F1, F2]), origin="manual", base=flow())
|
||
assert ("fact_claim_mismatch", "/relations/0/claims") in got
|
||
|
||
|
||
def test_fact_on_changed_segment_must_be_claimed():
|
||
"""新发现落在改动过的 measures 段上、又没有人认领:fact_unclaimed 照报。"""
|
||
r = flow()
|
||
segs(r)[0]["labels"]["expect"].append("分区丙(人次)")
|
||
segs(r)[0]["measures"]["分区丙(人次)"] = "分区丙"
|
||
r["tables"][0]["units"]["分区丙"] = "人次"
|
||
extra = Fact("F3", "sum_eq", "客流汇总", "第 8 行 = 第 6 行 + 第 7 行(30 列中 30 列成立)",
|
||
{"segment": "日客流", "total": "分区丙(人次)", "parts": ["分区甲(人次)", "分区乙(人次)"],
|
||
"rows": [8, 6, 7]})
|
||
got = codes(r, facts=DraftFacts(facts=[F1, F2, extra]), origin="manual", base=flow())
|
||
assert ("fact_unclaimed", "/relations") in got
|
||
# 落在没改过的段上的新发现:不查
|
||
got2 = codes(flow(), facts=DraftFacts(facts=[F1, F2, extra]), origin="manual", base=flow())
|
||
assert got2 == []
|
||
|
||
|
||
def test_invalid_base_is_ignored():
|
||
assert codes(d09(), origin="manual", base={"recipe_format": "x"}) == [
|
||
("const_not_candidate", f"{SEG}/1/const/时段类别/pick")]
|
||
|
||
|
||
# ==========================================================================
|
||
# 9.2 period_literal 的扩展:after_title 和忽略规则的锚点(origin 不是 replay 时)
|
||
# ==========================================================================
|
||
|
||
|
||
def test_after_title_with_digits_is_period_literal_except_replay():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["after_title"] = "2026年8月 销售月报"
|
||
assert codes(r) == [("period_literal", "/sheets/0/blocks/0/after_title")]
|
||
assert codes(r, origin="replay") == []
|
||
r["sheets"][0]["blocks"][0]["after_title"] = "销售月报"
|
||
assert codes(r) == []
|
||
|
||
|
||
@pytest.mark.parametrize("where,item,path", [
|
||
("ignore_rows", {"label": "注:9月补录", "reason": "补录不导入"}, "/sheets/0/blocks/0/ignore_rows/0/label"),
|
||
("ignore_columns", {"header": "2026合计", "reason": "合计列"}, "/sheets/0/blocks/0/ignore_columns/0/header"),
|
||
("ignore_outside", {"anchor": "9月补录说明", "reason": "说明行"}, "/sheets/0/ignore_outside/0/anchor"),
|
||
])
|
||
def test_ignore_anchor_with_digits_is_period_literal(where, item, path):
|
||
r = flow()
|
||
target = r["sheets"][0] if where == "ignore_outside" else r["sheets"][0]["blocks"][0]
|
||
target[where] = [item]
|
||
assert codes(r) == [("period_literal", path)]
|
||
assert codes(r, origin="replay") == []
|
||
item = {k: (v.replace("9", "").replace("2026", "") if k != "reason" else v) for k, v in item.items()}
|
||
target[where] = [item]
|
||
assert codes(r) == []
|
||
|
||
|
||
# ==========================================================================
|
||
# 9.2 ignore_conflict、ignore_duplicate
|
||
# ==========================================================================
|
||
|
||
|
||
def test_ignore_row_that_a_segment_expects_conflicts():
|
||
r = flow()
|
||
r["sheets"][0]["blocks"][0]["ignore_rows"] = [{"label": "分区甲(人次)", "reason": "不要"}]
|
||
assert codes(r) == [("ignore_conflict", "/sheets/0/blocks/0/ignore_rows/0/label")]
|
||
# 时段按解析后的规范写法比:全角写法也算同一个标签
|
||
r["sheets"][0]["blocks"][0]["ignore_rows"] = [{"label": "8-9", "reason": "不要"}]
|
||
assert ("ignore_conflict", "/sheets/0/blocks/0/ignore_rows/0/label") in codes(r)
|
||
r["sheets"][0]["blocks"][0]["ignore_rows"] = [{"label": "补录(人次)", "reason": "补录不导入"}]
|
||
assert codes(r) == []
|
||
|
||
|
||
def test_crosstab_ignore_column_that_is_a_date_conflicts():
|
||
r = flow()
|
||
r["sheets"][0]["blocks"][0]["ignore_columns"] = [{"header": "九月合计", "reason": "合计列"}]
|
||
assert codes(r) == []
|
||
r["sheets"][0]["blocks"][0]["ignore_columns"] = [{"header": "九月合计", "reason": "合计列"},
|
||
{"header": "九月一日", "reason": "x"}]
|
||
assert codes(r) == []
|
||
r["sheets"][0]["blocks"][0]["ignore_columns"] = [{"header": "九月合计", "reason": "合计列"},
|
||
{"header": "9月1日", "reason": "x"}]
|
||
got = codes(r, origin="replay")
|
||
assert got == [("ignore_conflict", "/sheets/0/blocks/0/ignore_columns/1/header")]
|
||
|
||
|
||
def test_list_ignore_column_same_as_imported_header_conflicts():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["ignore_columns"] = [{"header": "销 量", "reason": "不要"}]
|
||
assert codes(r) == [("ignore_conflict", "/sheets/0/blocks/0/ignore_columns/0/header")]
|
||
r["sheets"][0]["blocks"][0]["ignore_columns"] = [{"header": "备注", "reason": "不要"}]
|
||
assert codes(r) == []
|
||
|
||
|
||
@pytest.mark.parametrize("where,key,a,b", [
|
||
("ignore_rows", "label", "补录(人次)", "补录(人次)"),
|
||
("ignore_columns", "header", "合计", " 合 计 "),
|
||
("ignore_outside", "anchor", "补录说明", "补录说明"),
|
||
])
|
||
def test_ignore_duplicate(where, key, a, b):
|
||
r = flow()
|
||
target = r["sheets"][0] if where == "ignore_outside" else r["sheets"][0]["blocks"][0]
|
||
target[where] = [{key: a, "reason": "一"}, {key: b, "reason": "二"}]
|
||
prefix = "/sheets/0" if where == "ignore_outside" else "/sheets/0/blocks/0"
|
||
assert codes(r) == [("ignore_duplicate", f"{prefix}/{where}/1/{key}")]
|
||
|
||
|
||
def test_new_problem_messages_do_not_expose_field_names():
|
||
r = flow()
|
||
r["sheets"][0]["blocks"][0]["ignore_rows"] = [{"label": "分区甲(人次)", "reason": "x"},
|
||
{"label": "分区甲(人次)", "reason": "y"}]
|
||
_rec, problems = validate_recipe(r)
|
||
for p in problems:
|
||
assert not any(k in p.message for k in ("ignore_rows", "label", "reason")), p.message
|