mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:32:00 +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(部署本来就是单进程)
795 lines
44 KiB
Python
795 lines
44 KiB
Python
"""说明生成(recipe_notes)、写进表结构(apply_notes)与工具描述、冻结白名单(tools/datasource)的测试。
|
||
|
||
不依赖执行器和核对模块的库:核对结果、Extraction 都在测试里手写(假名)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from copy import deepcopy
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
import app.db.models # noqa: F401 - 会话级建表(conftest)按已导入的模型建:冻结 schema_snapshot 要用工件表
|
||
from app.data import recipe_parsers as P
|
||
from app.data.recipe_notes import CONSISTENT_KEYS, COLUMN_TEXT, NOTE_TEXT, apply_notes, build_notes
|
||
from app.data.recipe_types import (
|
||
Acceptance,
|
||
AxisOut,
|
||
CheckResult,
|
||
DerivedItem,
|
||
Extraction,
|
||
OutsideText,
|
||
PeriodOut,
|
||
Problem,
|
||
Recipe,
|
||
SchemaNotes,
|
||
SheetsOut,
|
||
prose_problems,
|
||
)
|
||
|
||
FLOW = json.loads((Path(__file__).parent / "fixtures" / "recipes" / "flow_recipe.json").read_text(encoding="utf-8"))
|
||
SHEET = "客流汇总"
|
||
TOTALS = "时段客流_表内合计"
|
||
NULLS = {"日客流": {"日期": 0, "全日客流": 0, "分区甲": 0, "分区乙": 0},
|
||
"时段客流": {"日期": 0, "时段": 0, "时段类别": 0, "起始小时": 0, "结束小时": 0, "客流": 31},
|
||
TOTALS: {"日期": 0, "合计项": 0, "起始小时": 0, "结束小时": 0, "客流": 0}}
|
||
|
||
|
||
def chk(cid: str, kind: str, status: str = "passed", category: str = "structure", **kw: Any) -> CheckResult:
|
||
return CheckResult(cid, kind, cid, status, category, **kw)
|
||
|
||
|
||
def flow_checks(**override: CheckResult) -> list[CheckResult]:
|
||
base = {
|
||
"C1": chk("C1", "context_agree", category="data_quality"),
|
||
"C2": chk("C2", "filename_period", category="data_quality"),
|
||
"K1": chk("K1", "derived_sum", checked=93),
|
||
"G1": chk("G1", "formula_refs", checked=93),
|
||
"R1": chk("R1", "relation_sum_eq", category="data_quality", checked=31),
|
||
"R2": chk("R2", "relation_not_comparable", "info", "info", checked=31, failed=31),
|
||
**{f"N{i}": chk(f"N{i}", "row_count") for i in (1, 2, 3)},
|
||
**{f"P{i}": chk(f"P{i}", "pk_unique") for i in (1, 2, 3)},
|
||
}
|
||
base.update(override)
|
||
return [c for c in base.values() if c is not None]
|
||
|
||
|
||
def night_item(**kw: Any) -> DerivedItem:
|
||
args = dict(kind="label_range_sum", segment="夜间合计", sheet=SHEET, cell="C28", label_raw="18-22 时合计",
|
||
label="18-22时合计", value=1, is_formula=True, formula="=SUM(C22:C25)", base_table="时段客流",
|
||
base_value="客流", start=18, end=22, key={"日期": "2026-08-01", "时段类别": "夜间"},
|
||
start_col="起始小时", end_col="结束小时", ref_rowids=[1], func="SUM")
|
||
args.update(kw)
|
||
return DerivedItem(**args)
|
||
|
||
|
||
def flow_extraction(**kw: Any) -> Extraction:
|
||
args: dict[str, Any] = dict(
|
||
ok=True, derived=[night_item()], placeholders={"·": 31},
|
||
axes=[AxisOut("交叉表", 4, "8月1日", "8月31日", 31, "text")],
|
||
outside_text=[OutsideText(SHEET, "B3", "客流汇总表", "text")],
|
||
sheets=SheetsOut(matched={"s1": SHEET}),
|
||
period=PeriodOut("2026-08-01", "2026-08-31", "cells", [f"{SHEET}!B2"]),
|
||
)
|
||
args.update(kw)
|
||
return Extraction(**args)
|
||
|
||
|
||
def notes_for(data: dict | None = None, *, extraction: Extraction | None = None,
|
||
checks: list[CheckResult] | None = None, acceptances: list[Acceptance] | None = None,
|
||
null_counts: Any = NULLS):
|
||
recipe = Recipe.model_validate(deepcopy(data or FLOW))
|
||
return build_notes(recipe, extraction or flow_extraction(), flow_checks() if checks is None else checks,
|
||
acceptances or [], null_counts=null_counts)
|
||
|
||
|
||
def accept(*ids: str) -> list[Acceptance]:
|
||
return [Acceptance(i, "已与业务方核实") for i in ids]
|
||
|
||
|
||
# ---------------------------------------------------------------- 参考配方(D00)
|
||
|
||
|
||
def test_reference_flow_notes():
|
||
notes = notes_for()
|
||
assert notes.problems == []
|
||
assert notes.tables["日客流"].comment == (
|
||
"粒度:每个日期。全日客流 等于 分区甲 与 分区乙 之和(导入时已逐日核对):求合计只用 全日客流,不要把这几列相加。"
|
||
"与 时段客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加")
|
||
assert notes.tables["时段客流"].comment == (
|
||
"粒度:每个日期的每个时段。与 日客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加。"
|
||
"时段 按规范写法存储:起始小时-结束小时,不带单位。时段类别 取自原表的分段标题。"
|
||
"原表的合计在 时段客流_表内合计,不要与本表相加")
|
||
assert notes.tables[TOTALS].comment == (
|
||
"粒度:每个日期的每个合计项。原表写明的合计,已按明细重算核对一致。"
|
||
"各合计项覆盖的时段可能互相重叠:不要彼此相加,也不要与 时段客流 相加")
|
||
date = "格式 YYYY-MM-DD,年份按统计期补全"
|
||
assert notes.tables["日客流"].columns == {"日期": date, "全日客流": "单位:人次", "分区甲": "单位:人次",
|
||
"分区乙": "单位:人次"}
|
||
assert notes.tables["时段客流"].columns == {
|
||
"日期": date, "客流": "单位:人次。空值表示原表为无数据占位符,不代表零;求和时请同时统计非空个数"}
|
||
assert notes.tables[TOTALS].columns == {"日期": date, "客流": "单位:人次"}
|
||
# 模板带引用,进清单可复查;渲染后的说明没有引用标记
|
||
assert "{列:全日客流} 等于 {列:分区甲} 与 {列:分区乙} 之和" in notes.templates["日客流"]
|
||
assert notes.templates["时段客流.客流"].startswith("单位:{单位:人次}")
|
||
# B3「客流汇总表」是纯文字的区域外文字:不加 E7 提示
|
||
assert all("附有说明文字" not in t.comment for t in notes.tables.values())
|
||
|
||
|
||
def test_all_rendered_notes_pass_prose_check_with_known_names():
|
||
recipe = Recipe.model_validate(deepcopy(FLOW))
|
||
notes = notes_for()
|
||
known = {"列": {"日期", "全日客流", "分区甲", "分区乙", "时段", "时段类别", "起始小时", "结束小时", "客流", "合计项"},
|
||
"表": {"日客流", "时段客流", TOTALS}, "单位": set(P.UNITS)}
|
||
assert set(notes.templates) >= {t.name for t in recipe.tables}
|
||
for key, tpl in notes.templates.items():
|
||
assert prose_problems(tpl, known=known) == [], key
|
||
|
||
|
||
# ---------------------------------------------------------------- 每个模板都过数字和引用检查
|
||
|
||
_SLOTS = {
|
||
"a": "{列:日期}", "b": "{列:时段}", "total": "{列:全日客流}", "parts": "{列:分区甲} 与 {列:分区乙}", "per": "逐日",
|
||
"unit": "日期", "other": "{表:时段客流}", "dims": "{列:时段}", "value": "{列:全日客流}", "a_value": "{列:客流}",
|
||
"by": "{列:日期}", "dim": "{列:时段}", "start": "{列:起始小时}", "end": "{列:结束小时}", "col": "{列:时段类别}",
|
||
"table": "{表:时段客流_表内合计}", "base": "{表:时段客流}", "cols": "{列:现金}、{列:刷卡}",
|
||
}
|
||
_KNOWN = {"列": {"日期", "时段", "全日客流", "分区甲", "分区乙", "客流", "起始小时", "结束小时", "时段类别", "现金",
|
||
"刷卡"},
|
||
"表": {"时段客流", TOTALS}, "单位": set(P.UNITS)}
|
||
|
||
|
||
@pytest.mark.parametrize("key", sorted(NOTE_TEXT))
|
||
def test_every_table_template_passes_prose_check(key):
|
||
assert prose_problems(NOTE_TEXT[key].format(**_SLOTS), known=_KNOWN) == []
|
||
|
||
|
||
@pytest.mark.parametrize("key", sorted(COLUMN_TEXT))
|
||
@pytest.mark.parametrize("unit", P.UNITS)
|
||
def test_every_column_template_passes_prose_check(key, unit):
|
||
text = COLUMN_TEXT[key].format(unit="{单位:" + unit + "}", meaning="不适用")
|
||
assert prose_problems(text, known=_KNOWN) == []
|
||
|
||
|
||
def test_only_passed_templates_say_consistent():
|
||
"""「一致」只许出现在 K / T 通过时才选的模板里。别的模板(包括统计期冲突那两条)一个「一致」都没有:
|
||
K / T 没通过的表,说明由这些片段拼成,按子串判断也不会误报。"""
|
||
assert CONSISTENT_KEYS <= set(NOTE_TEXT)
|
||
for key, text in NOTE_TEXT.items():
|
||
if key not in CONSISTENT_KEYS:
|
||
assert "一致" not in text, key
|
||
assert all("一致" not in text for text in COLUMN_TEXT.values())
|
||
|
||
|
||
# ---------------------------------------------------------------- 交叉表的表内合计:状态 × 原因
|
||
|
||
_K_EXPECT = {
|
||
"uncached": "原表写明的合计。本期原表未保存计算结果,合计值为空,未能核对",
|
||
"blank": "原表写明的合计。本期原表合计格为空,未能核对",
|
||
"null_detail": "原表写明的合计,按原表保存的值存储。本期明细含无数据占位符,未能核对",
|
||
"tiling": "原表写明的合计,按原表保存的值存储。本期明细未能覆盖合计的全部范围,未能核对",
|
||
"hidden": "原表写明的合计,按原表保存的值存储。本期存在隐藏行或使用了分类汇总函数,合计口径无法确定,未能核对",
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize("reason", sorted(_K_EXPECT))
|
||
@pytest.mark.parametrize("accepted", [True, False])
|
||
def test_k_unverifiable_each_reason_never_says_consistent(reason, accepted):
|
||
k = chk("K1", "derived_sum", "unverifiable", unverifiable=93, acceptable=True, reasons={reason: 93})
|
||
notes = notes_for(checks=flow_checks(K1=k), acceptances=accept("K1") if accepted else [])
|
||
assert notes.problems == []
|
||
comment = notes.tables[TOTALS].comment
|
||
assert _K_EXPECT[reason] in comment
|
||
assert "一致" not in comment
|
||
assert "各合计项覆盖的时段可能互相重叠" in comment
|
||
|
||
|
||
def test_k_mixed_reasons_use_weakest_wording():
|
||
k = chk("K1", "derived_sum", "unverifiable", acceptable=True, reasons={"uncached": 2, "hidden": 1})
|
||
notes = notes_for(checks=flow_checks(K1=k), acceptances=accept("K1"))
|
||
comment = notes.tables[TOTALS].comment
|
||
assert "原表写明的合计,按原表保存的值存储。本期合计值未能核对" in comment
|
||
assert "一致" not in comment and "未保存计算结果" not in comment and "分类汇总" not in comment
|
||
|
||
|
||
def test_k_passed_but_g_unverifiable():
|
||
g = chk("G1", "formula_refs", "unverifiable", acceptable=True, reasons={"unrecognized": 3})
|
||
notes = notes_for(checks=flow_checks(G1=g), acceptances=accept("G1"))
|
||
assert notes.problems == []
|
||
assert "原表写明的合计,合计值已按明细核对一致,公式引用的格子未能核对" in notes.tables[TOTALS].comment
|
||
|
||
|
||
def test_k_passed_without_formulas_has_no_g():
|
||
ex = flow_extraction(derived=[night_item(is_formula=False, formula=None, func=None, ref_rowids=None)])
|
||
notes = notes_for(extraction=ex, checks=flow_checks(G1=None))
|
||
assert notes.problems == []
|
||
assert "原表写明的合计,已按明细重算核对一致" in notes.tables[TOTALS].comment
|
||
|
||
|
||
@pytest.mark.parametrize("k,g", [
|
||
(chk("K1", "derived_sum", "unverifiable", acceptable=True, reasons={"unrecognized": 1}), None),
|
||
(chk("K1", "derived_sum", "unverifiable", acceptable=True, reasons={}), None),
|
||
(chk("K1", "derived_sum", "info", "info"), None),
|
||
(chk("K1", "derived_sum", "mismatch", acceptable=True), None),
|
||
])
|
||
def test_no_template_records_problem_and_never_falls_back(k, g):
|
||
"""找不到对应模板就是 bug:记 problems(不能保存),绝不退回到「核对一致」之类的别的说法。"""
|
||
override = {}
|
||
if k is not None:
|
||
override["K1"] = k
|
||
if g is not None:
|
||
override["G1"] = g
|
||
notes = notes_for(checks=flow_checks(**override))
|
||
assert any(TOTALS in p for p in notes.problems)
|
||
comment = notes.tables[TOTALS].comment
|
||
assert "原表写明的合计" not in comment and "一致" not in comment
|
||
|
||
|
||
@pytest.mark.parametrize("override", [
|
||
{"K1": chk("K1", "derived_sum", "mismatch", failed=1)},
|
||
{"G1": chk("G1", "formula_refs", "mismatch", failed=1)},
|
||
{"K1": chk("K1", "derived_sum", "mismatch", failed=1), "G1": chk("G1", "formula_refs", "mismatch", failed=1)},
|
||
# K 不一致、G 无法核对:只要有一条不可接受的结构类失败,整段都不写
|
||
{"K1": chk("K1", "derived_sum", "mismatch", failed=1),
|
||
"G1": chk("G1", "formula_refs", "unverifiable", acceptable=True, reasons={"hidden": 1})},
|
||
])
|
||
def test_blocking_structure_failure_skips_total_note_without_problem(override):
|
||
"""K / G 不一致(D08、P9):试运行本来就因结构类核对拒收。说明里不写合计那一句(更不会写「一致」),
|
||
也不记 problems——problems 会被当成「配方不合法 / 说明生成失败」,把用户引去改配方。"""
|
||
notes = notes_for(checks=flow_checks(**override))
|
||
assert notes.problems == []
|
||
comment = notes.tables[TOTALS].comment
|
||
assert "原表写明的合计" not in comment and "一致" not in comment
|
||
# 与核对无关、本身成立的提示照写
|
||
assert "各合计项覆盖的时段可能互相重叠" in comment
|
||
|
||
|
||
def test_blocking_relation_failure_skips_relation_note():
|
||
"""关系核对的 SQL 出错(_guarded:mismatch、structure、不可接受):不能当成「关系不成立、尚未确认」去写。"""
|
||
broken = {"R1": chk("R1", "relation_sum_eq", "mismatch", details=["核对 SQL 执行失败"]),
|
||
"R2": chk("R2", "relation_not_comparable", "mismatch", details=["核对 SQL 执行失败"])}
|
||
notes = notes_for(checks=flow_checks(**broken))
|
||
assert notes.problems == []
|
||
daily = notes.tables["日客流"].comment
|
||
assert "分区甲" not in daily and "口径不同" not in daily and "尚未确认" not in daily
|
||
|
||
|
||
# ---------------------------------------------------------------- 一张合计表由两个 derived 段写入
|
||
|
||
|
||
def two_totals_recipe() -> dict:
|
||
"""日间、夜间各有一组合计行,都另存进 时段客流_表内合计(P2-SPEC 6.1 允许两段时段合表)。"""
|
||
data = deepcopy(FLOW)
|
||
segs = data["sheets"][0]["blocks"][0]["segments"]
|
||
segs[1]["stop_parser"] = "hour_range_total"
|
||
day = deepcopy(segs[3])
|
||
day.update(id="日间合计", locate={"by": "after", "segment": "日间"}, labels={"expect": ["8-12 时合计"]})
|
||
segs.insert(2, day)
|
||
return data
|
||
|
||
|
||
def day_item(**kw: Any) -> DerivedItem:
|
||
args = dict(segment="日间合计", cell="C21", label_raw="8-12 时合计", label="8-12时合计", formula="=SUM(C11:C14)",
|
||
start=8, end=12, key={"日期": "2026-08-01", "时段类别": "日间"})
|
||
args.update(kw)
|
||
return night_item(**args)
|
||
|
||
|
||
def two_totals(**override: CheckResult) -> SchemaNotes:
|
||
"""K1 / G1 = 日间合计,K2 / G2 = 夜间合计(plan_checks 按配方里分段的先后编号)。"""
|
||
base = {"K1": chk("K1", "derived_sum", checked=31), "K2": chk("K2", "derived_sum", checked=93),
|
||
"G1": chk("G1", "formula_refs", checked=31), "G2": chk("G2", "formula_refs", checked=93)}
|
||
base.update(override)
|
||
ex = flow_extraction(derived=[day_item(), night_item()])
|
||
return notes_for(two_totals_recipe(), extraction=ex, checks=flow_checks(**base), acceptances=accept(*base))
|
||
|
||
|
||
_UNC = {"status": "unverifiable", "acceptable": True}
|
||
|
||
|
||
def test_two_segments_into_one_total_table_all_passed():
|
||
notes = two_totals()
|
||
assert notes.problems == []
|
||
assert "原表写明的合计,已按明细重算核对一致" in notes.tables[TOTALS].comment
|
||
|
||
|
||
@pytest.mark.parametrize("k1,k2", [
|
||
({"reasons": {"uncached": 31}, **_UNC}, {}), # 日间无法核对、夜间通过
|
||
({}, {"reasons": {"hidden": 2}, **_UNC}), # 反过来
|
||
({"reasons": {"uncached": 31}, **_UNC}, {"reasons": {"hidden": 2}, **_UNC}), # 两段原因不同
|
||
])
|
||
def test_two_segments_partly_unverifiable_use_mixed_template(k1, k2):
|
||
"""几个 derived 段写进同一张合计表:只要有一段没通过,就不能写「一致」;一段通过、一段无法核对,或者两段
|
||
原因不同,都用最弱的「原因混合」说法,不挑其中一段的原因来写。"""
|
||
notes = two_totals(K1=chk("K1", "derived_sum", **k1), K2=chk("K2", "derived_sum", **k2))
|
||
assert notes.problems == []
|
||
comment = notes.tables[TOTALS].comment
|
||
assert "一致" not in comment
|
||
assert "原表写明的合计,按原表保存的值存储。本期合计值未能核对" in comment
|
||
assert "未保存计算结果" not in comment and "分类汇总" not in comment
|
||
assert comment.count("各合计项覆盖的时段可能互相重叠") == 1
|
||
|
||
|
||
def test_two_segments_same_reason_use_that_reason():
|
||
unc = {"reasons": {"uncached": 3}, **_UNC}
|
||
notes = two_totals(K1=chk("K1", "derived_sum", **unc), K2=chk("K2", "derived_sum", **unc))
|
||
assert notes.problems == []
|
||
comment = notes.tables[TOTALS].comment
|
||
assert "原表写明的合计。本期原表未保存计算结果,合计值为空,未能核对" in comment and "一致" not in comment
|
||
|
||
|
||
def test_two_segments_one_g_unverifiable():
|
||
notes = two_totals(G2=chk("G2", "formula_refs", "unverifiable", acceptable=True, reasons={"unrecognized": 1}))
|
||
assert notes.problems == []
|
||
assert "原表写明的合计,合计值已按明细核对一致,公式引用的格子未能核对" in notes.tables[TOTALS].comment
|
||
|
||
|
||
def test_two_segments_one_blocking_skips_without_problem():
|
||
notes = two_totals(K1=chk("K1", "derived_sum", **{"reasons": {"uncached": 31}, **_UNC}),
|
||
K2=chk("K2", "derived_sum", "mismatch", failed=1))
|
||
assert notes.problems == []
|
||
assert "原表写明的合计" not in notes.tables[TOTALS].comment
|
||
|
||
|
||
# ---------------------------------------------------------------- 统计期冲突与「一致」
|
||
|
||
|
||
@pytest.mark.parametrize("reason", sorted(_K_EXPECT))
|
||
def test_period_conflict_with_unverifiable_k_never_says_consistent(reason):
|
||
"""K 无法核对、C1 / C2 冲突都已接受:合计表的说明里同时有统计期那一句,仍然一个「一致」都没有。"""
|
||
k = chk("K1", "derived_sum", "unverifiable", acceptable=True, reasons={reason: 3})
|
||
c1 = chk("C1", "context_agree", "mismatch", "data_quality", acceptable=True)
|
||
c2 = chk("C2", "filename_period", "mismatch", "data_quality", acceptable=True)
|
||
notes = notes_for(checks=flow_checks(K1=k, C1=c1, C2=c2), acceptances=accept("K1", "C1", "C2"))
|
||
assert notes.problems == []
|
||
comment = notes.tables[TOTALS].comment
|
||
assert "本期统计期的写法在原表或文件名中有冲突,已由用户确认接受" in comment
|
||
assert "一致" not in comment
|
||
|
||
|
||
def test_missing_check_is_problem():
|
||
notes = notes_for(checks=flow_checks(R1=None))
|
||
assert any("R1" in p and "日客流" in p for p in notes.problems)
|
||
|
||
|
||
# ---------------------------------------------------------------- 关系
|
||
|
||
def test_sum_eq_acceptance_rewrites_note():
|
||
"""D26:R1 不成立。接受前后都不再写「已逐日核对」;接受后写「已由用户确认接受」。"""
|
||
assert "已逐日核对" in notes_for().tables["日客流"].comment
|
||
r1 = chk("R1", "relation_sum_eq", "mismatch", "data_quality", checked=30, failed=1, acceptable=True)
|
||
accepted = notes_for(checks=flow_checks(R1=r1), acceptances=accept("R1"))
|
||
pending = notes_for(checks=flow_checks(R1=r1))
|
||
assert accepted.problems == [] and pending.problems == []
|
||
a, p = accepted.tables["日客流"].comment, pending.tables["日客流"].comment
|
||
assert "已逐日核对" not in a and "已逐日核对" not in p
|
||
assert ("全日客流 与 分区甲、分区乙 之和的关系在本期个别日期不成立,已由用户确认接受,详见导入清单:"
|
||
"不要据此推算,也不要把这几列相加") in a
|
||
assert "尚未确认接受" in p and "已由用户确认接受" not in p
|
||
assert "求合计只用" not in a
|
||
|
||
|
||
def test_sum_eq_with_nulls():
|
||
r1 = chk("R1", "relation_sum_eq", "unverifiable", "data_quality", unverifiable=2, acceptable=True)
|
||
comment = notes_for(checks=flow_checks(R1=r1), acceptances=accept("R1")).tables["日客流"].comment
|
||
assert "全日客流 与 分区甲、分区乙 之和的关系在本期部分日期因空值未能核对:不要据此推算,也不要把这几列相加" in comment
|
||
assert "已逐日核对" not in comment
|
||
|
||
|
||
def test_sum_eq_without_rows_says_so_and_not_checked():
|
||
"""AU-1:没有数据行时 R 判无法核对(checked=0、unverifiable=0):说明写「没有数据行」,不写「已核对」「因空值」。"""
|
||
r1 = chk("R1", "relation_sum_eq", "unverifiable", "data_quality", checked=0, unverifiable=0, acceptable=True)
|
||
comment = notes_for(checks=flow_checks(R1=r1), acceptances=accept("R1")).tables["日客流"].comment
|
||
assert "全日客流 与 分区甲、分区乙 之和的关系在本期没有数据行,未能核对:不要据此推算,也不要把这几列相加" in comment
|
||
assert "已逐日核对" not in comment and "因空值" not in comment
|
||
|
||
|
||
def test_not_comparable_all_equal_drops_not_equal_wording():
|
||
r2 = chk("R2", "relation_not_comparable", "info", "info", checked=31, failed=0)
|
||
notes = notes_for(checks=flow_checks(R2=r2))
|
||
for table in ("日客流", "时段客流"):
|
||
comment = notes.tables[table].comment
|
||
assert "口径不同,不能互相推算或相加" in comment and "不等于" not in comment
|
||
assert "与 时段客流 口径不同" in notes.tables["日客流"].comment
|
||
assert "与 日客流 口径不同" in notes.tables["时段客流"].comment
|
||
|
||
|
||
def test_not_comparable_flat_variant():
|
||
data = deepcopy(FLOW)
|
||
data["relations"][1]["a"] = {"table": "日客流", "value": "分区甲"}
|
||
data["relations"][1]["b"] = {"table": TOTALS, "value": "客流"}
|
||
notes = notes_for(data)
|
||
assert notes.problems == []
|
||
assert "与 时段客流_表内合计 口径不同:分区甲 按 日期 汇总不等于 客流,不能互相推算或相加" in notes.tables["日客流"].comment
|
||
|
||
|
||
# ---------------------------------------------------------------- 单位、列名里的数字
|
||
|
||
|
||
def test_digit_names_and_units_pass_checks():
|
||
data = deepcopy(FLOW)
|
||
seg = data["sheets"][0]["blocks"][0]["segments"][0]
|
||
seg["labels"]["expect"] = ["1月(万元)", "3号门客流(千克)", "指标2(亿元)"]
|
||
seg["measures"] = {"1月(万元)": "1月", "3号门客流(千克)": "3号门客流", "指标2(亿元)": "指标2"}
|
||
data["tables"][0]["units"] = {"1月": "万元", "3号门客流": "千克", "指标2": "亿元"}
|
||
data["relations"][0].update(total="1月", parts=["3号门客流", "指标2"])
|
||
data["relations"][1]["b"]["value"] = "1月"
|
||
data["tables"][0]["note"] = "{列:3号门客流} 只统计闸机,{单位:万元} 与 {单位:亿元} 不要混算"
|
||
notes = notes_for(data, null_counts=None)
|
||
assert notes.problems == []
|
||
comment = notes.tables["日客流"].comment
|
||
assert "1月 等于 3号门客流 与 指标2 之和(导入时已逐日核对)" in comment
|
||
assert comment.endswith("3号门客流 只统计闸机,万元 与 亿元 不要混算")
|
||
assert notes.tables["日客流"].columns["1月"].startswith("单位:万元")
|
||
assert notes.tables["日客流"].columns["指标2"].startswith("单位:亿元")
|
||
|
||
|
||
def test_unknown_unit_in_column_note_is_problem():
|
||
"""列说明同样过引用检查:单位不在系统单位表里(「万人次」),{单位:…} 不遮盖、记问题,不能保存。"""
|
||
data = deepcopy(FLOW)
|
||
data["tables"][0]["units"]["全日客流"] = "万人次"
|
||
notes = notes_for(data)
|
||
assert any("全日客流" in p and "不存在的单位「万人次」" in p for p in notes.problems)
|
||
|
||
|
||
def test_note_smuggling_numbers_is_problem():
|
||
data = deepcopy(FLOW)
|
||
data["tables"][0]["note"] = "{列:增长30%} 要单独看"
|
||
notes = notes_for(data)
|
||
assert any("日客流" in p and "不存在" in p for p in notes.problems)
|
||
assert any("数字" in p for p in notes.problems)
|
||
|
||
|
||
# ---------------------------------------------------------------- 本期情况:E7、统计期、隐藏行、忽略列、停止之后
|
||
|
||
|
||
def test_e7_only_for_text_digits():
|
||
plain = notes_for()
|
||
assert all("附有说明文字" not in t.comment for t in plain.tables.values())
|
||
for extra in (OutsideText(SHEET, "B32", "注:9月15日闸机故障,当日客流为估算值", "text_digits"),
|
||
OutsideText(SHEET, "B3", "客流汇总表(2026年9月)", "text_digits", period_source=True)):
|
||
ex = flow_extraction(outside_text=[OutsideText(SHEET, "B3", "客流汇总表", "text"), extra])
|
||
notes = notes_for(extraction=ex)
|
||
assert notes.problems == []
|
||
for t in notes.tables.values():
|
||
assert "本期原表附有说明文字(可能涉及口径),内容见证据面板" in t.comment
|
||
assert "9月" not in t.comment and "B32" not in t.comment
|
||
|
||
|
||
def test_period_conflict_and_human_period():
|
||
c1 = chk("C1", "context_agree", "mismatch", "data_quality", acceptable=True)
|
||
accepted = notes_for(checks=flow_checks(C1=c1), acceptances=accept("C1"))
|
||
pending = notes_for(checks=flow_checks(C1=c1))
|
||
assert "本期统计期的写法在原表或文件名中有冲突,已由用户确认接受,详见导入清单" in accepted.tables["日客流"].comment
|
||
assert "本期统计期的写法在原表或文件名中有冲突,尚未确认接受" in pending.tables["日客流"].comment
|
||
human = flow_extraction(period=PeriodOut("2026-09-01", "2026-09-30", "human", signed_by="甲"))
|
||
assert "本期统计期为人工录入" in notes_for(extraction=human).tables["时段客流"].comment
|
||
assert "人工录入" not in notes_for().tables["时段客流"].comment
|
||
|
||
|
||
LIST_RECIPE = {
|
||
"recipe_format": "agentlab-recipe/2",
|
||
"sheets": [{"id": "s1", "match": {"name": "销售"}, "hidden": {"rows": "exclude"}, "blocks": [{
|
||
"id": "明细", "layout": "list", "table": "销售",
|
||
"columns": [{"header": "日期", "name": "日期", "type": "DATE"},
|
||
{"header": "地区", "name": "地区", "type": "TEXT"},
|
||
{"header": "产品", "name": "产品", "type": "TEXT"},
|
||
{"header": "金额(元)", "name": "金额", "type": "INTEGER"},
|
||
{"header": "现金", "name": "现金", "type": "INTEGER"},
|
||
{"header": "刷卡", "name": "刷卡", "type": "INTEGER"}],
|
||
"rows": {"total_row": {"label_column": "地区", "pick": "合计", "keep_as": "销售_表内合计"}}}]}],
|
||
"tables": [{"name": "销售", "grain": ["日期", "地区", "产品"], "units": {"金额": "元"}},
|
||
{"name": "销售_表内合计", "kind": "reported_total", "units": {"金额": "元"}}],
|
||
"relations": [{"id": "R1", "kind": "sum_eq", "table": "销售", "total": "金额", "parts": ["现金", "刷卡"]}],
|
||
}
|
||
|
||
|
||
LIST_NUMBERS = ("金额", "现金", "刷卡")
|
||
|
||
|
||
def list_item(col: str = "金额") -> DerivedItem:
|
||
cell = {"金额": "D9", "现金": "E9", "刷卡": "F9"}.get(col, "D9")
|
||
return DerivedItem(kind="column_sum", segment="明细", sheet="销售", cell=cell, label_raw="合计", label="合计",
|
||
value=1, is_formula=False, formula=None, base_table="销售", base_value=col,
|
||
rowid_first=1, rowid_last=6)
|
||
|
||
|
||
def list_notes(t1: CheckResult | None = None, *, data: dict | None = None, accepted: bool = True,
|
||
**ex: Any):
|
||
checks = [t1 or chk("T1", "column_sum"), chk("R1", "relation_sum_eq", category="data_quality")]
|
||
args = dict(ok=True, derived=[list_item(c) for c in LIST_NUMBERS], sheets=SheetsOut(matched={"s1": "销售"}),
|
||
lineage={"销售": {"金额": [[1, "销售", "D2", 6, "down"]]}})
|
||
args.update(ex)
|
||
recipe = Recipe.model_validate(deepcopy(data or LIST_RECIPE))
|
||
return build_notes(recipe, Extraction(**args), checks, accept("T1") if accepted else [])
|
||
|
||
|
||
def test_list_reference_notes():
|
||
notes = list_notes()
|
||
assert notes.problems == []
|
||
assert notes.tables["销售"].comment == (
|
||
"粒度:日期、地区 与 产品 的每个组合。金额 等于 现金 与 刷卡 之和(导入时已逐行核对):求合计只用 金额,"
|
||
"不要把这几列相加。原表的合计在 销售_表内合计,不要与本表相加")
|
||
assert notes.tables["销售_表内合计"].comment == "原表合计行的值,已按各列明细求和核对一致:不要与 销售 相加"
|
||
cols = notes.tables["销售"].columns
|
||
assert cols["日期"] == "格式 YYYY-MM-DD"
|
||
assert cols["金额"] == "单位:元。空值表示原表为空格,不代表零;求和时请同时统计非空个数"
|
||
assert "地区" not in cols
|
||
# 列表合计表的空值不写列说明(表说明已交代)
|
||
assert notes.tables["销售_表内合计"].columns == {"金额": "单位:元"}
|
||
|
||
|
||
_T_EXPECT = {
|
||
"uncached": "原表合计行的值。本期原表未保存计算结果,合计值为空,未能核对:不要与 销售 相加",
|
||
"blank": "原表合计行的值。本期原表合计格为空,未能核对:不要与 销售 相加",
|
||
"tiling": "原表合计行的值,按原表保存的值存储。本期合计行之上没有明细,未能核对:不要与 销售 相加",
|
||
"null_detail": "原表合计行的值,按原表保存的值存储。本期明细含空值,未能核对:不要与 销售 相加",
|
||
"hidden": "原表合计行的值,按原表保存的值存储。本期存在隐藏行或使用了分类汇总函数,合计口径无法确定,未能核对:不要与 销售 相加",
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize("reason", sorted(_T_EXPECT))
|
||
def test_t_unverifiable_each_reason_never_says_consistent(reason):
|
||
notes = list_notes(chk("T1", "column_sum", "unverifiable", acceptable=True, reasons={reason: 1}))
|
||
assert notes.problems == []
|
||
comment = notes.tables["销售_表内合计"].comment
|
||
assert comment == _T_EXPECT[reason]
|
||
assert "一致" not in comment
|
||
|
||
|
||
def test_t_mixed_and_missing_templates():
|
||
mixed = list_notes(chk("T1", "column_sum", "unverifiable", acceptable=True, reasons={"uncached": 1, "hidden": 1}))
|
||
assert mixed.tables["销售_表内合计"].comment == "原表合计行的值,按原表保存的值存储。本期未能核对:不要与 销售 相加"
|
||
# 列表合计没有「公式写法无法识别」这种原因(那是公式引用 G 的):真出现了就是没有模板,记问题
|
||
odd = list_notes(chk("T1", "column_sum", "unverifiable", acceptable=True, reasons={"unrecognized": 1}))
|
||
assert any("T1" in p for p in odd.problems)
|
||
assert "一致" not in odd.tables["销售_表内合计"].comment
|
||
# T 不一致:试运行已因结构类核对拒收,不写这一句、不记问题
|
||
mismatch = list_notes(chk("T1", "column_sum", "mismatch", failed=1))
|
||
assert mismatch.problems == []
|
||
assert "原表合计行的值" not in mismatch.tables["销售_表内合计"].comment
|
||
|
||
|
||
def test_list_total_with_blank_cells_does_not_claim_every_column():
|
||
"""合计行里 现金、刷卡 的格子是空的(P2-SPEC 4.4:空格不生成合计格),T 只核对了 金额 并通过:
|
||
不能写「各列都核对一致」,要点名哪几列的合计格为空。只看合计格,不依赖 null_counts。"""
|
||
notes = list_notes(derived=[list_item("金额")])
|
||
assert notes.problems == []
|
||
assert notes.tables["销售_表内合计"].comment == (
|
||
"原表合计行的值。本期原表 现金、刷卡 的合计格为空,其余各列已按明细求和核对一致:不要与 销售 相加")
|
||
assert "各列明细求和核对一致" not in notes.tables["销售_表内合计"].comment
|
||
one = list_notes(derived=[list_item("金额"), list_item("刷卡")])
|
||
assert "本期原表 现金 的合计格为空,其余各列" in one.tables["销售_表内合计"].comment
|
||
|
||
|
||
def test_list_total_not_kept():
|
||
data = deepcopy(LIST_RECIPE)
|
||
data["sheets"][0]["blocks"][0]["rows"]["total_row"]["keep_as"] = None
|
||
data["tables"] = data["tables"][:1]
|
||
notes = list_notes(data=data)
|
||
assert notes.problems == []
|
||
assert "原表的合计行未导入本表" in notes.tables["销售"].comment
|
||
assert "销售_表内合计" not in notes.tables
|
||
|
||
|
||
def test_crosstab_total_not_kept():
|
||
data = deepcopy(FLOW)
|
||
data["sheets"][0]["blocks"][0]["segments"][3]["keep_as"] = None
|
||
data["tables"] = data["tables"][:2]
|
||
notes = notes_for(data)
|
||
assert notes.problems == []
|
||
assert "原表的合计行未导入本表" in notes.tables["时段客流"].comment
|
||
|
||
|
||
def test_hidden_rows_excluded_and_included():
|
||
excluded = list_notes(hidden={"销售": {"rows": [4], "cols": [], "policy_rows": "exclude"}})
|
||
assert "本表不含原表中被隐藏的行" in excluded.tables["销售"].comment
|
||
data = deepcopy(LIST_RECIPE)
|
||
data["sheets"][0]["hidden"] = {"rows": "include"}
|
||
included = list_notes(data=data, hidden={"销售": {"rows": [4], "cols": [], "policy_rows": "include"}})
|
||
assert "本表包含原表中被隐藏的行" in included.tables["销售"].comment
|
||
# 隐藏行在本表占的行之外:不写
|
||
far = list_notes(data=data, hidden={"销售": {"rows": [40], "cols": [], "policy_rows": "include"}})
|
||
assert "隐藏" not in far.tables["销售"].comment
|
||
assert "隐藏" not in list_notes().tables["销售"].comment
|
||
|
||
|
||
def test_ignored_columns_and_rows_after_stop():
|
||
data = deepcopy(LIST_RECIPE)
|
||
data["sheets"][0]["blocks"][0]["extra_columns"] = "ignore"
|
||
notes = list_notes(data=data, ignored_columns={"明细": ["备注"]}, problems=[Problem(
|
||
"rows_after_stop", "confirm", "第 9 行起有 2 行文字在空行之后,没有导入", cells=["销售!A9", "销售!B9"])])
|
||
comment = notes.tables["销售"].comment
|
||
assert "原表另有未导入的列" in comment
|
||
assert "原表在空行之后另有未导入的文字行,内容见证据面板" in comment
|
||
assert "备注" not in comment
|
||
assert "未导入的列" not in list_notes(data=data).tables["销售"].comment
|
||
|
||
|
||
def test_rows_after_stop_goes_to_block_above():
|
||
data = deepcopy(LIST_RECIPE)
|
||
second = deepcopy(data["sheets"][0]["blocks"][0])
|
||
second.update(id="费用", table="费用", after_title="二、费用")
|
||
second["rows"] = {}
|
||
data["sheets"][0]["blocks"].append(second)
|
||
data["tables"].append({"name": "费用"})
|
||
lineage = {"销售": {"金额": [[1, "销售", "D2", 6, "down"]]}, "费用": {"金额": [[1, "销售", "D20", 3, "down"]]}}
|
||
notes = list_notes(data=data, lineage=lineage, problems=[Problem(
|
||
"rows_after_stop", "confirm", "第 25 行起有 1 行文字在空行之后,没有导入", cells=["销售!A25", "销售!B25"])])
|
||
assert "空行之后" in notes.tables["费用"].comment
|
||
assert "空行之后" not in notes.tables["销售"].comment
|
||
|
||
|
||
|
||
def test_rows_after_stop_from_a_skip_list_goes_to_the_block_named_in_the_message():
|
||
"""跳过空行的列表按表下说明收尾、那格文字不像说明时报的 rows_after_stop(WP-8 评审意见 4)也写进那张表的说明:
|
||
消息里写着「列表「费用」」,按它找块;只按 blank_rows=stop 找的话,这张表的说明里没有这一句。"""
|
||
data = deepcopy(LIST_RECIPE)
|
||
second = deepcopy(data["sheets"][0]["blocks"][0])
|
||
second.update(id="费用", table="费用", after_title="二、费用")
|
||
second["rows"] = {"blank_rows": "skip"}
|
||
data["sheets"][0]["blocks"].append(second)
|
||
data["tables"].append({"name": "费用"})
|
||
lineage = {"销售": {"金额": [[1, "销售", "D2", 6, "down"]]}, "费用": {"金额": [[1, "销售", "D20", 3, "down"]]}}
|
||
notes = list_notes(data=data, lineage=lineage, problems=[Problem(
|
||
"rows_after_stop", "confirm", "工作表「销售」的列表「费用」:第 25 行只有首列文字(A25「地区丁」),"
|
||
"已当作表下说明,没有导入。", cells=["销售!A25"])])
|
||
assert "空行之后" in notes.tables["费用"].comment
|
||
assert "空行之后" not in notes.tables["销售"].comment
|
||
|
||
|
||
# ---------------------------------------------------------------- 列说明:空值、日期、单位
|
||
|
||
|
||
def test_null_notes_follow_actual_nulls_when_counts_given():
|
||
"""给了每列空值数:只有本期真有空值的值列写空值说明(全日客流 只写单位)。不给时按配方推断。"""
|
||
with_counts = notes_for()
|
||
assert with_counts.tables["日客流"].columns["全日客流"] == "单位:人次"
|
||
without = notes_for(null_counts=None)
|
||
assert without.tables["日客流"].columns["全日客流"].startswith("单位:人次。空值表示原表为无数据占位符")
|
||
|
||
|
||
def test_four_arg_call_without_null_counts_is_conservative():
|
||
"""规格里 build_notes 是 4 参数调用(P2-SPEC 5.4、7.8 第 3 步)。Extraction 里的占位符计数是整份文件的、
|
||
不分列,4 参数调用时只能对「可能有空值」的值列都写空值那一句,宁多勿漏。
|
||
|
||
这条钉住这层依赖:9.7 第 2 步要的 全日客流 列说明是「单位:人次」,只有调用方把
|
||
recipe_checks.column_null_counts 的结果作为 null_counts 传进来才成立(试运行预览和提交都要传)。
|
||
"""
|
||
recipe = Recipe.model_validate(deepcopy(FLOW))
|
||
four = build_notes(recipe, flow_extraction(), flow_checks(), [])
|
||
assert four.problems == []
|
||
cols = four.tables["日客流"].columns
|
||
assert cols["全日客流"] == "单位:人次。空值表示原表为无数据占位符,不代表零;求和时请同时统计非空个数"
|
||
assert four.tables["时段客流"].columns["客流"].startswith("单位:人次。空值表示原表为无数据占位符")
|
||
# 合计表的空值由表说明交代,哪种调用都不写
|
||
assert four.tables[TOTALS].columns["客流"] == "单位:人次"
|
||
given = build_notes(recipe, flow_extraction(), flow_checks(), [], null_counts=NULLS)
|
||
assert given.tables["日客流"].columns["全日客流"] == "单位:人次"
|
||
# 表说明不受影响
|
||
assert {t: n.comment for t, n in four.tables.items()} == {t: n.comment for t, n in given.tables.items()}
|
||
|
||
|
||
def test_null_note_variants():
|
||
data = deepcopy(FLOW)
|
||
data["sheets"][0]["blocks"][0]["values"]["placeholders"] = [{"text": "—", "meaning": "不适用"}]
|
||
ex = flow_extraction(placeholders={"—": 31})
|
||
assert notes_for(data, extraction=ex).tables["时段客流"].columns["客流"] == (
|
||
"单位:人次。空值表示原表为不适用占位符,不代表零;求和时请同时统计非空个数")
|
||
data["sheets"][0]["blocks"][0]["values"]["blank"] = "null"
|
||
data["sheets"][0]["blocks"][0]["values"]["placeholders"].append({"text": "·"})
|
||
ex = flow_extraction(placeholders={"—": 3, "·": 4})
|
||
assert notes_for(data, extraction=ex).tables["时段客流"].columns["客流"] == (
|
||
"单位:人次。空值表示原表为无数据或不适用占位符或空格,不代表零;求和时请同时统计非空个数")
|
||
# 配了占位符但本期没出现、又不收空格:没有空值来源,不写
|
||
ex = flow_extraction(placeholders={})
|
||
data["sheets"][0]["blocks"][0]["values"]["blank"] = "reject"
|
||
assert notes_for(data, extraction=ex, null_counts=None).tables["时段客流"].columns["客流"] == "单位:人次"
|
||
|
||
|
||
def test_axis_with_own_year_drops_year_hint():
|
||
ex = flow_extraction(axes=[AxisOut("交叉表", 4, "2026-08-01", "2026-08-31", 31, "date")])
|
||
assert notes_for(extraction=ex).tables["日客流"].columns["日期"] == "格式 YYYY-MM-DD"
|
||
data = deepcopy(FLOW)
|
||
data["sheets"][0]["blocks"][0]["axis"]["year_from"] = None
|
||
assert notes_for(data).tables["日客流"].columns["日期"] == "格式 YYYY-MM-DD"
|
||
|
||
|
||
def test_hour_range_without_derive_uses_plain_template():
|
||
data = {
|
||
"recipe_format": "agentlab-recipe/2",
|
||
"sheets": [{"id": "s1", "match": {"name": SHEET}, "blocks": [{
|
||
"id": "交叉表", "layout": "crosstab", "axis": {"year_from": None},
|
||
"segments": [{"id": "日间", "role": "dimension", "table": "时段客流",
|
||
"locate": {"by": "section_title", "title": "日间时段客流(人次)"},
|
||
"labels": {"expect": ["7-8", "8-9"]}, "dim": {"name": "时段", "parser": "hour_range"},
|
||
"value": "客流"}]}]}],
|
||
"tables": [{"name": "时段客流", "grain": ["日期", "时段"]}],
|
||
}
|
||
notes = build_notes(Recipe.model_validate(data), Extraction(ok=True), [], [])
|
||
assert notes.problems == []
|
||
assert "时段 按规范写法存储:起始小时与结束小时用连字符相连,不带单位" in notes.tables["时段客流"].comment
|
||
|
||
|
||
# ---------------------------------------------------------------- apply_notes
|
||
|
||
|
||
def _cache() -> dict[str, Any]:
|
||
def cols(*names):
|
||
return [{"name": n, "type": "INTEGER", "nullable": True} for n in names]
|
||
|
||
return {"tables": {
|
||
"日客流": {"columns": cols("日期", "全日客流", "分区甲", "分区乙"), "primary_key": ["日期"]},
|
||
"时段客流": {"columns": cols("日期", "时段", "时段类别", "起始小时", "结束小时", "客流"),
|
||
"primary_key": ["日期", "时段"]},
|
||
TOTALS: {"columns": cols("日期", "合计项", "起始小时", "结束小时", "客流"), "primary_key": ["日期", "合计项"]},
|
||
}, "synced_at": "2026-09-01T00:00:00"}
|
||
|
||
|
||
def test_apply_notes_writes_comments_without_touching_input():
|
||
cache = _cache()
|
||
frozen = json.dumps(cache, sort_keys=True, ensure_ascii=False)
|
||
notes = notes_for()
|
||
out = apply_notes(cache, notes)
|
||
assert json.dumps(cache, sort_keys=True, ensure_ascii=False) == frozen
|
||
assert out["import_mode"] == "recipe" and out["synced_at"] == cache["synced_at"]
|
||
assert out["tables"]["日客流"]["comment"] == notes.tables["日客流"].comment
|
||
cols = {c["name"]: c for c in out["tables"]["时段客流"]["columns"]}
|
||
assert cols["客流"]["comment"] == notes.tables["时段客流"].columns["客流"]
|
||
assert "comment" not in cols["时段"]
|
||
assert notes.problems == []
|
||
|
||
|
||
def test_apply_notes_records_unknown_tables_and_columns():
|
||
cache = _cache()
|
||
del cache["tables"][TOTALS]
|
||
cache["tables"]["日客流"]["columns"] = cache["tables"]["日客流"]["columns"][:2]
|
||
notes = notes_for()
|
||
out = apply_notes(cache, notes)
|
||
assert any(TOTALS in p for p in notes.problems)
|
||
assert any("分区甲" in p for p in notes.problems)
|
||
assert TOTALS not in out["tables"]
|
||
|
||
|
||
# ---------------------------------------------------------------- 工具描述与冻结白名单
|
||
|
||
|
||
def _source(cache: dict[str, Any]) -> SimpleNamespace:
|
||
return SimpleNamespace(name="passenger_flow", description="", readonly=True, schema_cache=cache, kind="sqlite")
|
||
|
||
|
||
def test_query_description_adds_hint_only_for_recipe_sources():
|
||
from app.tools.datasource import RECIPE_QUERY_HINT, _query_description
|
||
|
||
recipe = _query_description(_source({**_cache(), "import_mode": "recipe"}))
|
||
manual = _query_description(_source(_cache()))
|
||
simple = _query_description(_source({**_cache(), "import_mode": "simple"}))
|
||
hint = "中文表名和列名请加双引号。查单个值时,把主键列一起选出来,证据面板才能追到原表格子。"
|
||
assert hint in recipe and RECIPE_QUERY_HINT.strip() == hint
|
||
assert recipe.index("可用表") < recipe.index(hint)
|
||
assert hint not in manual and hint not in simple
|
||
assert recipe.replace(RECIPE_QUERY_HINT, "") == manual
|
||
assert prose_problems(hint) == []
|
||
|
||
|
||
async def test_store_schema_freezes_import_manifests_and_keeps_manual_sources_identical():
|
||
from app.core.artifact_store import canonical_json, content_hash, load
|
||
from app.tools.datasource import _store_schema
|
||
|
||
ctx = SimpleNamespace(run_id="", node_id="")
|
||
manual = {**_cache(), "schema": "main", "truncated": False, "total": 3, "available_schemas": ["x"]}
|
||
got = await _store_schema(_source(manual), ctx)
|
||
# 改动前的写法:只复制 schema、synced_at、truncated、total 四个键
|
||
before = {"source": "passenger_flow", "tables": manual["tables"],
|
||
**{k: manual[k] for k in ("schema", "synced_at", "truncated", "total") if k in manual}}
|
||
assert got == content_hash(canonical_json(before))
|
||
|
||
recipe = {**_cache(), "import_mode": "recipe", "import_manifests": ["m" * 64]}
|
||
frozen_id = await _store_schema(_source(recipe), ctx)
|
||
frozen = load(frozen_id)
|
||
assert frozen["import_manifests"] == ["m" * 64] and frozen["import_mode"] == "recipe"
|
||
assert frozen_id != got
|