Files
AgentLab/backend/tests/test_recipe_checks.py
ThearchyHelios 2d5e50fa88 Excel 导入改造期 2:配方引擎与确认向导——表从哪一行哪一列开始、哪些是合计、日期怎么补年份,都写进一份可校验的配方;首次导入经人确认,之后每期按配方重放并核对
配方语言(agentlab-recipe/2,新 recipe_types、recipe)
- 封闭、不含正则、保存时静态校验:常量只能从表里的候选文字里选,占位符只能映射成空值,单位从固定词表里取
- 交叉表和列表两种版式;分段各自定位、不依赖先后顺序;合计按标签语义区间核对,关系恒等式、口径不同的声明都写在配方里
- 固定的解析器集合(parsers/2):全角、各类横线、千分位、月日写法宽容归一,规范写法存库、原文另记

执行器(新 xlsx_cells、recipe_engine)
- 两遍读取逐行对账,每个非空格都要有去向(格子账);交叉表逆透视成长表,按统计期补年份并断言覆盖整期
- 合计行改作核对并另存一张表,不和明细混在一列;区域外文字、隐藏行列、合并单元格、空行之后的文字都单独报出
- STRICT 加主键写临时库;按锚点段记溯源,供后续证据下钻用;大表流式读取,扫描在 sheetData 处停止

校验与说明(新 recipe_checks、recipe_notes)
- SQL 核对分通过、不一致、无法核对三种结果,失败分结构、数据质量、需确认三类;结构类直接拒收
- 数据质量类可以写理由接受,接受不改库、只改状态和说明
- 表的说明按核对结果生成、不含数字,经过数字检查;没核对过的不写「已核对」

起草(新 recipe_suggest、recipe_ai)
- 先用规则起草:建议卡片加待确认问题(关系要不要登记、占位符表示什么)
- 规则起草不完整时才提示 AI 兜底:界面先展示要发出的全部文字,用户同意才发;只发结构,数字、隐藏行列、遮罩列不发,公式先脱敏;同意绑定模型和服务方;AI 只写配方,有限次修订,记 token 和费用;截断单独识别
- 无论规则还是 AI 起草,都必须人工确认才能启用

导入流程(新 recipe_imports、recipe_confirm、recipe_diff、api/table_imports)
- 暂存 → 起草 → 回答问题或改配方 → 试运行 → 勾确认项、写接受理由 → 确认并启用;接入期 1 的构建、导入记录、快照和运行固定版本
- 上传新一期按已确认的配方重放,不调用模型,出回执和差异卡,手动启用
- 改配方报破坏性变更(breaking:*)、单位变化、工作表改名的兜底,都要逐项勾选确认

前端导入向导(新 pages/import)
- 原始网格(行列号、去向着色)、建议卡片、配方面板(表单,不露正则)、试运行回执、确认清单、差异卡、AI 起草的同意对话框
2026-10-01 22:00:07 +08:00

