mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:12:01 +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(部署本来就是单进程)
1127 lines
46 KiB
Python
1127 lines
46 KiB
Python
"""配方静态校验(recipe.py,P2-SPEC 2.2、2.3):每条规则一正一反,反例断言 code 和 path。
|
||
|
||
只用契约里的参考配方(flow_recipe.json)和在测试里手写的小配方,不读任何 Excel。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.data.recipe import (
|
||
RecipeInvalid,
|
||
apply_patch,
|
||
canonical_recipe,
|
||
parse_recipe,
|
||
recipe_sha256,
|
||
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 行(31 列中 31 列成立)",
|
||
{"segment": "日客流", "total": "全日客流(人次)", "parts": ["分区甲(人次)", "分区乙(人次)"], "rows": [5, 6, 7]})
|
||
F2 = Fact("F2", "not_equal_sum", "客流汇总", "日间、夜间各行之和 与 第 5 行(31 列中 0 列相等)",
|
||
{"a": ["日间", "夜间"], "b_segment": "日客流", "b": "全日客流(人次)", "equal": 0, "checked": 31})
|
||
FACTS = DraftFacts(facts=[F1, F2])
|
||
|
||
|
||
def flow() -> dict:
|
||
return copy.deepcopy(FLOW)
|
||
|
||
|
||
def segs(r: dict) -> list[dict]:
|
||
return r["sheets"][0]["blocks"][0]["segments"]
|
||
|
||
|
||
def table(r: dict, name: str) -> dict:
|
||
return next(t for t in r["tables"] if t["name"] == name)
|
||
|
||
|
||
def list_recipe() -> dict:
|
||
"""lab c01 那种列表:表头 地区 / 产品 / 销量 / 金额(万元),合计行另存。"""
|
||
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": "TEXT"},
|
||
{"header": "销量", "name": "销量", "type": "INTEGER"},
|
||
{"header": "金额(万元)", "name": "金额", "type": "REAL"}],
|
||
"rows": {"total_row": {"label_column": "地区", "pick": "合计", "keep_as": "销售_表内合计"}}}]}],
|
||
"tables": [{"name": "销售", "grain": ["地区", "产品"], "units": {"金额": "万元"}},
|
||
{"name": "销售_表内合计", "kind": "reported_total", "grain": ["合计项"], "units": {"金额": "万元"}}],
|
||
}
|
||
|
||
|
||
def run(data, *, facts=FACTS, origin="manual"):
|
||
_recipe, problems = validate_recipe(data, facts=facts, origin=origin)
|
||
return problems
|
||
|
||
|
||
def only(data, code, path=None, **kw):
|
||
"""恰好一条问题,code(和 path)对得上:反例不能连带一串别的问题,否则看不出规则本身对不对。"""
|
||
problems = run(data, **kw)
|
||
assert [p.code for p in problems] == [code], [(p.code, p.path, p.message) for p in problems]
|
||
if path is not None:
|
||
assert problems[0].path == path
|
||
return problems[0]
|
||
|
||
|
||
def has(data, code, path=None, **kw):
|
||
problems = run(data, **kw)
|
||
hits = [p for p in problems if p.code == code and (path is None or p.path == path)]
|
||
assert hits, [(p.code, p.path, p.message) for p in problems]
|
||
return hits[0]
|
||
|
||
|
||
# ==========================================================================
|
||
# 参考配方
|
||
# ==========================================================================
|
||
|
||
|
||
@pytest.mark.parametrize("origin", ["rules", "ai", "manual", "replay"])
|
||
def test_reference_recipe_has_no_problems(origin):
|
||
recipe, problems = validate_recipe(flow(), facts=FACTS, origin=origin)
|
||
assert problems == []
|
||
assert isinstance(recipe, Recipe)
|
||
|
||
|
||
def test_reference_recipe_without_facts_and_as_model():
|
||
assert run(flow(), facts=None) == []
|
||
model = Recipe.model_validate(flow())
|
||
recipe, problems = validate_recipe(model)
|
||
assert recipe is model and problems == []
|
||
|
||
|
||
def test_list_recipe_has_no_problems():
|
||
assert run(list_recipe(), facts=None) == []
|
||
|
||
|
||
# ==========================================================================
|
||
# schema(规则 1、5、7):parse_recipe 转 pydantic 的错误
|
||
# ==========================================================================
|
||
|
||
|
||
def test_closed_language_extra_fields_are_schema_problems_with_clean_paths():
|
||
r = flow()
|
||
r["sheets"][0]["regex"] = "x"
|
||
segs(r)[1]["const"]["时段类别"] = {"value": "日间"}
|
||
recipe, problems = validate_recipe(r)
|
||
assert recipe is None
|
||
assert {p.code for p in problems} == {"schema"}
|
||
# loc 里 pydantic 插的判别字段(crosstab、dimension)去掉,path 对得上配方里的位置
|
||
assert {p.path for p in problems} == {"/sheets/0/regex", f"{SEG}/1/const/时段类别/value",
|
||
f"{SEG}/1/const/时段类别/pick"}
|
||
by_path = {p.path: p.message for p in problems}
|
||
assert by_path["/sheets/0/regex"] == "第 1 个工作表「客流汇总」:有配方语言不支持的字段「regex」"
|
||
assert by_path[f"{SEG}/1/const/时段类别/pick"].startswith("第 1 个工作表「客流汇总」的分段「日间」:")
|
||
|
||
|
||
@pytest.mark.parametrize("mutate,path", [
|
||
(lambda r: r.__setitem__("recipe_format", "agentlab-recipe/1"), "/recipe_format"),
|
||
(lambda r: segs(r)[1]["dim"].__setitem__("parser", "regex"), f"{SEG}/1/dim/parser"),
|
||
(lambda r: r["relations"][0].__setitem__("id", "X1"), "/relations/0/id"),
|
||
(lambda r: segs(r)[0].pop("measures"), f"{SEG}/0/measures"),
|
||
(lambda r: r["sheets"][0]["blocks"][0].__setitem__("layout", "grid"), "/sheets/0/blocks/0"),
|
||
(lambda r: r["sheets"][0]["blocks"][0]["values"]["placeholders"][0].__setitem__("to", 0),
|
||
"/sheets/0/blocks/0/values/placeholders/0/to"),
|
||
])
|
||
def test_schema_problems_point_at_the_field(mutate, path):
|
||
r = flow()
|
||
mutate(r)
|
||
recipe, problems = validate_recipe(r)
|
||
assert recipe is None
|
||
assert [(p.code, p.path) for p in problems] == [("schema", path)]
|
||
assert ":" in problems[0].message
|
||
|
||
|
||
def test_parse_recipe_raises_with_problems():
|
||
with pytest.raises(RecipeInvalid) as info:
|
||
parse_recipe({"recipe_format": "agentlab-recipe/2"})
|
||
assert {p.code for p in info.value.problems} == {"schema"}
|
||
assert {p.path for p in info.value.problems} == {"/sheets", "/tables"}
|
||
with pytest.raises(RecipeInvalid):
|
||
parse_recipe(["not", "a", "dict"]) # type: ignore[arg-type]
|
||
assert isinstance(parse_recipe(flow()), Recipe)
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 2:常量只能 pick 分段标题的候选词
|
||
# ==========================================================================
|
||
|
||
|
||
def test_const_pick_must_be_a_candidate_word():
|
||
r = flow()
|
||
segs(r)[2]["const"]["时段类别"]["pick"] = "晚上"
|
||
p = only(r, "const_not_candidate", f"{SEG}/2/const/时段类别/pick")
|
||
assert p.message == ("第 1 个工作表「客流汇总」的分段「夜间」:常量「时段类别」的取值「晚上」不在分段标题"
|
||
"「夜间时段客流(人次)」的候选词中,可选:夜间、夜间时段客流")
|
||
|
||
|
||
def test_const_pick_other_candidate_is_fine():
|
||
r = flow()
|
||
segs(r)[2]["const"]["时段类别"]["pick"] = "夜间时段客流"
|
||
assert run(r) == []
|
||
|
||
|
||
def test_const_needs_a_section_title():
|
||
r = flow()
|
||
segs(r)[1]["locate"] = {"by": "labels"}
|
||
has(r, "const_not_candidate", f"{SEG}/1/const/时段类别/pick")
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 3:占位符不能像数
|
||
# ==========================================================================
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["0", "-1", "1,234", "12.5%"])
|
||
def test_placeholder_must_not_look_numeric(text):
|
||
r = flow()
|
||
r["sheets"][0]["blocks"][0]["values"]["placeholders"][0]["text"] = text
|
||
only(r, "placeholder_numeric", "/sheets/0/blocks/0/values/placeholders/0/text")
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["·", "—", "无", "N/A"])
|
||
def test_placeholder_text_ok(text):
|
||
r = flow()
|
||
r["sheets"][0]["blocks"][0]["values"]["placeholders"][0]["text"] = text
|
||
assert run(r) == []
|
||
|
||
|
||
def test_list_placeholder_must_not_look_numeric():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["values"] = {"placeholders": [{"text": "0"}]}
|
||
only(r, "placeholder_numeric", "/sheets/0/blocks/0/values/placeholders/0/text", facts=None)
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 4:单位封闭词表
|
||
# ==========================================================================
|
||
|
||
|
||
def test_unit_must_be_in_vocabulary():
|
||
r = flow()
|
||
# 标签不带单位,只看词表这一条
|
||
seg = segs(r)[0]
|
||
seg["labels"]["expect"][0] = "全日客流"
|
||
seg["measures"] = {"全日客流": "全日客流", "分区甲(人次)": "分区甲", "分区乙(人次)": "分区乙"}
|
||
table(r, "日客流")["units"]["全日客流"] = "人头"
|
||
p = only(r, "unit_unknown", "/tables/0/units/全日客流", facts=None)
|
||
assert p.message == "表「日客流」:列「全日客流」的单位「人头」不在单位词表中"
|
||
|
||
|
||
def test_unit_in_vocabulary_ok():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["columns"][2]["header"] = "销量(件)"
|
||
r["tables"][0]["units"]["销量"] = "件"
|
||
r["tables"][1]["units"]["销量"] = "件"
|
||
assert run(r, facts=None) == []
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 5:解析器固定集合(标签解析不了的、重复的在保存时就报)
|
||
# ==========================================================================
|
||
|
||
|
||
def test_dimension_labels_must_parse():
|
||
r = flow()
|
||
segs(r)[1]["labels"]["expect"][1] = "8:30-9:00"
|
||
only(r, "label_unparsed", f"{SEG}/1/labels/expect/1")
|
||
|
||
|
||
def test_derived_labels_must_parse_as_totals():
|
||
r = flow()
|
||
segs(r)[3]["labels"]["expect"][0] = "18-22"
|
||
only(r, "label_unparsed", f"{SEG}/3/labels/expect/0")
|
||
|
||
|
||
def test_duplicate_labels_by_canonical_form():
|
||
r = flow()
|
||
segs(r)[1]["labels"]["expect"][1] = "07:00-08:00" # 规范写法是 7-8,与第一项重复
|
||
only(r, "label_duplicate", f"{SEG}/1/labels/expect/1")
|
||
|
||
|
||
def test_text_dimension_labels_ok():
|
||
"""文本维度:标签只要非空、不超长;不能派生起止小时。日间改成文本维度、单独成表。"""
|
||
r = flow()
|
||
seg = segs(r)[1]
|
||
seg.update(table="日间客流", dim={"name": "时段", "parser": "text"}, const={})
|
||
seg["labels"]["expect"] = ["早上", "中午", "下午"]
|
||
r["tables"].insert(1, {"name": "日间客流", "grain": ["日期", "时段"], "units": {"客流": "人次"}})
|
||
# 夜间独占「时段客流」后没有兄弟标题,候选词只剩整个标题
|
||
has(r, "const_not_candidate", f"{SEG}/2/const/时段类别/pick")
|
||
segs(r)[2]["const"]["时段类别"]["pick"] = "夜间时段客流"
|
||
assert run(r) == []
|
||
seg["labels"]["expect"].append("很" * 41)
|
||
only(r, "label_unparsed", f"{SEG}/1/labels/expect/3")
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 6:不能写死年份或统计期
|
||
# ==========================================================================
|
||
|
||
|
||
def test_const_pick_with_digits_is_period_literal():
|
||
r = flow()
|
||
segs(r)[1]["const"]["时段类别"]["pick"] = "2026日间"
|
||
only(r, "period_literal", f"{SEG}/1/const/时段类别/pick")
|
||
|
||
|
||
def test_prefer_prefix_with_digits_is_period_literal():
|
||
r = flow()
|
||
r["sheets"][0]["context"][0]["prefer_prefix"] = "2026年统计"
|
||
only(r, "period_literal", "/sheets/0/context/0/prefer_prefix")
|
||
|
||
|
||
def test_total_row_pick_with_digits_is_period_literal():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["rows"]["total_row"]["pick"] = "2026合计"
|
||
only(r, "period_literal", "/sheets/0/blocks/0/rows/total_row/pick", facts=None)
|
||
|
||
|
||
def test_year_in_list_header_is_not_an_error():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["columns"][2]["header"] = "2026年上半年_销量"
|
||
assert run(r, facts=None) == []
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 7:格式版本
|
||
# ==========================================================================
|
||
|
||
|
||
def test_accumulate_mode_follows_eligibility():
|
||
"""期 3 改写(P3-SPEC 2.2):按期累积不再一律报 mode_unsupported,改按资格判(契约 accumulate_blockers)。"""
|
||
r = flow()
|
||
r["mode"] = "accumulate"
|
||
assert run(r) == [] # 参考配方符合资格:交叉表、有统计期、covers_context、grain 含日期
|
||
# 去掉统计期:accumulate_needs_period(同时断言 covers_context、补年份都要求统计期,照期 2 报)
|
||
r2 = flow()
|
||
r2["mode"] = "accumulate"
|
||
r2["sheets"][0]["context"] = []
|
||
codes = [p.code for p in run(r2)]
|
||
assert "accumulate_needs_period" in codes and "mode_unsupported" not in codes
|
||
# 列表写入的表:accumulate_unkeyed,path 指向那张表
|
||
lr = list_recipe()
|
||
lr["mode"] = "accumulate"
|
||
lr["sheets"][0]["context"] = [{}]
|
||
probs = run(lr, facts=None)
|
||
assert [(p.code, p.path) for p in probs] == [("accumulate_unkeyed", "/tables/0"),
|
||
("accumulate_unkeyed", "/tables/1")]
|
||
assert "列表形态的表目前只支持每期替换" in probs[0].message
|
||
# 同一份配方每期替换时不报
|
||
lr["mode"] = "replace"
|
||
assert run(lr, facts=None) == []
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 8:名字、键唯一
|
||
# ==========================================================================
|
||
|
||
|
||
def test_table_name_must_be_valid():
|
||
r = flow()
|
||
table(r, "时段客流_表内合计")["name"] = "select"
|
||
segs(r)[3]["keep_as"]["table"] = "select"
|
||
p = only(r, "name_invalid", "/tables/2/name")
|
||
assert "关键字" in p.message
|
||
|
||
|
||
def test_column_name_must_be_valid():
|
||
r = flow()
|
||
for seg in segs(r)[1:3]:
|
||
seg["const"] = {"1类别": seg["const"]["时段类别"]}
|
||
problems = run(r)
|
||
assert [(p.code, p.path) for p in problems] == [
|
||
("name_invalid", f"{SEG}/1/const/1类别"), ("name_invalid", f"{SEG}/2/const/1类别")]
|
||
|
||
|
||
def test_column_names_collide_within_a_table():
|
||
r = flow()
|
||
for seg in segs(r)[1:3]:
|
||
seg["const"] = {"时段": seg["const"]["时段类别"]}
|
||
problems = run(r)
|
||
assert [(p.code, p.path) for p in problems] == [
|
||
("name_collision", f"{SEG}/1/const/时段"), ("name_collision", f"{SEG}/2/const/时段")]
|
||
|
||
|
||
def test_column_names_collide_case_insensitively():
|
||
r = list_recipe()
|
||
cols = r["sheets"][0]["blocks"][0]["columns"]
|
||
cols[0]["name"], cols[1]["name"] = "Area", "area"
|
||
r["sheets"][0]["blocks"][0]["rows"]["total_row"]["label_column"] = "Area"
|
||
r["tables"][0]["grain"] = ["Area"]
|
||
only(r, "name_collision", "/sheets/0/blocks/0/columns/1/name", facts=None)
|
||
|
||
|
||
def test_table_names_collide():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["rows"]["total_row"]["keep_as"] = "SALES"
|
||
r["sheets"][0]["blocks"][0]["table"] = "sales"
|
||
r["tables"][0]["name"] = "sales"
|
||
r["tables"][1]["name"] = "SALES"
|
||
problems = run(r, facts=None)
|
||
assert ("name_collision", "/tables/1/name") in [(p.code, p.path) for p in problems]
|
||
|
||
|
||
def _second_sheet(r: dict, block: dict, tables: list[dict]) -> dict:
|
||
r["sheets"].append({"id": "s2", "match": {"name": "附表"}, "blocks": [block]})
|
||
r["tables"] += tables
|
||
return r
|
||
|
||
|
||
def _extra_measures(seg_id: str, block_id: str = "交叉表2") -> tuple[dict, list[dict]]:
|
||
block = {"id": block_id, "layout": "crosstab", "axis": {"year_from": None, "checks": ["contiguous"]},
|
||
"segments": [{"id": seg_id, "role": "measures", "table": "附表客流", "locate": {"by": "labels"},
|
||
"labels": {"expect": ["附表客流"]}, "measures": {"附表客流": "附表客流"}}]}
|
||
return block, [{"name": "附表客流", "grain": ["日期"]}]
|
||
|
||
|
||
def test_second_sheet_ok():
|
||
block, tables = _extra_measures("附表")
|
||
assert run(_second_sheet(flow(), block, tables)) == []
|
||
|
||
|
||
def test_segment_id_unique_across_blocks():
|
||
block, tables = _extra_measures("日间")
|
||
only(_second_sheet(flow(), block, tables), "segment_id_duplicate", "/sheets/1/blocks/0/segments/0/id")
|
||
|
||
|
||
def test_block_id_unique_across_sheets():
|
||
block, tables = _extra_measures("附表", block_id="交叉表")
|
||
only(_second_sheet(flow(), block, tables), "block_id_duplicate", "/sheets/1/blocks/0/id")
|
||
|
||
|
||
def test_block_id_must_not_reuse_a_segment_id():
|
||
block, tables = _extra_measures("附表", block_id="夜间")
|
||
only(_second_sheet(flow(), block, tables), "block_id_duplicate", "/sheets/1/blocks/0/id")
|
||
|
||
|
||
def test_sheet_id_and_sheet_name_unique():
|
||
block, tables = _extra_measures("附表")
|
||
r = _second_sheet(flow(), block, tables)
|
||
r["sheets"][1]["id"] = "s1"
|
||
only(r, "sheet_id_duplicate", "/sheets/1/id")
|
||
r["sheets"][1]["id"] = "s2"
|
||
r["sheets"][1]["match"]["name"] = "客流 汇总"
|
||
only(r, "sheet_name_duplicate", "/sheets/1/match/name")
|
||
|
||
|
||
def test_one_crosstab_per_sheet():
|
||
r = flow()
|
||
block, tables = _extra_measures("附表")
|
||
r["sheets"][0]["blocks"].append(block)
|
||
r["tables"] += tables
|
||
only(r, "crosstab_twice", "/sheets/0/blocks/1")
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 9:引用完整(逐字比较)
|
||
# ==========================================================================
|
||
|
||
|
||
def test_segment_table_must_match_verbatim():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["table"] = "Sales"
|
||
r["tables"][0]["name"] = "sales"
|
||
problems = run(r, facts=None)
|
||
assert [(p.code, p.path) for p in problems] == [("table_unknown", "/sheets/0/blocks/0/table"),
|
||
("table_unused", "/tables/0")]
|
||
assert "「sales」" in problems[0].message
|
||
|
||
|
||
def test_crosstab_segment_table_case_differs():
|
||
r = flow()
|
||
table(r, "日客流")["name"] = "sales"
|
||
segs(r)[0]["table"] = "Sales"
|
||
r["relations"][0]["table"] = r["relations"][1]["b"]["table"] = "sales"
|
||
p = has(r, "table_unknown", f"{SEG}/0/table")
|
||
assert "「sales」" in p.message
|
||
|
||
|
||
def test_crosstab_segment_table_unknown():
|
||
r = flow()
|
||
segs(r)[0]["table"] = "日客流表"
|
||
has(r, "table_unknown", f"{SEG}/0/table")
|
||
|
||
|
||
def test_keep_as_table_unknown():
|
||
r = flow()
|
||
segs(r)[3]["keep_as"]["table"] = "时段客流_合计"
|
||
problems = run(r)
|
||
assert [(p.code, p.path) for p in problems] == [("table_unknown", f"{SEG}/3/keep_as/table"),
|
||
("table_unused", "/tables/2")]
|
||
|
||
|
||
def test_verify_against_table_unknown_and_invalid():
|
||
r = flow()
|
||
segs(r)[3]["verify"]["against_table"] = "时段明细"
|
||
only(r, "table_unknown", f"{SEG}/3/verify/against_table")
|
||
r["sheets"][0]["blocks"][0]["segments"][3]["verify"]["against_table"] = "日客流"
|
||
only(r, "verify_against_invalid", f"{SEG}/3/verify/against_table")
|
||
|
||
|
||
def test_verify_value_must_be_the_value_column():
|
||
r = flow()
|
||
segs(r)[3]["verify"]["value"] = "人次"
|
||
only(r, "column_unknown", f"{SEG}/3/verify/value")
|
||
segs(r)[3]["verify"]["value"] = "起始小时"
|
||
only(r, "verify_against_invalid", f"{SEG}/3/verify/value")
|
||
|
||
|
||
@pytest.mark.parametrize("key", ["销 量", "Qty"])
|
||
def test_units_key_must_be_a_column_verbatim(key):
|
||
r = list_recipe()
|
||
if key == "Qty":
|
||
r["sheets"][0]["blocks"][0]["columns"][2]["name"] = "qty"
|
||
r["tables"][0]["units"][key] = "件"
|
||
only(r, "column_unknown", f"/tables/0/units/{key}", facts=None)
|
||
|
||
|
||
def test_units_key_with_extra_space_in_flow():
|
||
r = flow()
|
||
units = table(r, "日客流")["units"]
|
||
units["全日客流 "] = units.pop("全日客流")
|
||
has(r, "column_unknown", "/tables/0/units/全日客流 ")
|
||
|
||
|
||
def test_grain_column_unknown():
|
||
r = flow()
|
||
table(r, "时段客流")["grain"] = ["日期", "时段名"]
|
||
only(r, "column_unknown", "/tables/1/grain/1")
|
||
|
||
|
||
def test_relation_columns_and_types():
|
||
r = flow()
|
||
r["relations"][0]["parts"] = ["分区甲", "分区丁"]
|
||
has(r, "column_unknown", "/relations/0/parts/1")
|
||
r = flow()
|
||
r["relations"][0]["total"] = "日期"
|
||
has(r, "relation_invalid", "/relations/0/total", facts=None)
|
||
r = flow()
|
||
r["relations"][0]["table"] = "日客流表"
|
||
only(r, "table_unknown", "/relations/0/table", facts=None)
|
||
|
||
|
||
def test_not_comparable_by_must_exist_on_both_sides():
|
||
r = flow()
|
||
r["relations"][1]["by"] = "时段"
|
||
only(r, "column_unknown", "/relations/1/by")
|
||
|
||
|
||
def test_relation_ids_unique():
|
||
r = flow()
|
||
r["relations"][1]["id"] = "R1"
|
||
only(r, "relation_invalid", "/relations/1/id")
|
||
|
||
|
||
def test_total_row_label_column_must_exist():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["rows"]["total_row"]["label_column"] = "区域"
|
||
only(r, "column_unknown", "/sheets/0/blocks/0/rows/total_row/label_column", facts=None)
|
||
|
||
|
||
def ascii_flow() -> dict:
|
||
"""参考配方换成英文表名、列名(标签和分段 id 不动,FACTS 照样能认领)。
|
||
|
||
「差一点」的引用要两种各造一个才能把逐字比较钉死:只差 ASCII 大小写的(name_key 相同、match_key 不同)
|
||
和只差空白的(match_key 相同、name_key 不同)。中文名没有大小写,前一种只能用英文名造。
|
||
"""
|
||
r = flow()
|
||
s = segs(r)
|
||
r["sheets"][0]["blocks"][0]["axis"]["name"] = "Day"
|
||
s[0]["table"] = "Daily"
|
||
s[0]["measures"] = {"全日客流(人次)": "Total", "分区甲(人次)": "PartA", "分区乙(人次)": "PartB"}
|
||
for seg in s[1:3]:
|
||
seg.update(table="Hourly", value="Flow", const={"Kind": seg["const"]["时段类别"]})
|
||
seg["dim"].update(name="Slot", derive={"StartH": "start", "EndH": "end"})
|
||
s[3]["verify"].update(against_table="Hourly", value="Flow")
|
||
s[3]["keep_as"] = {"table": "Hourly_total", "dim": "Item", "derive": {"StartH": "start", "EndH": "end"},
|
||
"value": "Flow"}
|
||
r["tables"] = [
|
||
{"name": "Daily", "grain": ["Day"], "units": {"Total": "人次", "PartA": "人次", "PartB": "人次"}},
|
||
{"name": "Hourly", "grain": ["Day", "Slot"], "units": {"Flow": "人次"}},
|
||
{"name": "Hourly_total", "kind": "reported_total", "grain": ["Day", "Item"], "units": {"Flow": "人次"}},
|
||
]
|
||
r["relations"] = [
|
||
{"id": "R1", "kind": "sum_eq", "table": "Daily", "total": "Total", "parts": ["PartA", "PartB"], "claims": "F1"},
|
||
{"id": "R2", "kind": "not_comparable", "a": {"table": "Hourly", "value": "Flow"},
|
||
"b": {"table": "Daily", "value": "Total"}, "by": "Day", "claims": "F2"},
|
||
]
|
||
return r
|
||
|
||
|
||
def ascii_list() -> dict:
|
||
r = list_recipe()
|
||
block = r["sheets"][0]["blocks"][0]
|
||
block["columns"][0]["name"] = "Region"
|
||
block["rows"]["total_row"]["label_column"] = "Region"
|
||
r["tables"][0]["grain"] = ["Region", "产品"]
|
||
return r
|
||
|
||
|
||
#: 差一点的写法:只差大小写(name_key 当成同一个)、多一个空格(match_key 当成同一个)
|
||
NEAR = {"case": str.lower, "space": lambda s: s + " "}
|
||
|
||
|
||
def _get(doc, pointer: str):
|
||
for part in pointer.split("/")[1:]:
|
||
doc = doc[int(part)] if isinstance(doc, list) else doc[part]
|
||
return doc
|
||
|
||
|
||
def test_ascii_variants_have_no_problems():
|
||
assert run(ascii_flow()) == []
|
||
assert run(ascii_list(), facts=None) == []
|
||
|
||
|
||
@pytest.mark.parametrize("near", NEAR)
|
||
@pytest.mark.parametrize("base,pointer,expected", [
|
||
# 关系里的表和列(table_ref、numeric_col、not_comparable.by)
|
||
(ascii_flow, "/relations/0/table", [("table_unknown", "/relations/0/table")]),
|
||
(ascii_flow, "/relations/0/total", [("column_unknown", "/relations/0/total")]),
|
||
(ascii_flow, "/relations/0/parts/0", [("column_unknown", "/relations/0/parts/0")]),
|
||
(ascii_flow, "/relations/1/a/table", [("table_unknown", "/relations/1/a/table")]),
|
||
(ascii_flow, "/relations/1/b/table", [("table_unknown", "/relations/1/b/table")]),
|
||
(ascii_flow, "/relations/1/a/value", [("column_unknown", "/relations/1/a/value")]),
|
||
(ascii_flow, "/relations/1/b/value", [("column_unknown", "/relations/1/b/value")]),
|
||
(ascii_flow, "/relations/1/by", [("column_unknown", "/relations/1/by")]),
|
||
# 主键
|
||
(ascii_flow, "/tables/1/grain/1", [("column_unknown", "/tables/1/grain/1")]),
|
||
# 合计分段的核对基表、核对的值列、另存的表
|
||
(ascii_flow, f"{SEG}/3/verify/against_table", [("table_unknown", f"{SEG}/3/verify/against_table")]),
|
||
(ascii_flow, f"{SEG}/3/verify/value", [("column_unknown", f"{SEG}/3/verify/value")]),
|
||
(ascii_flow, f"{SEG}/3/keep_as/table", [("table_unknown", f"{SEG}/3/keep_as/table"),
|
||
("table_unused", "/tables/2")]),
|
||
# 分段写入的表:日客流一改,表清单里的 Daily 没人写,关系里的 Daily 也就不是配方产出的表
|
||
(ascii_flow, f"{SEG}/0/table", [("table_unknown", f"{SEG}/0/table"), ("table_unused", "/tables/0"),
|
||
("table_unknown", "/relations/0/table"),
|
||
("table_unknown", "/relations/1/b/table")]),
|
||
# 列表合计行的标签列
|
||
(ascii_list, "/sheets/0/blocks/0/rows/total_row/label_column",
|
||
[("column_unknown", "/sheets/0/blocks/0/rows/total_row/label_column")]),
|
||
])
|
||
def test_internal_references_are_verbatim(near, base, pointer, expected):
|
||
"""规则 9:配方内的引用逐字比较。每类引用各改成「差一点」的写法,必须报出来、且只报这些。"""
|
||
r = base()
|
||
wrong = NEAR[near](_get(r, pointer))
|
||
r = apply_patch(r, [{"op": "replace", "path": pointer, "value": wrong}])
|
||
problems = run(r, facts=None)
|
||
assert [(p.code, p.path) for p in problems] == expected, [(p.code, p.path, p.message) for p in problems]
|
||
|
||
|
||
@pytest.mark.parametrize("near", NEAR)
|
||
def test_verify_against_table_must_be_the_target_table_verbatim(near):
|
||
"""核对基表在表清单里、与紧邻分段写入的表只差大小写或空格:仍不是那张表(名字本身另报撞名或不合规)。"""
|
||
r = ascii_flow()
|
||
other = NEAR[near]("Hourly")
|
||
r["tables"].append({"name": other})
|
||
segs(r)[3]["verify"]["against_table"] = other
|
||
has(r, "verify_against_invalid", f"{SEG}/3/verify/against_table", facts=None)
|
||
|
||
|
||
def test_table_kind_must_match_writer():
|
||
r = flow()
|
||
table(r, "时段客流_表内合计")["kind"] = "data"
|
||
only(r, "table_kind_mismatch", "/tables/2/kind")
|
||
|
||
|
||
def test_declared_table_without_writer():
|
||
r = flow()
|
||
r["tables"].append({"name": "多余的表"})
|
||
only(r, "table_unused", "/tables/3")
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 10:结构一致
|
||
# ==========================================================================
|
||
|
||
|
||
def test_measures_keys_half_width_brackets():
|
||
r = flow()
|
||
segs(r)[0]["measures"] = {"全日客流(人次)": "全日客流", "分区甲(人次)": "分区甲", "分区乙(人次)": "分区乙"}
|
||
# 只报根因:引用「全日客流」的单位、关系不再连带报「列不存在」
|
||
p = only(r, "measures_keys_mismatch", f"{SEG}/0/measures")
|
||
assert "全日客流(人次)" in p.message
|
||
|
||
|
||
def test_table_shape_conflict():
|
||
r = flow()
|
||
segs(r)[2]["value"] = "人次数"
|
||
only(r, "table_shape_conflict", f"{SEG}/2")
|
||
|
||
|
||
def test_derived_must_follow_a_dimension_with_stop_parser():
|
||
r = flow()
|
||
segs(r)[2]["stop_parser"] = None
|
||
only(r, "derived_after_invalid", f"{SEG}/3/locate/segment")
|
||
r = flow()
|
||
segs(r)[3]["locate"]["segment"] = "日客流"
|
||
only(r, "derived_after_invalid", f"{SEG}/3/locate/segment")
|
||
r = flow()
|
||
segs(r)[3]["locate"]["segment"] = "不存在"
|
||
only(r, "derived_after_invalid", f"{SEG}/3/locate/segment")
|
||
r = flow()
|
||
segs(r)[3]["locate"] = {"by": "labels"}
|
||
only(r, "derived_after_invalid", f"{SEG}/3/locate/by")
|
||
|
||
|
||
def test_locate_rules_for_measures_and_dimension():
|
||
r = flow()
|
||
segs(r)[0]["locate"] = {"by": "after", "segment": "日间"}
|
||
has(r, "locate_invalid", f"{SEG}/0/locate/by")
|
||
r = flow()
|
||
segs(r)[1]["locate"] = {"by": "section_title"}
|
||
has(r, "locate_invalid", f"{SEG}/1/locate/title")
|
||
|
||
|
||
def test_crosstab_grain_must_contain_axis():
|
||
r = flow()
|
||
table(r, "日客流")["grain"] = []
|
||
only(r, "grain_invalid", "/tables/0/grain")
|
||
|
||
|
||
def test_derive_role_duplicate():
|
||
r = flow()
|
||
segs(r)[3]["keep_as"]["derive"] = {"起始小时": "start", "结束小时": "start"}
|
||
only(r, "derive_role_duplicate", f"{SEG}/3/keep_as/derive")
|
||
|
||
|
||
def test_derive_only_for_hour_range():
|
||
r = flow()
|
||
for seg in segs(r)[1:3]:
|
||
seg["dim"]["parser"] = "text"
|
||
has(r, "derive_invalid", f"{SEG}/1/dim/derive")
|
||
|
||
|
||
def test_overlapping_dimension_segments_need_a_distinguishing_const_in_grain():
|
||
r = flow()
|
||
segs(r)[1]["labels"]["expect"].append("18-19")
|
||
only(r, "grain_invalid", "/tables/1/grain")
|
||
table(r, "时段客流")["grain"] = ["日期", "时段", "时段类别"]
|
||
assert run(r) == []
|
||
|
||
|
||
def test_overlapping_segments_with_same_pick_still_invalid():
|
||
r = flow()
|
||
segs(r)[1]["labels"]["expect"].append("18-19")
|
||
segs(r)[2]["const"]["时段类别"]["pick"] = "夜间时段客流"
|
||
table(r, "时段客流")["grain"] = ["日期", "时段", "时段类别"]
|
||
assert run(r) == []
|
||
segs(r)[1]["locate"]["title"] = "夜间时段客流(人次)"
|
||
segs(r)[1]["const"]["时段类别"]["pick"] = "夜间时段客流"
|
||
has(r, "grain_invalid", "/tables/1/grain")
|
||
|
||
|
||
def test_covers_context_and_year_need_a_context():
|
||
r = flow()
|
||
r["sheets"][0]["context"] = []
|
||
problems = run(r)
|
||
assert [(p.code, p.path) for p in problems] == [
|
||
("covers_without_context", "/sheets/0/blocks/0/axis/checks"),
|
||
("covers_without_context", "/sheets/0/blocks/0/axis/year_from")]
|
||
r["sheets"][0]["blocks"][0]["axis"]["checks"] = ["contiguous"]
|
||
r["sheets"][0]["blocks"][0]["axis"]["year_from"] = None
|
||
assert run(r) == []
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 11:认领(计数与语义)
|
||
# ==========================================================================
|
||
|
||
|
||
def test_fact_unclaimed():
|
||
r = flow()
|
||
r["relations"][0]["claims"] = None
|
||
p = only(r, "fact_unclaimed", "/relations")
|
||
assert p.message == "系统发现的关系「第 5 行 = 第 6 行 + 第 7 行」没有被认领:请登记为每期核对,或说明不登记的理由"
|
||
|
||
|
||
def test_fact_claimed_twice():
|
||
r = flow()
|
||
r["relations"].append({"id": "R3", "kind": "sum_eq", "table": "日客流", "total": "全日客流",
|
||
"parts": ["分区乙", "分区甲"], "claims": "F1"})
|
||
only(r, "fact_claimed_twice", "/relations/2/claims")
|
||
|
||
|
||
def test_claim_of_unknown_fact():
|
||
r = flow()
|
||
r["relations"].append({"id": "R3", "kind": "dismissed", "claims": "F9", "reason": "巧合"})
|
||
only(r, "fact_claim_mismatch", "/relations/2/claims")
|
||
|
||
|
||
def test_claim_with_wrong_members():
|
||
r = flow()
|
||
r["relations"][0].update(total="分区甲", parts=["全日客流", "分区乙"])
|
||
p = only(r, "fact_claim_mismatch", "/relations/0/claims")
|
||
assert "「全日客流(人次)」" in p.message
|
||
|
||
|
||
def test_claim_with_wrong_kind():
|
||
r = flow()
|
||
r["relations"][0], r["relations"][1] = (
|
||
{"id": "R1", "kind": "sum_eq", "table": "日客流", "total": "全日客流", "parts": ["分区甲", "分区乙"], "claims": "F2"},
|
||
{**r["relations"][1], "claims": "F1"})
|
||
problems = run(r)
|
||
assert sorted((p.code, p.path) for p in problems) == [("fact_claim_mismatch", "/relations/0/claims"),
|
||
("fact_claim_mismatch", "/relations/1/claims")]
|
||
|
||
|
||
def test_not_comparable_sides_must_match_fact():
|
||
r = flow()
|
||
rel = r["relations"][1]
|
||
rel["a"], rel["b"] = rel["b"], rel["a"]
|
||
only(r, "fact_claim_mismatch", "/relations/1/claims")
|
||
r = flow()
|
||
r["relations"][1]["b"]["value"] = "分区甲"
|
||
only(r, "fact_claim_mismatch", "/relations/1/claims")
|
||
|
||
|
||
def test_sum_eq_claim_must_be_on_the_fact_segment_table():
|
||
"""另一张表的列名碰巧相同:R1 指过去,按 Fact 那个分段的 measures 反查列名能对上,但表不是那张表。"""
|
||
block = {"id": "交叉表2", "layout": "crosstab", "axis": {"year_from": None, "checks": ["contiguous"]},
|
||
"segments": [{"id": "附表", "role": "measures", "table": "附表客流", "locate": {"by": "labels"},
|
||
"labels": {"expect": ["全日客流", "分区甲", "分区乙"]},
|
||
"measures": {"全日客流": "全日客流", "分区甲": "分区甲", "分区乙": "分区乙"}}]}
|
||
r = _second_sheet(flow(), block, [{"name": "附表客流", "grain": ["日期"]}])
|
||
assert run(r) == []
|
||
r["relations"][0]["table"] = "附表客流"
|
||
assert run(r, facts=None) == []
|
||
p = only(r, "fact_claim_mismatch", "/relations/0/claims")
|
||
assert "「日客流」" in p.message and "「附表客流」" in p.message
|
||
|
||
|
||
def test_not_comparable_a_value_must_be_the_value_column():
|
||
"""F2 说的是日间、夜间「各行之和」,加总的是值列;换成派生列「起始小时」(也是数字列)口径就写错了。"""
|
||
r = flow()
|
||
r["relations"][1]["a"]["value"] = "起始小时"
|
||
assert run(r, facts=None) == []
|
||
p = only(r, "fact_claim_mismatch", "/relations/1/claims")
|
||
assert "值列「客流」" in p.message and "「起始小时」" in p.message
|
||
|
||
|
||
def _rename_dimension_segments(r: dict, day: str | None, night: str | None) -> dict:
|
||
s = segs(r)
|
||
if day:
|
||
s[1]["id"] = day
|
||
if night:
|
||
s[2]["id"] = night
|
||
s[3]["locate"]["segment"] = night
|
||
return r
|
||
|
||
|
||
@pytest.mark.parametrize("day,night", [("白天", "晚上"), ("白天", None), (None, "晚上")])
|
||
def test_renamed_dimension_segments_are_found_in_the_b_block(day, night):
|
||
"""F2 的 a 只记分段 id(起草时取 pick:日间、夜间);改名后按 b 那个 measures 段同块的全部 dimension 段认,
|
||
照样核对内容,而不是报一条看不懂的认领错误。"""
|
||
r = _rename_dimension_segments(flow(), day, night)
|
||
assert run(r) == []
|
||
r["relations"][1]["a"]["value"] = "起始小时"
|
||
only(r, "fact_claim_mismatch", "/relations/1/claims")
|
||
|
||
|
||
def test_renamed_dimension_segments_with_a_different_count_do_not_match():
|
||
"""改名后同块的 dimension 段个数与 a 不同(少了日间):内容变了,不按同块认,照样报对不上。"""
|
||
r = flow()
|
||
del segs(r)[1]
|
||
seg = segs(r)[1]
|
||
seg["id"], seg["const"] = "晚上", {}
|
||
segs(r)[2]["locate"]["segment"] = "晚上"
|
||
assert run(r, facts=None) == []
|
||
p = only(r, "fact_claim_mismatch", "/relations/1/claims")
|
||
assert "(配方里找不到这些分段)" in p.message
|
||
|
||
|
||
def test_dismissed_claims_any_fact():
|
||
r = flow()
|
||
r["relations"][1] = {"id": "R2", "kind": "dismissed", "claims": "F2", "reason": "两块口径不同,不作关系登记"}
|
||
assert run(r) == []
|
||
|
||
|
||
def test_claims_not_checked_without_facts():
|
||
r = flow()
|
||
r["relations"][0]["claims"] = None
|
||
r["relations"][1]["claims"] = "F9"
|
||
assert run(r, facts=None) == []
|
||
|
||
|
||
def test_renamed_measures_segment_is_found_by_labels():
|
||
"""起草时分段 id 是表名,改名后 Fact.detail.segment 对不上:按标签找到唯一的分段,照样核对内容。"""
|
||
r = flow()
|
||
segs(r)[0]["id"] = "按日"
|
||
assert run(r) == []
|
||
r["relations"][0].update(total="分区甲", parts=["全日客流", "分区乙"])
|
||
only(r, "fact_claim_mismatch", "/relations/0/claims")
|
||
|
||
|
||
def test_new_fact_numbering_is_not_an_empty_claim():
|
||
"""改配方后重新探测:F1 现在是「甲 + 乙 + 丙」(D13),旧的 R1 claims=F1 报出来,而不是空认领通过。"""
|
||
new_f1 = Fact("F1", "sum_eq", "客流汇总", "第 5 行 = 第 6 行 + 第 7 行 + 第 8 行(30 列中 30 列成立)",
|
||
{"segment": "日客流", "total": "全日客流(人次)",
|
||
"parts": ["分区甲(人次)", "分区乙(人次)", "分区丙(人次)"], "rows": [5, 6, 7, 8]})
|
||
only(flow(), "fact_claim_mismatch", "/relations/0/claims", facts=DraftFacts(facts=[new_f1, F2]))
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 12:说明散文
|
||
# ==========================================================================
|
||
|
||
|
||
def test_note_ok():
|
||
r = flow()
|
||
table(r, "日客流")["note"] = "粒度:每个{列:日期}。{列:全日客流} 等于 {列:分区甲} 与 {列:分区乙} 之和,单位{单位:人次}"
|
||
assert run(r) == []
|
||
|
||
|
||
@pytest.mark.parametrize("note", ["导入时已逐日核对,共 31 天", "数据见 C5 格", "约 5000 人次"])
|
||
def test_note_with_numbers_or_coordinates(note):
|
||
r = flow()
|
||
table(r, "日客流")["note"] = note
|
||
has(r, "note_numbers", "/tables/0/note")
|
||
assert {p.code for p in run(r)} == {"note_numbers"}
|
||
|
||
|
||
@pytest.mark.parametrize("note", ["{列:增长30%},请据此推算", "{列:客流} 不能相加", "{表:第2期}只有一半", "按{单位:箱}计"])
|
||
def test_note_with_unknown_token(note):
|
||
r = flow()
|
||
table(r, "日客流")["note"] = note
|
||
has(r, "note_token_unknown", "/tables/0/note")
|
||
|
||
|
||
def test_ai_note_forbidden():
|
||
r = flow()
|
||
table(r, "日客流")["note"] = "按{列:日期}逐日记录"
|
||
only(r, "ai_note_forbidden", "/tables/0/note", origin="ai")
|
||
assert run(r, origin="rules") == []
|
||
|
||
|
||
# ==========================================================================
|
||
# 规则 13:单位与标签一致
|
||
# ==========================================================================
|
||
|
||
|
||
def test_unit_label_conflict_wrong_unit():
|
||
r = list_recipe()
|
||
r["tables"][0]["units"]["金额"] = "元"
|
||
p = only(r, "unit_label_conflict", "/tables/0/units/金额", facts=None)
|
||
assert "「金额(万元)」" in p.message and "万元" in p.message
|
||
|
||
|
||
def test_unit_label_conflict_missing_unit():
|
||
r = list_recipe()
|
||
del r["tables"][0]["units"]["金额"]
|
||
p = only(r, "unit_label_conflict", "/tables/0/units", facts=None)
|
||
assert p.message == "表「销售」:原表表头「金额(万元)」写着单位「万元」,列「金额」的单位也必须写「万元」"
|
||
|
||
|
||
def test_unit_label_conflict_unit_outside_vocabulary():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["columns"][2]["header"] = "销量(箱)"
|
||
assert run(r, facts=None) == []
|
||
r["tables"][0]["units"]["销量"] = "件"
|
||
only(r, "unit_label_conflict", "/tables/0/units/销量", facts=None)
|
||
|
||
|
||
def test_unit_label_conflict_measures_and_section_title():
|
||
r = flow()
|
||
table(r, "日客流")["units"]["分区甲"] = "人"
|
||
only(r, "unit_label_conflict", "/tables/0/units/分区甲")
|
||
r = flow()
|
||
table(r, "时段客流")["units"]["客流"] = "人"
|
||
only(r, "unit_label_conflict", "/tables/1/units/客流")
|
||
|
||
|
||
def test_unit_label_conflict_list_total_table():
|
||
"""列表合计表的数字列就是那几个表头:主表写对了、合计表写成「元」或漏写,同样要报。"""
|
||
r = list_recipe()
|
||
r["tables"][1]["units"]["金额"] = "元"
|
||
p = only(r, "unit_label_conflict", "/tables/1/units/金额", facts=None)
|
||
assert p.message.startswith("表「销售_表内合计」:") and "「金额(万元)」" in p.message
|
||
del r["tables"][1]["units"]["金额"]
|
||
only(r, "unit_label_conflict", "/tables/1/units", facts=None)
|
||
|
||
|
||
def test_unit_label_conflict_list_total_table_unit_outside_vocabulary():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["columns"][2]["header"] = "销量(箱)"
|
||
r["tables"][1]["units"]["销量"] = "件"
|
||
only(r, "unit_label_conflict", "/tables/1/units/销量", facts=None)
|
||
|
||
|
||
def test_list_without_kept_total_has_no_total_table_units():
|
||
r = list_recipe()
|
||
r["sheets"][0]["blocks"][0]["rows"]["total_row"]["keep_as"] = None
|
||
del r["tables"][1]
|
||
assert run(r, facts=None) == []
|
||
|
||
|
||
def test_unit_label_conflict_crosstab_total_table():
|
||
"""表内合计和它紧跟的夜间明细同在「夜间时段客流(人次)」下:合计表的客流写错或漏写,同样要报。"""
|
||
r = flow()
|
||
table(r, "时段客流_表内合计")["units"]["客流"] = "人"
|
||
p = only(r, "unit_label_conflict", "/tables/2/units/客流")
|
||
assert p.message.startswith("表「时段客流_表内合计」:") and "「夜间时段客流(人次)」" in p.message
|
||
del table(r, "时段客流_表内合计")["units"]["客流"]
|
||
only(r, "unit_label_conflict", "/tables/2/units")
|
||
|
||
|
||
# ==========================================================================
|
||
# 规范形式与哈希
|
||
# ==========================================================================
|
||
|
||
|
||
def _strip_defaults(r: dict) -> dict:
|
||
"""手写的省略写法:去掉参考配方里显式写出的默认值。"""
|
||
r = copy.deepcopy(r)
|
||
for k in ("mode", "other_visible_sheets"):
|
||
r.pop(k)
|
||
sheet = r["sheets"][0]
|
||
sheet["match"].pop("fallback")
|
||
sheet.pop("hidden")
|
||
ctx = sheet["context"][0]
|
||
for k in ("id", "kind", "parser", "cross_check"):
|
||
ctx.pop(k)
|
||
block = sheet["blocks"][0]
|
||
block.pop("axis")
|
||
block.pop("label_offset")
|
||
for k in ("type", "blank", "text_number", "formula"):
|
||
block["values"].pop(k)
|
||
block["values"]["placeholders"][0].pop("meaning")
|
||
segs(r)[3].pop("labels_parser")
|
||
for t in r["tables"]:
|
||
t.pop("note")
|
||
if t["kind"] == "data":
|
||
t.pop("kind")
|
||
return r
|
||
|
||
|
||
def test_hash_ignores_explicit_defaults():
|
||
assert recipe_sha256(parse_recipe(_strip_defaults(FLOW))) == recipe_sha256(parse_recipe(flow()))
|
||
|
||
|
||
def test_hash_ignores_axis_checks_order_and_key_order():
|
||
r = flow()
|
||
r["sheets"][0]["blocks"][0]["axis"]["checks"] = ["covers_context", "contiguous"]
|
||
assert recipe_sha256(parse_recipe(r)) == recipe_sha256(parse_recipe(flow()))
|
||
shuffled = json.loads(json.dumps(flow(), sort_keys=True))
|
||
shuffled = {k: shuffled[k] for k in reversed(list(shuffled))}
|
||
assert recipe_sha256(parse_recipe(shuffled)) == recipe_sha256(parse_recipe(flow()))
|
||
|
||
|
||
def test_hash_changes_with_content():
|
||
r = flow()
|
||
r["sheets"][0]["blocks"][0]["axis"]["checks"] = ["contiguous"]
|
||
assert recipe_sha256(parse_recipe(r)) != recipe_sha256(parse_recipe(flow()))
|
||
|
||
|
||
def test_canonical_form_is_compact_and_round_trips():
|
||
recipe = parse_recipe(flow())
|
||
canon = canonical_recipe(recipe)
|
||
assert "mode" not in canon and "hidden" not in canon["sheets"][0]
|
||
assert "axis" not in canon["sheets"][0]["blocks"][0]
|
||
assert parse_recipe(canon) == recipe
|
||
assert canonical_recipe(parse_recipe(canon)) == canon
|
||
text = json.dumps(canon, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||
assert recipe_sha256(recipe) == hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
# ==========================================================================
|
||
# apply_patch
|
||
# ==========================================================================
|
||
|
||
|
||
def test_apply_patch_add_replace_remove():
|
||
doc = {"a": {"b": 1}, "l": [1, 2], "x/y": {"~k": 0}}
|
||
out = apply_patch(doc, [
|
||
{"op": "add", "path": "/a/c", "value": [1]},
|
||
{"op": "add", "path": "/l/1", "value": 9},
|
||
{"op": "add", "path": "/l/-", "value": 3},
|
||
{"op": "replace", "path": "/a/b", "value": {"n": 2}},
|
||
{"op": "remove", "path": "/l/0"},
|
||
{"op": "replace", "path": "/x~1y/~0k", "value": 5},
|
||
{"op": "add", "path": "/a/b", "value": "overwritten"},
|
||
])
|
||
assert out == {"a": {"b": "overwritten", "c": [1]}, "l": [9, 2, 3], "x/y": {"~k": 5}}
|
||
assert doc == {"a": {"b": 1}, "l": [1, 2], "x/y": {"~k": 0}}
|
||
|
||
|
||
def test_apply_patch_question_effects_on_recipe():
|
||
"""起草问题「不登记」的 effects:把关系整条换成 dismissed;占位符选「不是」:删掉占位符。"""
|
||
out = apply_patch(flow(), [
|
||
{"op": "replace", "path": "/relations/1",
|
||
"value": {"id": "R2", "kind": "dismissed", "claims": "F2", "reason": "口径不同"}},
|
||
{"op": "remove", "path": "/sheets/0/blocks/0/values/placeholders/0"},
|
||
{"op": "replace", "path": f"{SEG}/2/const/时段类别/pick", "value": "夜间时段客流"},
|
||
])
|
||
assert out["relations"][1]["kind"] == "dismissed"
|
||
assert out["sheets"][0]["blocks"][0]["values"]["placeholders"] == []
|
||
assert segs(out)[2]["const"]["时段类别"]["pick"] == "夜间时段客流"
|
||
assert segs(FLOW)[2]["const"]["时段类别"]["pick"] == "夜间"
|
||
assert run(out) == []
|
||
|
||
|
||
def test_apply_patch_root_replace():
|
||
assert apply_patch({"a": 1}, [{"op": "replace", "path": "", "value": {"b": 2}}]) == {"b": 2}
|
||
|
||
|
||
@pytest.mark.parametrize("ops", [
|
||
[{"op": "replace", "path": "/nope", "value": 1}],
|
||
[{"op": "remove", "path": "/a/zz"}],
|
||
[{"op": "add", "path": "/l/5", "value": 1}],
|
||
[{"op": "replace", "path": "/l/2", "value": 1}],
|
||
[{"op": "remove", "path": "/l/01"}],
|
||
[{"op": "remove", "path": "/l/x"}],
|
||
[{"op": "add", "path": "/q/r", "value": 1}],
|
||
[{"op": "add", "path": "a", "value": 1}],
|
||
[{"op": "move", "from": "/a", "path": "/b"}],
|
||
[{"op": "add", "path": "/b"}],
|
||
[{"path": "/a"}],
|
||
[{"op": "remove", "path": ""}],
|
||
[{"op": "replace", "path": "", "value": [1]}],
|
||
[{"op": "add", "path": "/a/b/c", "value": 1}],
|
||
["not an op"],
|
||
])
|
||
def test_apply_patch_errors(ops):
|
||
doc = {"a": {"b": 1}, "l": [1, 2]}
|
||
with pytest.raises(ValueError):
|
||
apply_patch(doc, ops)
|
||
assert doc == {"a": {"b": 1}, "l": [1, 2]}
|
||
|
||
|
||
def test_apply_patch_is_all_or_nothing():
|
||
doc = {"a": 1}
|
||
with pytest.raises(ValueError):
|
||
apply_patch(doc, [{"op": "replace", "path": "/a", "value": 2}, {"op": "remove", "path": "/zz"}])
|
||
assert doc == {"a": 1}
|
||
|
||
|
||
def test_many_unknown_unit_keys_on_a_wide_table_validate_quickly():
|
||
"""SE-5:200 列的宽表上 2 万个不存在的单位键:_near 按名字列表建一次键索引,不再逐键对全表各列重算 NFKC
|
||
(改之前约 11 秒)。提示照旧:大小写、全半角不同的名字仍会点出来。"""
|
||
import time
|
||
|
||
from app.data.recipe import validate_recipe
|
||
|
||
data = copy.deepcopy(FLOW)
|
||
seg = data["sheets"][0]["blocks"][0]["segments"][0]
|
||
labels = [f"指标{i}(人次)" for i in range(200)]
|
||
seg["labels"]["expect"] = labels
|
||
seg["measures"] = {lab: f"指标{i}" for i, lab in enumerate(labels)}
|
||
data["tables"][0]["units"] = {f"指标{i}": "人次" for i in range(200)}
|
||
data["relations"] = []
|
||
data["tables"][0]["units"].update({f"col_{i}_x": "人次" for i in range(20000)})
|
||
data["tables"][0]["units"]["指标7"] = "人次" # 全角数字:提示「有「指标7」」
|
||
t = time.perf_counter()
|
||
_parsed, probs = validate_recipe(data)
|
||
assert time.perf_counter() - t < 4
|
||
assert sum(1 for p in probs if p.code == "column_unknown") == 20001
|
||
hint = next(p.message for p in probs if "指标7" in p.message)
|
||
assert "(有「指标7」,名字必须逐字相同)" in hint
|