mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:11:58 +00:00
2cdbf9a733
按期累积(新 recipe_accumulate;table_versions 扩展) - 按统计期出的交叉表报表首次导入时默认「按期累积」,由人确认;一期一个构建,两期及以上物化成一份新的 SQLite 快照,不用视图 - 统计期完全相同要勾「替换该期」,部分重叠拒收,中间有空缺要确认;物化前逐期核对库哈希和行号,并集上另加行数守恒、主键唯一、日期落在所属统计期三条核对 - 配方演进分三档:新增列(早期各期为空值)、退役的列和表保留旧值(只在这次才退役时要勾)、不兼容变化只能显式「重新开始累积」 - 快照 id 在累积时含目标配方和模式;同样的输入物化两次库哈希相同,同一文件换名再传直接复用 - 说明按期合并,任何一期没核对过就不写「已核对」;各期维度取值不同、退役列、合计表都有提示,经过数字检查 修复按钮(新 recipe_fixes;执行器给问题带结构化参数) - 加入标签、移除标签、声明为合计行、分段标题改名、编辑恒等式成员、按锚点忽略行/列/区域外文字、声明占位符、声明隐藏行处理、工作表改名 - 服务端算补丁,先预览新旧配方对照、再应用,可以撤销;补丁封闭,不引入正则、自由文本常量和写死坐标;修复进确认清单 - 只改分段标题、新标题仍含原来选的词时沿用已确认的常量,不算破坏性变更;修复后只查改动过的段的认领,没改的关系交给试运行核对 框选转锚点(新 recipe_select) - 网格上框选(鼠标、键盘、区域输入),按八种用途换算成按文字认的锚点,立刻干跑比对;配方里不存坐标,泛化不了时列出原因 - 整张表挪位后同一配方照样认得;框进合计行、夹着小计、表下说明都有明确的处理 确认与对照(recipe_confirm、recipe_diff、新 recipe_compare) - 改配方后确认清单只列有变化的项(同时按本期数据比较),不用每修一次重勾十来项;新旧配方对照界面;按规则重新起草并把名字对齐到现行配方 - 改配方后已体现在配方里的回答保留,矛盾的列出来请人重答 - 「排除的行」写进回执和导入清单:隐藏行、跳过的空行、按配方忽略的行、停止后的文字行、只核对不另存的合计行 版本页(新 source_versions、api/source_versions;前端 VersionsDialog) - 快照与导入记录列表、启用旧版本(可写理由)、查看清单、移除一期、作废接受、清除原件;写操作带界面看到的当前版本,并发改动回 409,不静默丢期 - 除当前版本和被运行引用的版本外,再保留最近 3 个版本供回滚;遮罩列丢失要明确确认 期 1、期 2 遗留 - 重传撞上被改过的构建文件:能用本次上传恢复就隔离旧文件再恢复,否则回 409,不再是 500 - 跳过空行的列表不再把表下隔一行的说明读成数据;不像说明的单格行要人确认;受影响的配方构建换 id - 版本存储加进程级文件锁:第二个进程拿不到锁时不清理、不回收,写操作回 503(部署本来就是单进程)
172 lines
8.5 KiB
Python
172 lines
8.5 KiB
Python
"""新旧配方对照(recipe_compare.compare_recipes,P3-SPEC 6.2、9.4 的 RecipeComparison)。
|
||
|
||
配方手写(假名),以参考配方为基线,按修复按钮、框选、重新起草会改到的地方逐项改。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
from typing import Any
|
||
|
||
from app.data.recipe_compare import compare_recipes, relation_text
|
||
from app.data.recipe_types import Recipe
|
||
from tests.test_recipe_confirm import LIST
|
||
from tests.test_recipe_confirm_p3 import plan
|
||
from tests.test_recipe_diff import FLOW
|
||
|
||
|
||
def flow() -> dict[str, Any]:
|
||
return copy.deepcopy(FLOW)
|
||
|
||
|
||
def seg(r: dict[str, Any], i: int) -> dict[str, Any]:
|
||
return r["sheets"][0]["blocks"][0]["segments"][i]
|
||
|
||
|
||
def table(cmp: dict[str, Any], name: str) -> dict[str, Any]:
|
||
return next(t for t in cmp["tables"] if t["name"] == name)
|
||
|
||
|
||
def column(cmp: dict[str, Any], tname: str, cname: str) -> dict[str, Any]:
|
||
return next(c for c in table(cmp, tname)["columns"] if c["name"] == cname)
|
||
|
||
|
||
def segment(cmp: dict[str, Any], sid: str) -> dict[str, Any]:
|
||
return next(s for s in cmp["segments"] if s["id"] == sid)
|
||
|
||
|
||
def test_identical_recipes():
|
||
cmp = compare_recipes(FLOW, Recipe.model_validate(FLOW).model_dump(mode="json", exclude_defaults=True))
|
||
assert cmp["breaking"] is False and cmp["units_changed"] == [] and cmp["accumulate"] is None
|
||
assert {t["status"] for t in cmp["tables"]} == {"same"}
|
||
assert all(c["status"] == "same" and c["changes"] == [] for t in cmp["tables"] for c in t["columns"])
|
||
assert {s["status"] for s in cmp["segments"]} == {"same"}
|
||
assert {r["status"] for r in cmp["relations"]} == {"same"}
|
||
assert cmp["sheets"] == [{"id": "s1", "name": {"old": "客流汇总", "new": "客流汇总"}}]
|
||
assert cmp["mode"] == {"old": "replace", "new": "replace"}
|
||
json.dumps(cmp, ensure_ascii=False) # 原样进 TrialOut、EditPreview
|
||
|
||
|
||
def test_d13_add_column_and_update_members():
|
||
new = flow()
|
||
s = seg(new, 0)
|
||
s["labels"]["expect"].append("分区丙(人次)")
|
||
s["measures"]["分区丙(人次)"] = "分区丙"
|
||
new["tables"][0]["units"]["分区丙"] = "人次"
|
||
new["relations"][0]["parts"] = ["分区甲", "分区乙", "分区丙"]
|
||
cmp = compare_recipes(FLOW, new)
|
||
t = table(cmp, "日客流")
|
||
assert t["status"] == "changed" and t["breaking"] == []
|
||
assert column(cmp, "日客流", "分区丙") == {"name": "分区丙", "status": "added", "old": None,
|
||
"new": {"type": "INTEGER", "unit": "人次", "source": "分区丙(人次)"},
|
||
"changes": []}
|
||
assert segment(cmp, "日客流")["labels"] == {"added": ["分区丙(人次)"], "removed": []}
|
||
r1 = next(r for r in cmp["relations"] if r["id"] == "R1")
|
||
assert r1 == {"id": "R1", "status": "changed", "old": "全日客流 = 分区甲 + 分区乙",
|
||
"new": "全日客流 = 分区甲 + 分区乙 + 分区丙"}
|
||
assert cmp["breaking"] is False
|
||
|
||
|
||
def test_d04_remove_label_is_not_breaking():
|
||
new = flow()
|
||
seg(new, 1)["labels"]["expect"] = seg(new, 1)["labels"]["expect"][1:]
|
||
cmp = compare_recipes(FLOW, new)
|
||
assert segment(cmp, "日间")["status"] == "changed"
|
||
assert segment(cmp, "日间")["labels"] == {"added": [], "removed": ["7-8"]}
|
||
assert table(cmp, "时段客流")["status"] == "same" and cmp["breaking"] is False
|
||
|
||
|
||
def test_title_change_and_const_value():
|
||
new = flow()
|
||
seg(new, 1)["locate"]["title"] = "日间分时段客流(人次)"
|
||
cmp = compare_recipes(FLOW, new)
|
||
assert segment(cmp, "日间")["title"] == {"old": "日间时段客流(人次)", "new": "日间分时段客流(人次)"}
|
||
assert cmp["breaking"] is False
|
||
seg(new, 1)["const"]["时段类别"]["pick"] = "日间分"
|
||
cmp = compare_recipes(FLOW, new)
|
||
assert column(cmp, "时段客流", "时段类别")["changes"] == ["const_value"]
|
||
assert column(cmp, "时段客流", "时段类别")["status"] == "changed"
|
||
assert cmp["breaking"] is True and table(cmp, "时段客流")["breaking"]
|
||
|
||
|
||
def test_unit_change_first_and_separate_from_breaking():
|
||
new = flow()
|
||
new["tables"][2]["units"]["客流"] = "人"
|
||
new["sheets"][0]["blocks"][0]["values"]["type"] = "REAL"
|
||
cmp = compare_recipes(FLOW, new)
|
||
assert cmp["tables"][0]["name"] == "时段客流_表内合计" # 单位变化的表最前
|
||
assert cmp["units_changed"] == [{"table": "时段客流_表内合计", "column": "客流", "old": "人次", "new": "人"}]
|
||
assert all("单位" not in m for t in cmp["tables"] for m in t["breaking"])
|
||
assert column(cmp, "时段客流_表内合计", "客流")["changes"] == ["type", "unit"]
|
||
assert cmp["breaking"] is True
|
||
# 其余破坏性的在没变化的表之前
|
||
statuses = [(bool(t["breaking"]), t["status"]) for t in cmp["tables"][1:]]
|
||
assert statuses == sorted(statuses, key=lambda x: (not x[0], x[1] == "same"))
|
||
|
||
|
||
def test_removed_and_added_tables_and_grain():
|
||
new = flow()
|
||
seg(new, 3)["keep_as"] = None
|
||
new["tables"].pop()
|
||
new["tables"][0]["grain"] = ["日期", "全日客流"]
|
||
cmp = compare_recipes(FLOW, new)
|
||
assert table(cmp, "时段客流_表内合计")["status"] == "removed"
|
||
assert table(cmp, "时段客流_表内合计")["kind"] == {"old": "reported_total", "new": None}
|
||
assert table(cmp, "日客流")["grain"] == {"old": ["日期"], "new": ["日期", "全日客流"], "changed": True}
|
||
back = compare_recipes(new, FLOW)
|
||
assert table(back, "时段客流_表内合计")["status"] == "added"
|
||
|
||
|
||
def test_added_and_removed_segments_list_all_labels():
|
||
"""起草器换了分段 id(「日间」→「日间段」):新增的分段列出标题和全部标签,去掉的分段列出原有的标题和标签,
|
||
redraft_adopted 据此逐项写出新认出的定位规则(6.3;早先新增分段的 labels.added 是空的)。"""
|
||
new = flow()
|
||
renamed = copy.deepcopy(seg(new, 1))
|
||
renamed["id"] = "日间段"
|
||
new["sheets"][0]["blocks"][0]["segments"][1] = renamed
|
||
cmp = compare_recipes(FLOW, new)
|
||
labels = list(seg(FLOW, 1)["labels"]["expect"])
|
||
added, removed = segment(cmp, "日间段"), segment(cmp, "日间")
|
||
assert added["status"] == "added" and added["labels"] == {"added": labels, "removed": []}
|
||
assert added["title"] == {"old": None, "new": "日间时段客流(人次)"}
|
||
assert removed["status"] == "removed" and removed["labels"] == {"added": [], "removed": labels}
|
||
assert removed["title"] == {"old": "日间时段客流(人次)", "new": None}
|
||
|
||
|
||
def test_sheet_name_mode_and_ignore_rules():
|
||
new = flow()
|
||
new["sheets"][0]["match"]["name"] = "客流汇总(新)"
|
||
new["mode"] = "accumulate"
|
||
new["sheets"][0]["blocks"][0]["ignore_rows"] = [{"label": "补录(人次)", "reason": "表下补录"}]
|
||
new["sheets"][0]["ignore_outside"] = [{"anchor": "补录说明", "reason": "说明里的数字"}]
|
||
cmp = compare_recipes(FLOW, new)
|
||
assert cmp["sheets"] == [{"id": "s1", "name": {"old": "客流汇总", "new": "客流汇总(新)"}}]
|
||
assert cmp["mode"] == {"old": "replace", "new": "accumulate"}
|
||
assert segment(cmp, "交叉表")["ignore"] == {"added": ["按行标签忽略「补录(人次)」"], "removed": []}
|
||
assert segment(cmp, "s1")["ignore"]["added"] == ["按同一行的文字「补录说明」忽略区域外的数字"]
|
||
assert compare_recipes(new, FLOW)["segments"][-1]["ignore"]["removed"]
|
||
|
||
|
||
def test_list_after_title_and_header_source():
|
||
new = copy.deepcopy(LIST)
|
||
block = new["sheets"][0]["blocks"][0]
|
||
block["after_title"] = "一、销售"
|
||
block["columns"][3]["header"] = "销量(件数)"
|
||
cmp = compare_recipes(LIST, new)
|
||
assert segment(cmp, "列表1")["title"] == {"old": None, "new": "一、销售"}
|
||
c = column(cmp, "销售", "销量")
|
||
assert c["changes"] == ["source"] and c["old"]["source"] == "销量(件)" and c["new"]["source"] == "销量(件数)"
|
||
|
||
|
||
def test_plan_is_copied_into_accumulate():
|
||
p = plan(change="compatible", added=[{"table": "日客流", "column": "分区丙"}])
|
||
cmp = compare_recipes(FLOW, FLOW, plan=p)
|
||
assert cmp["accumulate"] == {"change": "compatible", "added": [{"table": "日客流", "column": "分区丙"}],
|
||
"retired_new": [], "retired_existing": []}
|
||
|
||
|
||
def test_relation_text():
|
||
r = Recipe.model_validate(FLOW)
|
||
assert relation_text(r.relations[0]) == "全日客流 = 分区甲 + 分区乙"
|
||
assert relation_text(r.relations[1]) == "表「时段客流」与表「日客流」口径不同,不互相推算或相加"
|