827 lines
36 KiB
Python
Raw Permalink 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_checks)与 engine.open_checked_sqlite 的测试。
不依赖执行器:库和 Extraction 都在测试里按「结构仿照客流表」的参考配方手写(假名、随机假数)。
"""
from __future__ import annotations
import hashlib
import json
import random
import sqlite3
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
import pytest
from app.data import engine
from app.data.engine import SqliteHardeningError, open_checked_sqlite
from app.data.recipe_checks import (
col_letters,
column_null_counts,
lineage_cell,
plan_checks,
run_checks,
same_number,
)
from app.data.recipe_types import CheckResult, DerivedItem, Extraction, Recipe, derive_tables
FLOW = json.loads((Path(__file__).parent / "fixtures" / "recipes" / "flow_recipe.json").read_text(encoding="utf-8"))
SHEET = "客流汇总"
DAY = [f"{h}-{h + 1}" for h in range(7, 18)] # 第 10–20 行
NIGHT = [f"{h}-{h + 1}" for h in range(18, 24)] # 第 22–27 行
TOTALS = [("18-22时合计", 18, 22, 28), ("22-24时合计", 22, 24, 29), ("18-24时合计", 18, 24, 30)]
def _q(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def make_db(path: Path, recipe: Recipe, rows: dict[str, list[dict[str, Any]]], *, pk: bool = True) -> None:
"""按 derive_tables 建 STRICT 表、按给定顺序插入(rowid 就是插入序号)。"""
tables, problems = derive_tables(recipe)
assert problems == []
grains = {t.name: t.grain for t in recipe.tables}
conn = open_checked_sqlite(str(path), readonly=False)
try:
for table, cols in tables.items():
defs = [f"{_q(c.name)} {c.type}" for c in cols]
if pk and grains.get(table):
defs.append(f"PRIMARY KEY ({', '.join(_q(g) for g in grains[table])})")
conn.execute(f"CREATE TABLE {_q(table)} ({', '.join(defs)}) STRICT")
names = [c.name for c in cols]
conn.executemany(
f"INSERT INTO {_q(table)} ({', '.join(_q(n) for n in names)}) VALUES ({', '.join('?' * len(names))})",
[[r.get(n) for n in names] for r in rows.get(table, [])])
conn.commit()
finally:
conn.close()
@dataclass
class Flow:
recipe: Recipe
db: str
extraction: Extraction
dates: list[str]
rows: dict[str, list[dict[str, Any]]]
def flow(tmp_path: Path, days: int = 3, *, recipe_data: dict | None = None,
rows_hook: Callable[[dict], None] | None = None,
items_hook: Callable[[list[DerivedItem]], None] | None = None,
pk: bool = True, seed: int = 7, full_calc: bool = False) -> Flow:
rnd = random.Random(seed)
recipe = Recipe.model_validate(deepcopy(recipe_data or FLOW))
dates = [f"2026-08-{d:02d}" for d in range(1, days + 1)]
daily = []
for d in dates:
a, b = rnd.randint(3000, 9000), rnd.randint(2000, 8000)
daily.append({"日期": d, "全日客流": a + b, "分区甲": a, "分区乙": b})
hourly = []
for cat, labels in (("日间", DAY), ("夜间", NIGHT)):
for label in labels:
s, e = (int(x) for x in label.split("-"))
for d in dates:
v = None if label == "7-8" else rnd.randint(50, 900)
hourly.append({"日期": d, "时段": label, "时段类别": cat, "起始小时": s, "结束小时": e, "客流": v})
for row in daily: # 各时段之和 ≠ 全日客流(生成器保证)
tot = sum(h["客流"] or 0 for h in hourly if h["日期"] == row["日期"])
if tot == row["全日客流"]:
next(h for h in hourly if h["日期"] == row["日期"] and h["客流"] is not None)["客流"] += 1
def night_sum(d: str, s: int, e: int, rows: list[dict]) -> int:
return sum(h["客流"] for h in rows if h["日期"] == d and h["时段类别"] == "夜间"
and h["起始小时"] >= s and h["结束小时"] <= e)
totals = [{"日期": d, "合计项": label, "起始小时": s, "结束小时": e, "客流": night_sum(d, s, e, hourly)}
for label, s, e, _ in TOTALS for d in dates]
original = deepcopy(hourly)
rows = {"日客流": daily, "时段客流": hourly, "时段客流_表内合计": totals}
if rows_hook:
rows_hook(rows)
db = tmp_path / "trial.db"
make_db(db, recipe, rows, pk=pk)
items: list[DerivedItem] = []
for label, s, e, r in TOTALS:
for i, d in enumerate(dates):
refs = [n for n, h in enumerate(rows["时段客流"], 1) if h["日期"] == d and h["时段类别"] == "夜间"
and h["起始小时"] >= s and h["结束小时"] <= e]
items.append(DerivedItem(
kind="label_range_sum", segment="夜间合计", sheet=SHEET, cell=f"{col_letters(3 + i)}{r}",
label_raw=label.replace("时", " 时"), label=label, value=night_sum(d, s, e, original),
is_formula=True, formula=f"=SUM({col_letters(3 + i)}22:{col_letters(3 + i)}25)",
base_table="时段客流", base_value="客流", start=s, end=e,
key={"日期": d, "时段类别": "夜间"}, start_col="起始小时", end_col="结束小时",
ref_rowids=refs, func="SUM", keep_table="时段客流_表内合计"))
if items_hook:
items_hook(items)
lineage = {
"日客流": {c: [[1, SHEET, f"C{r}", days, "right"]] for c, r in (("全日客流", 5), ("分区甲", 6), ("分区乙", 7))},
"时段客流": {"客流": [[1 + j * days, SHEET, f"C{r}", days, "right"]
for j, r in enumerate([*range(10, 21), *range(22, 28)])]},
}
ctx = [CheckResult("C1", "context_agree", "统计期", "passed", "data_quality", checked=1),
CheckResult("C2", "filename_period", "文件名", "passed", "data_quality", checked=1)]
ex = Extraction(ok=True, derived=items, lineage=lineage, context_checks=ctx, full_calc_on_load=full_calc,
expected_rows={t: len(v) for t, v in rows.items()})
return Flow(recipe, str(db), ex, dates, rows)
def by_id(results: list[CheckResult]) -> dict[str, CheckResult]:
return {c.id: c for c in results}
# ---------------------------------------------------------------- open_checked_sqlite
def test_open_checked_sqlite_readonly_and_dqs_off(tmp_path):
path = tmp_path / "a b?#中文.db"
conn = open_checked_sqlite(str(path), readonly=False)
conn.execute("CREATE TABLE t (a INTEGER)")
conn.execute("INSERT INTO t VALUES (1)")
conn.commit()
# 默认回滚日志,不开 WAL(WAL 改写文件头,库哈希会变)
assert conn.execute("PRAGMA journal_mode").fetchone()[0] == "delete"
conn.close()
assert path.exists() and sorted(p.name for p in tmp_path.iterdir()) == [path.name]
ro = open_checked_sqlite(str(path))
try:
assert ro.execute("SELECT a FROM t").fetchall() == [(1,)]
with pytest.raises(sqlite3.OperationalError, match="readonly"):
ro.execute("INSERT INTO t VALUES (2)")
# DQS 关了:写错的双引号列名报错,而不是当成字符串
with pytest.raises(sqlite3.OperationalError, match="no such column"):
ro.execute('SELECT "no_such" FROM t')
finally:
ro.close()
def test_open_checked_sqlite_readonly_does_not_create(tmp_path):
with pytest.raises(sqlite3.OperationalError):
open_checked_sqlite(str(tmp_path / "missing.db"))
assert not (tmp_path / "missing.db").exists()
def test_open_checked_sqlite_refuses_when_dqs_cannot_be_disabled(tmp_path, monkeypatch):
def broken(conn):
raise SqliteHardeningError("关不掉")
monkeypatch.setattr(engine, "_disable_dqs", broken)
with pytest.raises(SqliteHardeningError):
open_checked_sqlite(str(tmp_path / "x.db"), readonly=False)
# ---------------------------------------------------------------- 小工具
def test_same_number_rules():
assert same_number(100, 100.0)
assert not same_number(100, 100.0000001 + 1)
assert same_number(0.3, 0.1 + 0.2) # 非整数值按相对容差
assert not same_number(5, 6.0)
assert not same_number(None, 0.0)
def test_lineage_cell_reverse_lookup():
lin = {"t": {"v": [[1, "表一", "C5", 31, "right"], [32, "表一", "B10", 4, "down"]]}}
assert lineage_cell(lin, "t", "v", 1) == "表一!C5"
assert lineage_cell(lin, "t", "v", 10) == "表一!L5"
assert lineage_cell(lin, "t", "v", 33) == "表一!B11"
assert lineage_cell(lin, "t", "v", 99) is None
assert col_letters(27) == "AA" and col_letters(33) == "AG"
# ---------------------------------------------------------------- 全部通过(D00 的形状)
def test_reference_flow_all_checks(tmp_path):
f = flow(tmp_path, days=5)
before = hashlib.sha256(Path(f.db).read_bytes()).hexdigest()
results = run_checks(f.db, f.extraction, f.recipe)
assert [c.id for c in results] == ["C1", "C2", "K1", "G1", "R1", "R2", "N1", "N2", "N3", "P1", "P2", "P3"]
got = by_id(results)
assert got["K1"].status == "passed" and got["K1"].checked == 15 and got["K1"].reasons == {}
assert got["K1"].kind == "derived_sum" and got["K1"].acceptable is False
assert got["G1"].status == "passed" and got["G1"].checked == 15
assert got["R1"].status == "passed" and got["R1"].checked == 5 and got["R1"].category == "data_quality"
assert got["R2"].status == "info" and got["R2"].details == ["各时段之和与全日客流:5 天中 0 天相等"]
assert got["R2"].checked == 5 and got["R2"].failed == 5
assert all(got[k].status == "passed" for k in ("N1", "N2", "N3", "P1", "P2", "P3"))
# 只读打开:核对不改库
assert hashlib.sha256(Path(f.db).read_bytes()).hexdigest() == before
assert sorted(p.name for p in tmp_path.iterdir()) == ["trial.db"]
def test_sql_text_and_params_recorded(tmp_path):
f = flow(tmp_path)
got = by_id(run_checks(f.db, f.extraction, f.recipe))
k = got["K1"]
assert k.sql == ('SELECT TOTAL("客流"), COUNT("客流"), COUNT(*) FROM "时段客流" '
'WHERE "日期" = ? AND "时段类别" = ? AND "起始小时" >= ? AND "结束小时" <= ?')
assert k.params == ["2026-08-01", "夜间", 18, 22]
assert '"全日客流" <> "分区甲" + "分区乙"' in got["R1"].sql
assert got["N1"].sql == 'SELECT COUNT(*) FROM "日客流"'
assert 'GROUP BY "日期", "时段"' in got["P2"].sql
assert got["G1"].sql.startswith('SELECT rowid FROM "时段客流" WHERE "日期" = ?')
# 重放:同一条 SQL、同样的参数,在同一个库上得到同一个数
conn = sqlite3.connect(f.db)
assert conn.execute(k.sql, k.params).fetchone()[0] == f.extraction.derived[0].value
conn.close()
def test_full_calc_on_load_consistent_passes_with_note(tmp_path):
f = flow(tmp_path, full_calc=True)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "passed"
assert "工作簿设置了打开时重算,保存值与重算一致" in k.details
# ---------------------------------------------------------------- K:各种结果
def test_k_mismatch_points_at_cell(tmp_path):
def bump(items):
items[4].value += 100 # 「18-22 时合计」第 5 个日期(G28)
f = flow(tmp_path, days=5, items_hook=bump)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "mismatch" and k.category == "structure" and k.acceptable is False
assert k.failed == 1 and k.checked == 15
item = f.extraction.derived[4]
assert k.details[0] == f"G28:表内为 {item.value},按 18-22 时的明细重算为 {item.value - 100}"
assert k.cells == [f"{SHEET}!G28"]
assert k.params == ["2026-08-05", "夜间", 18, 22] # 代表性 SQL 取第一处不一致
@pytest.mark.parametrize("slot,ranges", [
("19-20", "18-22 时、18-24 时"), # 中间缺一段
("18-19", "18-22 时、18-24 时"), # 缺起点
("23-24", "22-24 时、18-24 时"), # 缺终点
])
def test_k_tiling_unverifiable(tmp_path, slot, ranges):
def drop(rows):
rows["时段客流"] = [h for h in rows["时段客流"] if h["时段"] != slot]
f = flow(tmp_path, rows_hook=drop)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "unverifiable" and k.acceptable is True
assert k.reasons == {"tiling": 6} # 两个合计各 3 个日期;另一个合计照常一致
assert k.checked == 3 and k.failed == 0 and k.unverifiable == 6
assert f"6 格无法核对:明细时段没有恰好铺满 {ranges}" in k.details
def test_k_overlap_is_not_tiling(tmp_path):
"""区间里多出一条跨时段的明细(18-20):首尾相接不成立,不能判一致。"""
data = deepcopy(FLOW)
data["tables"][1]["grain"] = ["日期", "时段类别", "时段"]
def extra(rows):
for d in ("2026-08-01", "2026-08-02", "2026-08-03"):
rows["时段客流"].append({"日期": d, "时段": "18-20", "时段类别": "夜间", "起始小时": 18,
"结束小时": 20, "客流": 0})
f = flow(tmp_path, recipe_data=data, rows_hook=extra)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "unverifiable" and k.reasons == {"tiling": 6}
@pytest.mark.parametrize("is_formula,reason", [(True, "uncached"), (False, "blank")])
def test_k_missing_value(tmp_path, is_formula, reason):
def blank(items):
for it in items:
it.value = None
it.is_formula = is_formula
f = flow(tmp_path, items_hook=blank)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "unverifiable" and k.reasons == {reason: 9} and k.acceptable
assert k.checked == 0 and k.unverifiable == 9
def test_k_null_detail_never_passes(tmp_path):
"""明细含空值(占位符):哪怕保存的合计恰好等于非空明细之和,也只能判无法核对。"""
removed: dict[str, int] = {}
def hole(rows):
for h in rows["时段客流"]:
if h["时段"] == "23-24" and h["日期"] == "2026-08-02":
removed["v"], h["客流"] = h["客流"], None
def match_nonnull(items):
for it in items:
if it.key["日期"] == "2026-08-02" and it.end == 24:
it.value -= removed["v"] # 合计 = 非空明细之和:TOTAL 对得上,仍不能判一致
f = flow(tmp_path, rows_hook=hole, items_hook=match_nonnull)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "unverifiable" and k.reasons == {"null_detail": 2} # 22-24、18-24 那天
assert k.failed == 0 and k.checked == 7
assert "2 格无法核对:明细含无数据占位符" in k.details
@pytest.mark.parametrize("mark", ["hidden_in_range", "SUBTOTAL", "AGGREGATE"])
def test_k_hidden_turns_mismatch_into_unverifiable(tmp_path, mark):
def tweak(items):
it = items[0]
it.value += 5
if mark == "hidden_in_range":
it.hidden_in_range = True
else:
it.func = mark
f = flow(tmp_path, items_hook=tweak)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "unverifiable" and k.reasons == {"hidden": 1} and k.acceptable
def test_k_hidden_but_equal_is_consistent(tmp_path):
def tweak(items):
for it in items:
it.hidden_in_range = True
f = flow(tmp_path, items_hook=tweak)
assert by_id(run_checks(f.db, f.extraction, f.recipe))["K1"].status == "passed"
def test_k_key_with_const_column_ignores_other_category(tmp_path):
"""同一张表里别的分段(别的类别)也有 18-22 时的行:key 带常量列,不会被加进来。"""
data = deepcopy(FLOW)
data["tables"][1]["grain"] = ["日期", "时段类别", "时段"]
def other(rows):
# 别的类别按两小时一段:和夜间的逐小时混在一起就互相重叠、也会被加进合计
for d in ("2026-08-01", "2026-08-02", "2026-08-03"):
for s in (18, 20, 22):
rows["时段客流"].append({"日期": d, "时段": f"{s}-{s + 2}", "时段类别": "其他", "起始小时": s,
"结束小时": s + 2, "客流": 999})
f = flow(tmp_path, recipe_data=data, rows_hook=other)
got = by_id(run_checks(f.db, f.extraction, f.recipe))
assert got["K1"].status == "passed" and got["G1"].status == "passed"
def test_k_day_without_detail_rows_is_unverifiable(tmp_path):
"""某个日期的夜间明细一行都没有(别的日期都有,所以按区间判的「铺满」照样成立):
COUNT(*) = 0,TOTAL 是 0,不能拿它和保存的合计比,判无法核对(tiling),不判一致也不判不一致。"""
def drop_day(rows):
rows["时段客流"] = [h for h in rows["时段客流"]
if not (h["日期"] == "2026-08-02" and h["时段类别"] == "夜间")]
f = flow(tmp_path, rows_hook=drop_day)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "unverifiable" and k.acceptable
assert k.reasons == {"tiling": 3} and k.checked == 6 and k.failed == 0
assert sorted(k.cells) == [f"{SHEET}!D28", f"{SHEET}!D29", f"{SHEET}!D30"]
def test_k_mixed_reasons_counted(tmp_path):
def mix(items):
items[0].value = None # 18-22 第 1 天:无缓存
items[1].value += 1
items[1].func = "SUBTOTAL" # 18-22 第 2 天:分类汇总
items[3].value = None
items[3].is_formula = False # 22-24 第 1 天:空格
f = flow(tmp_path, items_hook=mix)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "unverifiable"
assert k.reasons == {"uncached": 1, "hidden": 1, "blank": 1}
assert k.unverifiable == 3 and k.checked == 6
assert any(d.startswith("1 格无法核对:文件未经计算保存") for d in k.details)
def test_k_mismatch_wins_over_unverifiable(tmp_path):
def mix(items):
items[0].value = None
items[2].value += 3
f = flow(tmp_path, items_hook=mix)
k = by_id(run_checks(f.db, f.extraction, f.recipe))["K1"]
assert k.status == "mismatch" and k.acceptable is False and k.reasons == {"uncached": 1}
def test_k_sql_error_is_structure_mismatch_not_crash(tmp_path):
"""列名写错:DQS 关闭的连接直接报错,核对判结构类不一致,而不是把「客 流」当字符串算出 0。"""
def typo(items):
for it in items:
it.base_value = "客 流"
f = flow(tmp_path, items_hook=typo)
got = by_id(run_checks(f.db, f.extraction, f.recipe))
k = got["K1"]
assert k.status == "mismatch" and k.category == "structure" and not k.acceptable
assert k.details[0].startswith("核对 SQL 执行失败") and "no such column" in k.details[0]
assert k.sql and '"客 流"' in k.sql
assert got["R1"].status == "passed" # 别的核对照常
# ---------------------------------------------------------------- G:公式引用
def test_g_p9_label_and_refs_disagree(tmp_path):
def p9(items):
it = items[0] # C28 改成 =SUM(C22:C24):只到 21 时
it.ref_rowids = it.ref_rowids[:3]
f = flow(tmp_path, items_hook=p9)
g = by_id(run_checks(f.db, f.extraction, f.recipe))["G1"]
assert g.status == "mismatch" and g.category == "structure"
assert g.details[0] == "C28:标签「18-22时合计」与公式引用的时段不一致"
assert g.cells == [f"{SHEET}!C28"]
@pytest.mark.parametrize("extra_slot,category", [
("22-23", "夜间"), # 同一类别里范围外的一行:「18-22 时合计」把 22-23 时也引用进来
("17-18", "日间"), # 别的类别的一行
])
def test_g_refs_superset_of_label_range_is_mismatch(tmp_path, extra_slot, category):
"""公式引用比标签范围多出一行(如「8-12时合计」=SUM(C10:C14) 把整行占位符的 7-8 时也引进来):
K 按标签区间重算照样一致,能拦住的只有 G 的集合相等——「引用 ⊆ 标签范围」不够,必须相等。"""
seen: dict[str, list[dict]] = {}
def keep(rows):
seen["rows"] = rows["时段客流"]
def widen(items):
it = items[0] # C28「18-22时合计」第 1 个日期
extra = next(n for n, h in enumerate(seen["rows"], 1)
if h["日期"] == it.key["日期"] and h["时段"] == extra_slot and h["时段类别"] == category)
it.ref_rowids = [*it.ref_rowids, extra]
f = flow(tmp_path, rows_hook=keep, items_hook=widen)
got = by_id(run_checks(f.db, f.extraction, f.recipe))
g = got["G1"]
assert g.status == "mismatch" and g.category == "structure" and not g.acceptable
assert g.failed == 1 and g.cells == [f"{SHEET}!C28"]
assert g.details[0] == "C28:标签「18-22时合计」与公式引用的时段不一致"
assert got["K1"].status == "passed"
@pytest.mark.parametrize("problem,status,reason", [
("unrecognized", "unverifiable", "unrecognized"), ("hidden", "unverifiable", "hidden"),
("outside", "mismatch", None),
])
def test_g_ref_problems(tmp_path, problem, status, reason):
def mark(items):
items[0].ref_problem = problem
items[0].ref_rowids = None
f = flow(tmp_path, items_hook=mark)
g = by_id(run_checks(f.db, f.extraction, f.recipe))["G1"]
assert g.status == status
assert g.acceptable is (status == "unverifiable")
assert g.reasons == ({reason: 1} if reason else {})
def test_g_absent_when_no_formula(tmp_path):
def literal(items):
for it in items:
it.is_formula, it.formula, it.func, it.ref_rowids = False, None, None, None
f = flow(tmp_path, items_hook=literal)
ids = [c.id for c in run_checks(f.db, f.extraction, f.recipe)]
assert "K1" in ids and "G1" not in ids
def test_uncached_formula_still_checks_refs(tmp_path):
"""P1b:公式无缓存——K1 无法核对(uncached),G1 照常核对引用并通过。"""
def p1b(items):
for it in items:
it.value = None
f = flow(tmp_path, items_hook=p1b)
got = by_id(run_checks(f.db, f.extraction, f.recipe))
assert got["K1"].status == "unverifiable" and got["K1"].reasons == {"uncached": 9}
assert got["G1"].status == "passed"
# ---------------------------------------------------------------- T:列表合计
LIST_RECIPE = {
"recipe_format": "agentlab-recipe/2",
"sheets": [{"id": "s1", "match": {"name": "销售"}, "blocks": [{
"id": "明细", "layout": "list", "table": "销售",
"columns": [{"header": "地区", "name": "地区", "type": "TEXT"},
{"header": "金额", "name": "金额", "type": "INTEGER"},
{"header": "数量", "name": "数量", "type": "REAL"}],
"rows": {"total_row": {"label_column": "地区", "pick": "合计", "keep_as": "销售_表内合计"}}}]}],
"tables": [{"name": "销售", "grain": ["地区"]}, {"name": "销售_表内合计", "kind": "reported_total"}],
}
def list_case(tmp_path, *, hook=None, null_amount=False, hidden=False):
recipe = Recipe.model_validate(deepcopy(LIST_RECIPE))
sales = [{"地区": f"分区{n}", "金额": 100 * i, "数量": 0.1 * i} for i, n in enumerate("甲乙丙", 1)]
if null_amount:
sales[1]["金额"] = None
totals = [{"合计项": "合计", "金额": 600, "数量": 0.6}]
make_db(tmp_path / "list.db", recipe, {"销售": sales, "销售_表内合计": totals})
items = [DerivedItem(kind="column_sum", segment="明细", sheet="销售", cell=f"{c}5", label_raw="合计",
label="合计", value=v, is_formula=False, formula=None, base_table="销售",
base_value=col, rowid_first=1, rowid_last=3, hidden_in_range=hidden,
keep_table="销售_表内合计")
for c, col, v in (("B", "金额", 600), ("C", "数量", 0.6))]
if hook:
hook(items)
ex = Extraction(ok=True, derived=items, expected_rows={"销售": 3, "销售_表内合计": 1})
return recipe, str(tmp_path / "list.db"), ex
def test_t_passed_with_real_tolerance(tmp_path):
recipe, db, ex = list_case(tmp_path)
got = by_id(run_checks(db, ex, recipe))
assert [c.id for c in run_checks(db, ex, recipe)] == ["T1", "N1", "N2", "P1"]
t = got["T1"]
assert t.kind == "column_sum" and t.status == "passed" and t.checked == 2 # 0.1+0.2+0.3 ≈ 0.6
assert t.sql == 'SELECT TOTAL("金额"), COUNT("金额"), COUNT(*) FROM "销售" WHERE rowid BETWEEN ? AND ?'
assert t.params == [1, 3]
def test_t_mismatch(tmp_path):
def bump(items):
items[0].value = 601
recipe, db, ex = list_case(tmp_path, hook=bump)
t = by_id(run_checks(db, ex, recipe))["T1"]
assert t.status == "mismatch" and t.details[0] == "B5:表内为 601,按明细求和为 600"
def test_t_uncached(tmp_path):
def unc(items):
items[0].value, items[0].is_formula, items[0].formula = None, True, "=SUM(B2:B4)"
recipe, db, ex = list_case(tmp_path, hook=unc)
t = by_id(run_checks(db, ex, recipe))["T1"]
assert t.status == "unverifiable" and t.reasons == {"uncached": 1} and t.acceptable
def test_t_null_detail(tmp_path):
recipe, db, ex = list_case(tmp_path, null_amount=True)
t = by_id(run_checks(db, ex, recipe))["T1"]
assert t.status == "unverifiable" and t.reasons == {"null_detail": 1}
assert "1 格无法核对:明细含空值" in t.details
def test_t_hidden_rows_turn_mismatch_unverifiable(tmp_path):
def bump(items):
items[0].value = 500
recipe, db, ex = list_case(tmp_path, hook=bump, hidden=True)
t = by_id(run_checks(db, ex, recipe))["T1"]
assert t.status == "unverifiable" and t.reasons == {"hidden": 1}
def test_t_without_detail_rows_is_unverifiable(tmp_path):
"""表头下面紧接着就是合计行:执行器给的明细区间两端都是 None。不能拿 BETWEEN NULL AND NULL 算出的 0
去比(合计写 0 时会被判「2 格全部一致」),判无法核对。"""
def empty(items):
for it in items:
it.rowid_first = it.rowid_last = None
it.value = 0
recipe, db, ex = list_case(tmp_path, hook=empty)
t = by_id(run_checks(db, ex, recipe))["T1"]
assert t.status == "unverifiable" and t.acceptable and t.category == "structure"
assert t.reasons == {"tiling": 2} and t.checked == 0
assert "2 格无法核对:合计行之上没有明细行" in t.details
assert all("一致" not in d for d in t.details)
assert t.params == [None, None]
def test_t_empty_rowid_range_is_unverifiable(tmp_path):
def far(items):
for it in items:
it.rowid_first, it.rowid_last = 10, 12
recipe, db, ex = list_case(tmp_path, hook=far)
t = by_id(run_checks(db, ex, recipe))["T1"]
assert t.status == "unverifiable" and t.reasons == {"tiling": 2}
assert t.params == [10, 12]
@pytest.mark.parametrize("first,last", [(None, 3), (1, None)])
def test_t_half_open_rowid_range_is_structure_failure(tmp_path, first, last):
"""区间只有一端:执行器的错,和 K 缺区间信息一样判结构类不一致,不硬算。"""
def half(items):
items[0].rowid_first, items[0].rowid_last = first, last
recipe, db, ex = list_case(tmp_path, hook=half)
t = by_id(run_checks(db, ex, recipe))["T1"]
assert t.status == "mismatch" and t.category == "structure" and not t.acceptable
assert t.details == ["B5:合计行对应的明细行区间只有一端"]
# ---------------------------------------------------------------- R:关系
def test_r_sum_eq_mismatch_located_by_lineage(tmp_path):
def seven(rows):
rows["日客流"][1]["全日客流"] += 7 # 第 2 个日期
f = flow(tmp_path, rows_hook=seven)
r1 = by_id(run_checks(f.db, f.extraction, f.recipe))["R1"]
assert r1.status == "mismatch" and r1.category == "data_quality" and r1.acceptable
assert r1.checked == 3 and r1.failed == 1
assert r1.cells == [f"{SHEET}!D5"]
row = f.rows["日客流"][1]
assert r1.details[0] == (f"日期 2026-08-02(D5):全日客流 为 {row['全日客流']},"
f"分区甲、分区乙 之和为 {row['全日客流'] - 7}")
def test_r_sum_eq_nulls_unverifiable(tmp_path):
def hole(rows):
rows["日客流"][0]["分区甲"] = None
f = flow(tmp_path, rows_hook=hole)
r1 = by_id(run_checks(f.db, f.extraction, f.recipe))["R1"]
assert r1.status == "unverifiable" and r1.acceptable and r1.category == "data_quality"
assert r1.checked == 2 and r1.unverifiable == 1 and r1.failed == 0
def test_r_sum_eq_no_rows_is_unverifiable_not_passed(tmp_path):
"""AU-1:表里一行都没有(只有表头):一行都没核对过,不能判通过,也不能写「0 行全部成立」。"""
def empty(rows):
rows["日客流"].clear()
f = flow(tmp_path, rows_hook=empty)
r1 = by_id(run_checks(f.db, f.extraction, f.recipe))["R1"]
assert r1.status == "unverifiable" and r1.acceptable and r1.category == "data_quality"
assert r1.checked == 0 and r1.unverifiable == 0 and r1.failed == 0
assert r1.details == ["本期这张表没有数据行,关系未能核对"]
def test_r_sum_eq_real_uses_tolerance(tmp_path):
data = deepcopy(FLOW)
data["sheets"][0]["blocks"][0]["values"]["type"] = "REAL"
def floats(rows):
for r in rows["日客流"]:
r["分区甲"], r["分区乙"], r["全日客流"] = 0.1, 0.2, 0.3
for t in ("时段客流", "时段客流_表内合计"):
for r in rows[t]:
r["客流"] = None if r["客流"] is None else float(r["客流"])
f = flow(tmp_path, recipe_data=data, rows_hook=floats)
r1 = by_id(run_checks(f.db, f.extraction, f.recipe))["R1"]
assert r1.status == "passed", r1.details
assert "ABS(" in r1.sql
# 同样的数按整数写法(<>)比,就会误判不成立
conn = sqlite3.connect(f.db)
assert conn.execute('SELECT COUNT(*) FROM "日客流" WHERE "全日客流" <> "分区甲" + "分区乙"').fetchone()[0] == 3
conn.close()
def test_r_not_comparable_all_equal(tmp_path):
def equalize(rows):
for r in rows["日客流"]:
r["全日客流"] = sum(h["客流"] or 0 for h in rows["时段客流"] if h["日期"] == r["日期"])
r["分区甲"] = r["全日客流"] - r["分区乙"]
f = flow(tmp_path, rows_hook=equalize)
got = by_id(run_checks(f.db, f.extraction, f.recipe))
r2 = got["R2"]
assert r2.status == "info" and r2.category == "info"
assert r2.details == ["各时段之和与全日客流:3 天全部相等"]
assert r2.checked == 3 and r2.failed == 0
assert got["R1"].status == "passed"
def test_r_not_comparable_partly_equal_stays_info(tmp_path):
def one(rows):
r = rows["日客流"][0]
r["全日客流"] = sum(h["客流"] or 0 for h in rows["时段客流"] if h["日期"] == r["日期"])
f = flow(tmp_path, rows_hook=one)
r2 = by_id(run_checks(f.db, f.extraction, f.recipe))["R2"]
assert r2.status == "info" and r2.details == ["各时段之和与全日客流:3 天中 1 天相等"] and r2.failed == 2
# ---------------------------------------------------------------- N、P
def test_n_row_count_mismatch(tmp_path):
f = flow(tmp_path)
f.extraction.expected_rows["时段客流"] += 1
n2 = by_id(run_checks(f.db, f.extraction, f.recipe))["N2"]
assert n2.status == "mismatch" and n2.category == "structure"
assert n2.details == ["库里 51 行,按格子账应有 52 行"]
def test_p_duplicate_key(tmp_path):
def dup(rows):
rows["日客流"].append(dict(rows["日客流"][0]))
f = flow(tmp_path, rows_hook=dup, pk=False)
got = by_id(run_checks(f.db, f.extraction, f.recipe))
assert got["P1"].status == "mismatch" and got["P1"].details == ["1 组主键重复"]
assert got["P2"].status == "passed"
# ---------------------------------------------------------------- 编号与空值统计
def test_plan_numbering_follows_recipe_order(tmp_path):
f = flow(tmp_path)
plan = plan_checks(f.recipe, f.extraction)
assert plan.k == {"夜间合计": "K1"} and plan.g == {"夜间合计": "G1"} and plan.t == {}
assert len(plan.items["夜间合计"]) == 9
def test_column_null_counts(tmp_path):
f = flow(tmp_path)
counts = column_null_counts(f.db, f.recipe)
assert counts["时段客流"]["客流"] == 3 # 7-8 那一行整期是占位符
assert counts["日客流"] == {"日期": 0, "全日客流": 0, "分区甲": 0, "分区乙": 0}
# ---------------------------------------------------------------- 核对结果 → 说明(同一套编号)
def test_checks_feed_notes_end_to_end(tmp_path):
from app.data.recipe_notes import build_notes
from app.data.recipe_types import Acceptance, AxisOut, SheetsOut
def p1b(items):
for it in items:
it.value = None
f = flow(tmp_path, items_hook=p1b)
f.extraction.placeholders = {"·": 3}
f.extraction.sheets = SheetsOut(matched={"s1": SHEET})
f.extraction.axes = [AxisOut("交叉表", 4, "8月1日", "8月3日", 3, "text")]
checks = run_checks(f.db, f.extraction, f.recipe)
counts = column_null_counts(f.db, f.recipe)
pending = build_notes(f.recipe, f.extraction, checks, [], null_counts=counts)
assert pending.problems == [] # 未接受的「无法核对」只影响状态,不让说明生成失败
assert by_id(checks)["K1"].acceptable
notes = build_notes(f.recipe, f.extraction, checks, [Acceptance("K1", "原表未经计算保存")], null_counts=counts)
assert notes.problems == []
total = notes.tables["时段客流_表内合计"].comment
assert "本期原表未保存计算结果,合计值为空,未能核对" in total and "一致" not in total
assert "已逐日核对" in notes.tables["日客流"].comment
assert notes.tables["日客流"].columns["全日客流"] == "单位:人次"
assert "无数据占位符" in notes.tables["时段客流"].columns["客流"]
def test_structure_mismatch_feeds_notes_without_problem(tmp_path):
"""P9:K1、G1 都不一致,试运行因结构类核对拒收。说明不写合计那一句、也不额外记「说明没有对应模板」,
免得被当成配方问题显示。"""
from app.data.recipe_notes import build_notes
from app.data.recipe_types import SheetsOut
def p9(items):
it = items[0]
it.ref_rowids = it.ref_rowids[:3]
it.value += 11
f = flow(tmp_path, items_hook=p9)
f.extraction.sheets = SheetsOut(matched={"s1": SHEET})
checks = by_id(run_checks(f.db, f.extraction, f.recipe))
assert checks["K1"].status == "mismatch" and checks["G1"].status == "mismatch"
notes = build_notes(f.recipe, f.extraction, list(checks.values()), [],
null_counts=column_null_counts(f.db, f.recipe))
assert notes.problems == []
total = notes.tables["时段客流_表内合计"].comment
assert "原表写明的合计" not in total and "一致" not in total
def _list_notes_from_db(tmp_path, hook):
from app.data.recipe_notes import build_notes
from app.data.recipe_types import Acceptance
recipe, db, ex = list_case(tmp_path, hook=hook)
checks = run_checks(db, ex, recipe)
acc = [Acceptance(c.id, "已与业务方核实") for c in checks if c.acceptable]
return by_id(checks), build_notes(recipe, ex, checks, acc, null_counts=column_null_counts(db, recipe))
def test_list_total_placeholder_cell_gets_blank_template(tmp_path):
"""合计行的 金额 格写了已声明的占位符:非空格照样生成合计格(P2-SPEC 4.4),值转成空值(4.5),
T 判无法核对(blank)。说明要有对应的模板,不能记「没有对应的说明」把合法文件当配方问题拒收。"""
def placeholder(items):
items[0].value = None
checks, notes = _list_notes_from_db(tmp_path, placeholder)
assert checks["T1"].status == "unverifiable" and checks["T1"].reasons == {"blank": 1}
assert notes.problems == []
total = notes.tables["销售_表内合计"].comment
assert total == "原表合计行的值。本期原表合计格为空,未能核对:不要与 销售 相加"
assert "一致" not in total
def test_list_total_blank_cell_not_claimed_as_checked(tmp_path):
"""合计行的 数量 格是空的:不生成合计格,T 只核对 金额 并通过。说明不能写「各列都核对一致」。"""
def drop(items):
del items[1]
checks, notes = _list_notes_from_db(tmp_path, drop)
assert checks["T1"].status == "passed" and checks["T1"].checked == 1
assert notes.problems == []
assert notes.tables["销售_表内合计"].comment == (
"原表合计行的值。本期原表 数量 的合计格为空,其余各列已按明细求和核对一致:不要与 销售 相加")