mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:31:59 +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(部署本来就是单进程)
965 lines
54 KiB
Python
965 lines
54 KiB
Python
"""按配方导入期 3 的接口(WP-5,P3-SPEC 9.1、12.6):真管线、经 HTTP。
|
||
|
||
覆盖按期累积的试运行与提交(追加、重叠拒收、替换该期、复用同一构建)、修复按钮(预览、应用、撤销、过期、
|
||
PUT 覆盖)、按规则重新起草与采用、改配方后回答的延续、StagingOut 与 TrialOut 的新字段。版本页的接口在
|
||
test_source_versions_api.py,错误码与遗留项在 test_p3_api_errors.py。
|
||
|
||
夹具一律合成、假名(tests/fixtures/xlsx 的客流表:分区甲、分区乙……),数字是随机数;不调用任何模型。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from sqlalchemy import select
|
||
|
||
from app.core import artifact_store
|
||
from app.core.config import settings
|
||
from app.data import table_versions
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import ImportStaging, SourceSnapshot, TableImport
|
||
from app.main import app
|
||
from tests.fixtures.xlsx.drift import AUG, DRIFT_CASES, OCT, SEP
|
||
from tests.fixtures.xlsx.flow import flow_workbook
|
||
|
||
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
API = "/api/datasources"
|
||
TABLES = ("日客流", "时段客流", "时段客流_表内合计")
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def store(monkeypatch, tmp_path):
|
||
data = tmp_path / "data"
|
||
(data / "uploads").mkdir(parents=True)
|
||
monkeypatch.setattr(settings, "data_dir", data)
|
||
yield data
|
||
|
||
|
||
@pytest.fixture
|
||
async def client():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||
yield c
|
||
|
||
|
||
# ==========================================================================
|
||
# 小工具(经 HTTP)
|
||
# ==========================================================================
|
||
|
||
|
||
def unique(prefix: str = "p3") -> str:
|
||
return f"{prefix}_{uuid.uuid4().hex[:10]}"
|
||
|
||
|
||
def rename_wide(rec: dict[str, Any], old: str, new: str) -> dict[str, Any]:
|
||
"""把规则起草的宽表名改成参考配方的名字(写法同 test_recipe_e2e.rename_wide)。"""
|
||
rec = json.loads(json.dumps(rec, ensure_ascii=False))
|
||
for sheet in rec["sheets"]:
|
||
for block in sheet["blocks"]:
|
||
for seg in block.get("segments", []):
|
||
if seg.get("table") == old:
|
||
seg["table"] = new
|
||
if seg.get("id") == old:
|
||
seg["id"] = new
|
||
for t in rec["tables"]:
|
||
if t["name"] == old:
|
||
t["name"] = new
|
||
for r in rec["relations"]:
|
||
if r.get("table") == old:
|
||
r["table"] = new
|
||
for side in ("a", "b"):
|
||
if isinstance(r.get(side), dict) and r[side].get("table") == old:
|
||
r[side]["table"] = new
|
||
return rec
|
||
|
||
|
||
async def stage(client, name: str, raw: bytes, filename: str):
|
||
return await client.post(f"{API}/imports/stage", files={"file": (filename, raw, XLSX)}, data={"name": name})
|
||
|
||
|
||
async def reupload(client, source_id: str, raw: bytes, filename: str) -> dict[str, Any]:
|
||
resp = await client.post(f"{API}/{source_id}/reupload", files={"file": (filename, raw, XLSX)})
|
||
assert resp.status_code == 201, resp.text
|
||
return resp.json()
|
||
|
||
|
||
async def trial(client, sid: str, **body: Any) -> dict[str, Any]:
|
||
resp = await client.post(f"{API}/imports/{sid}/trial", json=body)
|
||
assert resp.status_code == 200, resp.text
|
||
return resp.json()
|
||
|
||
|
||
def confirms(st: dict[str, Any]) -> list[str]:
|
||
return [i["id"] for i in (st.get("trial") or {}).get("confirm_items") or []]
|
||
|
||
|
||
async def commit(client, st: dict[str, Any], *, confirmations: list[str] | None = None,
|
||
acceptances: list[dict[str, str]] | None = None):
|
||
body: dict[str, Any] = {"trial_id": st["trial"]["trial_id"],
|
||
"confirmations": confirms(st) if confirmations is None else confirmations}
|
||
if acceptances is not None:
|
||
body["acceptances"] = acceptances
|
||
return await client.post(f"{API}/imports/{st['id']}/commit", json=body)
|
||
|
||
|
||
def answers_for(questions: list[dict[str, Any]], mode: str) -> dict[str, dict[str, Any]]:
|
||
out = {}
|
||
for q in questions:
|
||
values = [o["value"] for o in q["options"]]
|
||
if q["id"].startswith("q_relation"):
|
||
out[q["id"]] = {"value": "register"}
|
||
elif q["id"] == "q_mode":
|
||
out[q["id"]] = {"value": mode}
|
||
elif "null" in values:
|
||
out[q["id"]] = {"value": "null"}
|
||
else:
|
||
out[q["id"]] = {"value": values[0]}
|
||
return out
|
||
|
||
|
||
async def first_import(client, name: str, *, seed: int, mode: str = "accumulate", start=AUG, days: int = 31,
|
||
variant: str = "D00") -> dict[str, Any]:
|
||
"""首次导入(回答问题、宽表改名「日客流」、试运行、提交),q_mode 按 mode 答。返回提交响应和试运行。"""
|
||
raw, fn = flow_workbook(start, days, seed=seed, variant=variant)
|
||
resp = await stage(client, name, raw, fn)
|
||
assert resp.status_code == 201, resp.text
|
||
st = resp.json()
|
||
assert st["questions"][-1]["id"] == "q_mode", "q_mode 固定排在问题列表最后"
|
||
resp = await client.post(f"{API}/imports/{st['id']}/answers",
|
||
json={"answers": answers_for(st["questions"], mode)})
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
wide = next(t["name"] for t in st["recipe"]["tables"] if t["name"] not in TABLES)
|
||
resp = await client.put(f"{API}/imports/{st['id']}/recipe", json={"recipe": rename_wide(st["recipe"], wide, "日客流")})
|
||
assert resp.status_code == 200, resp.text
|
||
st = await trial(client, st["id"])
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
out = resp.json()
|
||
return {"commit": out, "trial": st, "source_id": out["source"]["id"], "raw": raw, "file_name": fn}
|
||
|
||
|
||
async def tool_rows(client, source_name: str, sql: str) -> list[list[Any]]:
|
||
resp = await client.post(f"/api/tools/db_query__{source_name}/run", json={"args": {"sql": sql}})
|
||
assert resp.status_code == 200, resp.text
|
||
out = resp.json()
|
||
assert out["ok"], out
|
||
return json.loads(out["result"])["rows"]
|
||
|
||
|
||
async def count(client, name: str, table: str, where: str = "") -> int:
|
||
return int((await tool_rows(client, name, f'SELECT COUNT(*) FROM "{table}"{where}'))[0][0])
|
||
|
||
|
||
async def db_get(model, key):
|
||
async with SessionLocal() as session:
|
||
return await session.get(model, key)
|
||
|
||
|
||
async def source_row(client, source_id: str) -> dict[str, Any]:
|
||
resp = await client.get(API)
|
||
return next(s for s in resp.json() if s["id"] == source_id)
|
||
|
||
|
||
# ==========================================================================
|
||
# 按期累积:追加
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_accumulate_august_then_september_materializes_and_publishes_the_union(client):
|
||
"""8 月首次导入(按期累积)+ 9 月上传新一期:试运行出累积计划、物化并集(U1–U3 通过),提交走 publish_snapshot,
|
||
当前版本两期、库在 snapshots/ 下,三表 61 / 1037 / 183,按月 31 / 30(2.12)。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=301)
|
||
source_id = base["source_id"]
|
||
first_snapshot = base["commit"]["snapshot_id"]
|
||
snap = await db_get(SourceSnapshot, first_snapshot)
|
||
assert snap.mode == "accumulate" and Path(snap.db_path).parent.name == "builds", "单期快照库就是那一期的构建库"
|
||
card = await source_row(client, source_id)
|
||
assert card["current_snapshot"]["mode"] == "accumulate" and card["current_snapshot"]["periods"] == 1
|
||
|
||
raw, fn = flow_workbook(SEP, 30, seed=302)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
t = st["trial"]
|
||
assert t["status"] == "passed", t["problems"]
|
||
plan = t["accumulate"]
|
||
assert plan["action"] == "append" and plan["mode"] == "accumulate"
|
||
assert [(p["start"], p["new"]) for p in plan["parts"]] == [("2026-08-01", False), ("2026-09-01", True)]
|
||
assert plan["parts"][1]["rows"] == {"日客流": 30, "时段客流": 510, "时段客流_表内合计": 90}
|
||
assert plan["parts"][1]["file_name"] == fn
|
||
assert plan["union"]["rows"] == {"日客流": 61, "时段客流": 1037, "时段客流_表内合计": 183}
|
||
assert [(c["id"], c["status"]) for c in t["union_checks"]] == [("U1", "passed"), ("U2", "passed"),
|
||
("U3", "passed")]
|
||
assert t["recipe_compare"] is None, "配方没变:不给对照"
|
||
assert t["prior_acceptances"] == []
|
||
assert "union" not in t and "prev_import_id" not in t, "并集的内部信息不给界面"
|
||
assert t["receipt"]["rows_excluded"] == [], "TrialOut.receipt 带排除的行(期 3 的试运行一律带这个键)"
|
||
union_file = table_versions.union_trial_path(Path((await db_get(ImportStaging, st["id"])).trial_path))
|
||
assert union_file.is_file(), "并集在试运行里物化,和主试运行库放在同一目录"
|
||
assert "本表按统计期累积多期数据" in t["notes"]["日客流"]["comment"], t["notes"]["日客流"]
|
||
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
out = resp.json()
|
||
assert out["parts"] == 2 and out["snapshot_reused"] is False and out["build_restored"] is False
|
||
assert not union_file.exists(), "并集的试运行文件被发布消耗"
|
||
snap = await db_get(SourceSnapshot, out["snapshot_id"])
|
||
imps = [await db_get(TableImport, i) for i in snap.imports]
|
||
assert [i.period_start for i in imps] == ["2026-08-01", "2026-09-01"]
|
||
assert [i.status for i in imps] == ["active", "active"], "当前快照里的各期都是 active(2.1)"
|
||
assert snap.mode == "accumulate" and Path(snap.db_path).parent.name == "snapshots"
|
||
assert snap.manifest_artifact and snap.schema_cache["snapshot_manifest"] == snap.manifest_artifact
|
||
for table, n in zip(TABLES, (61, 1037, 183)):
|
||
assert await count(client, name, table) == n
|
||
months = await tool_rows(client, name, 'SELECT substr("日期", 1, 7), COUNT(*) FROM "日客流" GROUP BY 1 ORDER BY 1')
|
||
assert months == [["2026-08", 31], ["2026-09", 30]]
|
||
# 快照清单与证据链(2.9):union.db_sha256 等于快照库的哈希,各期 rowid 区间相接
|
||
manifest = artifact_store.load(snap.manifest_artifact)
|
||
assert manifest["format"] == "agentlab-snapshot-manifest/1" and manifest["action"] == "append"
|
||
assert manifest["union"]["db_sha256"] == snap.db_sha256
|
||
rows = [p["rows"]["日客流"] for p in manifest["parts"]]
|
||
assert rows[0]["union"] == [1, 31] and rows[1]["union"] == [32, 61] and rows[1]["part"] == [1, 30]
|
||
assert snap.schema_cache["import_manifests"] == [i.manifest_artifact for i in imps]
|
||
# 新一期的导入清单:receipt 带排除的行,edits 是空列表
|
||
new_manifest = artifact_store.load(imps[1].manifest_artifact)
|
||
assert new_manifest["receipt"]["rows_excluded"] == [] and new_manifest["edits"] == []
|
||
# 卡片:按期累积 · 2 期,统计期范围
|
||
card = await source_row(client, source_id)
|
||
cur = card["current_snapshot"]
|
||
assert cur["mode"] == "accumulate" and cur["periods"] == 2
|
||
assert (cur["period_start"], cur["period_end"]) == ("2026-08-01", "2026-09-30") and cur["activated_at"]
|
||
|
||
|
||
async def test_overlapping_period_is_rejected_and_the_current_version_stays(client):
|
||
"""统计期与已有的部分重叠:试运行拒收(period_overlap,消息写两段统计期),提交 409 trial_not_passed(A2)。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=303)
|
||
source_id = base["source_id"]
|
||
raw, fn = flow_workbook(SEP, 30, seed=304)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
assert (await commit(client, st)).status_code == 201
|
||
current = (await source_row(client, source_id))["current_snapshot"]["id"]
|
||
raw, fn = flow_workbook(AUG.replace(day=15), 31, seed=305)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
t = st["trial"]
|
||
assert t["status"] == "rejected"
|
||
overlap = next(p for p in t["problems"] if p["code"] == "period_overlap")
|
||
assert overlap["category"] == "structure"
|
||
assert "2026-08-01 至 2026-08-31" in overlap["message"] and "2026-09-01 至 2026-09-30" in overlap["message"]
|
||
assert t["accumulate"]["action"] == "rejected" and t["union_checks"] is None
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 409 and resp.json()["code"] == "trial_not_passed"
|
||
assert (await source_row(client, source_id))["current_snapshot"]["id"] == current
|
||
|
||
|
||
async def test_replace_period_needs_its_confirmation_and_keeps_the_row_counts(client):
|
||
"""同一统计期再传(数字不同):replace_period,必勾 period_replace:<起>~<止>;勾上后新快照是 [8 月, 9 月新](A3)。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=306)
|
||
source_id = base["source_id"]
|
||
raw, fn = flow_workbook(SEP, 30, seed=307)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
assert (await commit(client, st)).status_code == 201
|
||
old_sep = (await tool_rows(client, name, 'SELECT "全日客流" FROM "日客流" WHERE "日期" = \'2026-09-05\''))[0][0]
|
||
raw2, fn2 = flow_workbook(SEP, 30, seed=308)
|
||
st = await reupload(client, source_id, raw2, fn2)
|
||
t = st["trial"]
|
||
assert t["status"] == "passed", t["problems"]
|
||
assert t["accumulate"]["action"] == "replace_period"
|
||
assert t["accumulate"]["replaces"]["start"] == "2026-09-01"
|
||
item = "period_replace:2026-09-01~2026-09-30"
|
||
got = {i["id"]: i for i in t["confirm_items"]}
|
||
assert item in got and got[item]["source"] == "accumulate" and got[item]["required"]
|
||
resp = await commit(client, st, confirmations=[i for i in confirms(st) if i != item])
|
||
assert resp.status_code == 422 and resp.json()["code"] == "confirm_required" and item in resp.json()["detail"]
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
snap = await db_get(SourceSnapshot, resp.json()["snapshot_id"])
|
||
assert len(snap.imports) == 2
|
||
for table, n in zip(TABLES, (61, 1037, 183)):
|
||
assert await count(client, name, table) == n
|
||
new_sep = (await tool_rows(client, name, 'SELECT "全日客流" FROM "日客流" WHERE "日期" = \'2026-09-05\''))[0][0]
|
||
assert new_sep != old_sep
|
||
|
||
|
||
async def test_same_file_again_in_accumulate_mode_is_unchanged(client):
|
||
"""累积模式下同一个 9 月文件原样再传:替换该期且构建相同 → 不新建任何记录(same_as_import 的累积扩展)。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=309)
|
||
source_id = base["source_id"]
|
||
raw, fn = flow_workbook(SEP, 30, seed=310)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
first = await commit(client, st)
|
||
assert first.status_code == 201
|
||
st = await reupload(client, source_id, raw, fn)
|
||
assert st["trial"]["accumulate"]["action"] == "replace_period"
|
||
assert st["trial"]["same_as_import"]["id"] == first.json()["import_id"]
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
out = resp.json()
|
||
assert out["unchanged"] is True and out["snapshot_id"] == first.json()["snapshot_id"] and out["parts"] == 2
|
||
async with SessionLocal() as session:
|
||
n = len(list((await session.execute(select(TableImport).where(TableImport.source_id == source_id))).scalars()))
|
||
assert n == 2
|
||
|
||
|
||
# ==========================================================================
|
||
# 修复按钮:预览、应用、撤销、过期、PUT 覆盖
|
||
# ==========================================================================
|
||
|
||
|
||
def problem_with(st: dict[str, Any], code: str) -> dict[str, Any]:
|
||
trial_problems = (st.get("trial") or {}).get("problems") or []
|
||
return next(p for p in [*trial_problems, *st["recipe_problems"], *st["draft_problems"]] if p["code"] == code)
|
||
|
||
|
||
def fix_of(st: dict[str, Any], kind: str) -> dict[str, Any]:
|
||
return next(f for f in st["fixes"] if f["kind"] == kind)
|
||
|
||
|
||
async def preview(client, sid: str, body: dict[str, Any]):
|
||
return await client.post(f"{API}/imports/{sid}/edits/preview", json=body)
|
||
|
||
|
||
async def apply(client, sid: str, body: dict[str, Any], expected: str | None, **extra: Any):
|
||
return await client.post(f"{API}/imports/{sid}/edits/apply", json={**body, "expected_sha256": expected, **extra})
|
||
|
||
|
||
async def fixed(client, st: dict[str, Any], kind: str, option: str, reason: str | None = None) -> dict[str, Any]:
|
||
"""按提议预览再应用,返回应用后的 StagingOut。"""
|
||
fx = fix_of(st, kind)
|
||
body = {"fix": {"id": fx["id"], "option": option, **({"reason": reason} if reason else {})}}
|
||
resp = await preview(client, st["id"], body)
|
||
assert resp.status_code == 200, resp.text
|
||
pv = resp.json()
|
||
assert pv["ok"], pv["problems"]
|
||
resp = await apply(client, st["id"], body, pv["recipe_sha256_after"])
|
||
assert resp.status_code == 200, resp.text
|
||
return resp.json()
|
||
|
||
|
||
async def test_d04_remove_label_fix_preview_apply_trial_and_commit(client):
|
||
"""D04(日间少了 7-8):拒收的问题带 fix_ids,提议去掉标签;预览不改暂存区,应用后试运行通过,确认清单的配方类只有
|
||
fix:*(A6、A15),并集 61 / 1007 / 183(3.4),修改记录进导入清单。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=311)
|
||
source_id = base["source_id"]
|
||
raw, fn = DRIFT_CASES["D04"].build(311)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
assert st["trial"]["status"] == "rejected"
|
||
missing = problem_with(st, "label_missing")
|
||
fx = fix_of(st, "remove_label")
|
||
assert missing["fix_ids"] == [fx["id"]] and fx["anchor"]["kind"] == "problem"
|
||
assert all("fix_ids" in p for p in st["trial"]["problems"]), "每条问题都带 fix_ids(没有提议时是 [])"
|
||
assert st["recipe_sha256"] and st["edits"] == [] and st["answers_dropped"] == []
|
||
before_sha = st["recipe_sha256"]
|
||
|
||
body = {"fix": {"id": fx["id"], "option": "remove"}, "seq": 3}
|
||
resp = await preview(client, st["id"], body)
|
||
assert resp.status_code == 200, resp.text
|
||
pv = resp.json()
|
||
assert pv["ok"] is True and pv["seq"] == 3 and pv["kind"] == "fix"
|
||
assert pv["key"] == "remove_label:日间:7-8" and pv["recipe_sha256_before"] == before_sha
|
||
assert pv["recipe_sha256_after"] and pv["recipe_sha256_after"] != before_sha
|
||
assert pv["breaking"] == {} and isinstance(pv["breaking"], dict), "dimension 段去标签不改表结构"
|
||
assert pv["recipe_problems"] == [] and pv["dry_run"]["problems"] == [] and pv["dry_run"]["marks"]
|
||
assert pv["accumulate_change"] == "compatible"
|
||
seg = next(s for s in pv["compare"]["segments"] if s["id"] == "日间")
|
||
assert seg["labels"]["removed"] == ["7-8"]
|
||
# 预览不改暂存区
|
||
again = (await client.get(f"{API}/imports/{st['id']}")).json()
|
||
assert again["recipe_sha256"] == before_sha and again["edits"] == []
|
||
|
||
resp = await apply(client, st["id"], {"fix": body["fix"]}, pv["recipe_sha256_after"], signed_by="合成署名甲")
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
assert st["status"] == "drafting" and st["recipe_sha256"] == pv["recipe_sha256_after"]
|
||
[edit] = st["edits"]
|
||
assert edit["key"] == "remove_label:日间:7-8" and edit["undoable"] is True and edit["superseded"] is False
|
||
assert edit["signed_by"] == "合成署名甲" and "before" not in edit and "base_sha256_after" not in edit
|
||
st = await trial(client, st["id"])
|
||
t = st["trial"]
|
||
assert t["status"] == "passed", t["problems"]
|
||
assert t["accumulate"]["action"] == "append"
|
||
assert t["accumulate"]["union"]["rows"] == {"日客流": 61, "时段客流": 1007, "时段客流_表内合计": 183}
|
||
assert t["recipe_compare"] is not None, "工作配方与现行配方不同:给新旧对照"
|
||
items = {i["id"]: i for i in t["confirm_items"]}
|
||
assert items["fix:remove_label:日间:7-8"]["source"] == "edit"
|
||
recipe_class = [i for i, v in items.items() if v.get("source", "recipe") == "recipe"]
|
||
assert recipe_class == [], recipe_class
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
imp = await db_get(TableImport, resp.json()["import_id"])
|
||
manifest = artifact_store.load(imp.manifest_artifact)
|
||
assert [e["key"] for e in manifest["edits"]] == ["remove_label:日间:7-8"]
|
||
assert "before" not in manifest["edits"][0]
|
||
# 3.1:修改记录(谁、何时、补丁)进导入清单——补丁和所选的选项都在,不只剩人话摘要
|
||
assert manifest["edits"][0]["fix"] == {"id": fx["id"], "option": "remove", "reason": None}
|
||
assert {"op": "replace", "path": manifest["edits"][0]["ops"][0]["path"],
|
||
"value": manifest["edits"][0]["ops"][0]["value"]} == manifest["edits"][0]["ops"][0]
|
||
assert manifest["edits"][0]["ops"][0]["path"].endswith("/labels/expect")
|
||
assert "7-8" not in manifest["edits"][0]["ops"][0]["value"]
|
||
assert await count(client, name, "时段客流") == 1007
|
||
|
||
|
||
async def test_undo_restores_the_start_and_put_supersedes_earlier_edits(client):
|
||
"""撤销恢复修改前的起点;撤销栈空了回 nothing_to_undo。PUT 之后之前的修改标「已被覆盖」,不能撤销、不出 fix:*(3.1)。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=312)
|
||
raw, fn = DRIFT_CASES["D04"].build(312)
|
||
st = await reupload(client, base["source_id"], raw, fn)
|
||
before_sha = st["recipe_sha256"]
|
||
st = await fixed(client, st, "remove_label", "remove")
|
||
resp = await client.post(f"{API}/imports/{st['id']}/edits/undo", json={})
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
assert st["recipe_sha256"] == before_sha and st["edits"] == []
|
||
resp = await client.post(f"{API}/imports/{st['id']}/edits/undo", json={})
|
||
assert resp.status_code == 409 and resp.json()["code"] == "nothing_to_undo"
|
||
|
||
st = await trial(client, st["id"])
|
||
st = await fixed(client, st, "remove_label", "remove")
|
||
resp = await client.put(f"{API}/imports/{st['id']}/recipe", json={"recipe": st["recipe"]})
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
[edit] = st["edits"]
|
||
assert edit["superseded"] is True and edit["undoable"] is False
|
||
resp = await client.post(f"{API}/imports/{st['id']}/edits/undo", json={})
|
||
assert resp.status_code == 409 and resp.json()["code"] == "nothing_to_undo"
|
||
st = await trial(client, st["id"])
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
assert not [i for i in confirms(st) if i.startswith("fix:")], "被覆盖的修改不再出确认项"
|
||
|
||
|
||
async def test_needs_reason_fix_refuses_a_changed_reason_and_records_rows_excluded(client):
|
||
"""D21(表下补录一行):按行标签忽略要理由。没理由预览 422 reason_required;预览之后改了理由,应用 409 edit_stale
|
||
(理由进配方哈希);照原样应用后试运行通过,回执的 rows_excluded 有 ignored_rows(A6b)。提议不在了回 fix_stale。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=313)
|
||
raw, fn = DRIFT_CASES["D21"].build(313)
|
||
st = await reupload(client, base["source_id"], raw, fn)
|
||
assert st["trial"]["status"] == "rejected"
|
||
fx = fix_of(st, "ignore_cells")
|
||
assert fx["options"][0]["needs_reason"] is True
|
||
option = fx["options"][0]["value"]
|
||
resp = await preview(client, st["id"], {"fix": {"id": fx["id"], "option": option}})
|
||
assert resp.status_code == 422 and resp.json()["code"] == "reason_required"
|
||
body = {"fix": {"id": fx["id"], "option": option, "reason": "合成理由:补录行另有出处"}}
|
||
pv = (await preview(client, st["id"], body)).json()
|
||
assert pv["ok"], pv["problems"]
|
||
changed = {"fix": {**body["fix"], "reason": "合成理由:改过的理由"}}
|
||
resp = await apply(client, st["id"], changed, pv["recipe_sha256_after"])
|
||
assert resp.status_code == 409 and resp.json()["code"] == "edit_stale"
|
||
resp = await apply(client, st["id"], body, pv["recipe_sha256_after"])
|
||
assert resp.status_code == 200, resp.text
|
||
resp = await preview(client, st["id"], body)
|
||
assert resp.status_code == 409 and resp.json()["code"] == "fix_stale", "应用之后问题没了,提议也不在了"
|
||
st = await trial(client, st["id"])
|
||
t = st["trial"]
|
||
assert t["status"] == "passed", t["problems"]
|
||
reasons = {r["reason"] for r in t["receipt"]["rows_excluded"]}
|
||
assert "ignored_rows" in reasons, t["receipt"]["rows_excluded"]
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
imp = await db_get(TableImport, resp.json()["import_id"])
|
||
manifest = artifact_store.load(imp.manifest_artifact)
|
||
assert {r["reason"] for r in manifest["receipt"]["rows_excluded"]} == reasons
|
||
|
||
|
||
async def test_d13_add_label_then_edit_members_two_steps(client):
|
||
"""D13(多一个分区):加标签之后静态校验报 fact_claim_mismatch,配方问题带 fix_ids,提议「更新关系成员」;两步
|
||
修复后试运行通过,确认项有两条 fix:*,提交后 8 月的分区丙为空值(3.4)。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=314)
|
||
raw, fn = DRIFT_CASES["D13"].build(314)
|
||
st = await reupload(client, base["source_id"], raw, fn)
|
||
assert st["trial"]["status"] == "rejected"
|
||
st = await fixed(client, st, "add_label", "add")
|
||
mismatch = problem_with(st, "fact_claim_mismatch")
|
||
fx = fix_of(st, "edit_members")
|
||
assert mismatch["fix_ids"] == [fx["id"]] and fx["anchor"]["kind"] == "recipe_problem"
|
||
st = await fixed(client, st, "edit_members", "update")
|
||
assert st["recipe_problems"] == [], st["recipe_problems"]
|
||
assert [e["kind"] for e in st["edits"]] == ["fix", "fix"] and [e["undoable"] for e in st["edits"]] == [False, True]
|
||
st = await trial(client, st["id"])
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
ids = confirms(st)
|
||
assert len([i for i in ids if i.startswith("fix:")]) == 2, ids
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
assert await count(client, name, "日客流", ' WHERE "分区丙" IS NULL') == 31
|
||
|
||
|
||
async def test_d09_rename_title_keeps_the_confirmed_word(client):
|
||
"""D09(日间标题改字):改分段标题、沿用「日间」(3.3 (b))。传 base 之后静态校验放行,确认项没有 breaking:*。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=315)
|
||
raw, fn = DRIFT_CASES["D09"].build(315)
|
||
st = await reupload(client, base["source_id"], raw, fn)
|
||
fx = fix_of(st, "rename_title")
|
||
keep = next(o["value"] for o in fx["options"] if o["value"].endswith("|keep"))
|
||
st = await fixed(client, st, "rename_title", keep)
|
||
assert st["recipe_problems"] == [], st["recipe_problems"]
|
||
st = await trial(client, st["id"])
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
assert not [i for i in confirms(st) if i.startswith("breaking:")], confirms(st)
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
kinds = await tool_rows(client, name, 'SELECT DISTINCT "时段类别" FROM "时段客流" ORDER BY 1')
|
||
assert sorted(k[0] for k in kinds) == ["夜间", "日间"]
|
||
|
||
|
||
# ==========================================================================
|
||
# 按规则重新起草、改配方后回答的延续
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_redraft_rules_then_adopt_lists_every_recipe_item_and_redraft_adopted(client):
|
||
"""redraft-rules 不改工作配方,记下对齐后的哈希;PUT(origin=rules_redraft)核对它,对不上 409 redraft_mismatch;
|
||
采用之后确认清单按首次导入列出全部配方类项,另出 redraft_adopted(6.3,评审一-M6)。首次导入不提供。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=316, mode="replace")
|
||
# 与现行配方逐字相同的重新起草(同一版式的 9 月):采用之后没有可确认的变化,只出 redraft_adopted 说明「相同」
|
||
raw, fn = flow_workbook(SEP, 30, seed=317)
|
||
same = await reupload(client, base["source_id"], raw, fn)
|
||
out = (await client.post(f"{API}/imports/{same['id']}/redraft-rules", json={})).json()
|
||
assert out["aligned_recipe"] is not None
|
||
resp = await client.put(f"{API}/imports/{same['id']}/recipe",
|
||
json={"recipe": out["aligned_recipe"], "origin": "rules_redraft"})
|
||
assert resp.json()["recipe_sha256"] == same["recipe_sha256"], "按来源对齐之后与现行配方逐字相同"
|
||
resp = await client.delete(f"{API}/imports/{same['id']}")
|
||
assert resp.status_code == 204
|
||
# D04(日间少了 7-8):重新起草出的分段标签与现行不同
|
||
raw, fn = DRIFT_CASES["D04"].build(317)
|
||
st = await reupload(client, base["source_id"], raw, fn)
|
||
before = st["recipe_sha256"]
|
||
resp = await client.post(f"{API}/imports/{st['id']}/redraft-rules", json={})
|
||
assert resp.status_code == 200, resp.text
|
||
out = resp.json()
|
||
assert out["aligned_recipe"] is not None and isinstance(out["alignment"], list)
|
||
assert out["compare"] is not None and "failures_for_model" not in out["draft"]
|
||
assert (await client.get(f"{API}/imports/{st['id']}")).json()["recipe_sha256"] == before, "不改工作配方"
|
||
tampered = json.loads(json.dumps(out["aligned_recipe"], ensure_ascii=False))
|
||
tampered["tables"][0]["note"] = "合成说明"
|
||
resp = await client.put(f"{API}/imports/{st['id']}/recipe", json={"recipe": tampered, "origin": "rules_redraft"})
|
||
assert resp.status_code == 409 and resp.json()["code"] == "redraft_mismatch"
|
||
resp = await client.put(f"{API}/imports/{st['id']}/recipe",
|
||
json={"recipe": out["aligned_recipe"], "origin": "rules_redraft"})
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
assert st["recipe_origin"] == "rules_redraft"
|
||
st = await trial(client, st["id"])
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
items = {i["id"]: i for i in st["trial"]["confirm_items"]}
|
||
assert items["redraft_adopted"]["source"] == "edit" and "7-8" in items["redraft_adopted"]["label"]
|
||
assert "mode" in items, "采用重新起草的结果时配方类项全部列出(只改了分段标签也一样)"
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
|
||
raw, fn = flow_workbook(AUG, 31, seed=318)
|
||
first = (await stage(client, unique(), raw, fn)).json()
|
||
resp = await client.post(f"{API}/imports/{first['id']}/redraft-rules", json={})
|
||
assert resp.status_code == 409 and resp.json()["code"] == "redraft_not_offered"
|
||
|
||
|
||
async def test_put_keeps_answers_the_new_start_embodies_and_drops_the_rest(client):
|
||
"""L2:先回答占位符「·」存为空值,再 PUT 一份体现了这个回答的配方:回答仍在、answers_dropped 为空;PUT 一份去掉
|
||
占位符的配方:answers_dropped 列出它(带问题文字,不只给 id)。"""
|
||
raw, fn = flow_workbook(AUG, 31, seed=319)
|
||
st = (await stage(client, unique(), raw, fn)).json()
|
||
qid = "q_placeholder:·"
|
||
assert qid in {q["id"] for q in st["questions"]}
|
||
resp = await client.post(f"{API}/imports/{st['id']}/answers", json={"answers": {qid: {"value": "null"}}})
|
||
st = resp.json()
|
||
resp = await client.put(f"{API}/imports/{st['id']}/recipe", json={"recipe": st["recipe"]})
|
||
st = resp.json()
|
||
assert st["answers"] == {qid: {"value": "null", "reason": None}} and st["answers_dropped"] == []
|
||
rec = json.loads(json.dumps(st["recipe"], ensure_ascii=False))
|
||
for sheet in rec["sheets"]:
|
||
for block in sheet["blocks"]:
|
||
if isinstance(block.get("values"), dict):
|
||
block["values"]["placeholders"] = []
|
||
resp = await client.put(f"{API}/imports/{st['id']}/recipe", json={"recipe": rec})
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
dropped = {d["id"]: d for d in st["answers_dropped"]}
|
||
assert qid in dropped and dropped[qid]["text"] and dropped[qid]["reason"] in ("changed", "gone")
|
||
assert qid not in st["answers"]
|
||
|
||
|
||
# ==========================================================================
|
||
# 框选转锚点(4.4、4.6)
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_c01_selection_preview_matches_the_rule_draft_and_applies(client):
|
||
"""lab.c01 暂存后在「月报!C5:F8」框选为列表:预览 ok,锚点是表头,重放与框一致,换算后的配方与规则草稿逐字相同
|
||
(哈希不变);框到第 7 行时说不清下边界,照样 200,ok=false、problems 写原因;应用后修改记录是 selection。"""
|
||
from tests.fixtures.xlsx import lab
|
||
|
||
raw, fn = lab.c01("literal")
|
||
st = (await stage(client, unique(), raw, fn)).json()
|
||
sel = {"sheet": "月报", "ref": "C5:F8", "as": "list", "options": {"header_rows": 1, "bottom": "box"}}
|
||
resp = await preview(client, st["id"], {"selection": sel, "seq": 1})
|
||
assert resp.status_code == 200, resp.text
|
||
pv = resp.json()
|
||
assert pv["ok"] is True and pv["kind"] == "selection" and pv["seq"] == 1
|
||
assert [a["text"] for a in pv["anchors"] if a["kind"] == "header"] == ["地区", "产品", "销量", "金额"]
|
||
assert pv["replay"]["match"] is True, pv["replay"]
|
||
assert pv["replay"]["expected"] == {"header": "C5:F5", "data": "C6:F8", "total": "C9:F9"}
|
||
assert pv["recipe_sha256_after"] == st["recipe_sha256"], "框选得到的规则与起草器完全一样"
|
||
assert pv["breaking"] == {} and pv["expected"] == pv["replay"]["expected"]
|
||
bad = (await preview(client, st["id"], {"selection": {**sel, "ref": "C5:F7"}})).json()
|
||
assert bad["ok"] is False and [p["code"] for p in bad["problems"]] == ["selection_bottom_not_anchorable"]
|
||
assert bad["breaking"] == {} and bad["recipe_problems"] == [] and bad["dry_run"] is None
|
||
resp = await apply(client, st["id"], {"selection": {**sel, "ref": "C5:F7"}}, None)
|
||
assert resp.status_code == 422 and resp.json()["code"] == "edit_not_applicable"
|
||
resp = await apply(client, st["id"], {"selection": sel}, pv["recipe_sha256_after"])
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
[edit] = st["edits"]
|
||
assert edit["kind"] == "selection" and edit["key"].startswith("list:") and edit["undoable"] is True
|
||
# 修改记录记下选区(键是 as,Selection.to_json);补丁只进导入清单,StagingOut 不给
|
||
assert edit["selection"] == sel and "ops" not in edit
|
||
row = await db_get(ImportStaging, st["id"])
|
||
assert row.edits[0]["ops"] and row.edits[0]["selection"] == sel
|
||
|
||
|
||
# ==========================================================================
|
||
# 评审后补:撤销不丢回答、预览的 breaking、采用重新起草之后的来源、重新起草没得到配方、带接受的重传、退役链
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_undo_keeps_the_answers_given_before_the_edit(client):
|
||
"""先回答(导入模式选每期替换)、再用修复按钮、再撤销:撤销只撤这一次修改,修改前的回答照旧、不报「需要重新
|
||
回答」,工作配方回到修改前(3.1)。原来撤销恢复的是还没叠加回答的起点,有效果的回答一律判 changed 丢掉,导入
|
||
模式悄悄退回草稿默认的按期累积(评审意见)。"""
|
||
raw, fn = DRIFT_CASES["D21"].build(331)
|
||
resp = await stage(client, unique(), raw, fn)
|
||
assert resp.status_code == 201, resp.text
|
||
st = resp.json()
|
||
resp = await client.post(f"{API}/imports/{st['id']}/answers",
|
||
json={"answers": answers_for(st["questions"], "replace")})
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
assert st["recipe"].get("mode", "replace") == "replace"
|
||
before_sha, before_answers = st["recipe_sha256"], st["answers"]
|
||
assert before_answers["q_mode"] == {"value": "replace", "reason": None}
|
||
fx = fix_of(st, "ignore_cells")
|
||
opt = fx["options"][0]
|
||
body = {"fix": {"id": fx["id"], "option": opt["value"],
|
||
**({"reason": "合成理由:表下另有补录行"} if opt["needs_reason"] else {})}}
|
||
pv = (await preview(client, st["id"], body)).json()
|
||
assert pv["ok"], pv["problems"]
|
||
resp = await apply(client, st["id"], body, pv["recipe_sha256_after"])
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
assert st["answers"] == before_answers and st["answers_dropped"] == []
|
||
resp = await client.post(f"{API}/imports/{st['id']}/edits/undo", json={})
|
||
assert resp.status_code == 200, resp.text
|
||
st = resp.json()
|
||
assert st["edits"] == [] and st["recipe_sha256"] == before_sha
|
||
assert st["answers"] == before_answers and st["answers_dropped"] == []
|
||
assert st["recipe"].get("mode", "replace") == "replace"
|
||
|
||
|
||
async def test_preview_breaking_lists_the_table_changes_of_a_breaking_option(client):
|
||
"""预览的 breaking 是 table_changes 算出的「表名 → 人话列表」(WP-0 交接):D09 改分段标题时另选新词(pick,常量
|
||
取值变了),「时段客流」列出取值的变化;沿用原词(keep)为空字典。只断言「是字典」抓不住漏算(评审意见)。"""
|
||
base = await first_import(client, unique(), seed=336)
|
||
raw, fn = DRIFT_CASES["D09"].build(336)
|
||
st = await reupload(client, base["source_id"], raw, fn)
|
||
fx = fix_of(st, "rename_title")
|
||
pick = next(o for o in fx["options"] if "|pick:" in o["value"])
|
||
keep = next(o for o in fx["options"] if o["value"].endswith("|keep"))
|
||
assert pick["breaking"] is True and keep["breaking"] is False
|
||
resp = await preview(client, st["id"], {"fix": {"id": fx["id"], "option": pick["value"]}})
|
||
assert resp.status_code == 200, resp.text
|
||
pv = resp.json()
|
||
assert pv["ok"], pv["problems"]
|
||
word = pick["value"].split("|pick:", 1)[1]
|
||
assert list(pv["breaking"]) == ["时段客流"], pv["breaking"]
|
||
assert any("日间" in line and word in line for line in pv["breaking"]["时段客流"]), pv["breaking"]
|
||
pv = (await preview(client, st["id"], {"fix": {"id": fx["id"], "option": keep["value"]}})).json()
|
||
assert pv["ok"] and pv["breaking"] == {}
|
||
|
||
|
||
async def test_an_adopted_redraft_is_stored_as_rules_and_next_month_does_not_ask_again(client):
|
||
"""采用按规则重新起草的配方并提交:配方记录、导入清单记 rules(只取 rules / ai / manual / mixed,界面按
|
||
RECIPE_ORIGIN_LABEL 显示)。下个月照常上传同一版式:暂存区的来源是 rules,不再出 redraft_adopted,也不再列出全部
|
||
配方类项(6.2、6.3:只有在本暂存区采用时才出)。原来 rules_redraft 原样入库,之后每个月都要再勾一次(评审意见)。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=337, mode="replace")
|
||
source_id = base["source_id"]
|
||
raw, fn = DRIFT_CASES["D04"].build(337)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
out = (await client.post(f"{API}/imports/{st['id']}/redraft-rules", json={})).json()
|
||
assert out["aligned_recipe"] is not None
|
||
resp = await client.put(f"{API}/imports/{st['id']}/recipe",
|
||
json={"recipe": out["aligned_recipe"], "origin": "rules_redraft"})
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["recipe_origin"] == "rules_redraft"
|
||
st = await trial(client, st["id"])
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
assert "redraft_adopted" in confirms(st), "本暂存区采用了重新起草:照旧出 redraft_adopted"
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
card = await source_row(client, source_id)
|
||
assert card["current_recipe"]["origin"] == "rules"
|
||
imp = await db_get(TableImport, resp.json()["import_id"])
|
||
assert artifact_store.load(imp.manifest_artifact)["recipe"]["origin"] == "rules"
|
||
|
||
raw, fn = DRIFT_CASES["D04"].build(338)
|
||
nxt = await reupload(client, source_id, raw, fn)
|
||
assert nxt["recipe_origin"] == "rules"
|
||
t = nxt["trial"]
|
||
assert t["status"] == "passed", t["problems"]
|
||
ids = confirms(nxt)
|
||
assert "redraft_adopted" not in ids, ids
|
||
assert [i["id"] for i in t["confirm_items"] if i.get("source", "recipe") == "recipe"] == [], ids
|
||
|
||
|
||
async def test_a_stored_rules_redraft_origin_is_read_as_rules_for_the_next_staging(client):
|
||
"""防住已经入库的 rules_redraft(修复之前提交的配方记录):上传新一期、修改配方给暂存区赋来源时同样映射成 rules,
|
||
不出 redraft_adopted。"""
|
||
from sqlalchemy import update
|
||
|
||
from app.db.models import TableRecipe
|
||
|
||
base = await first_import(client, unique(), seed=344, mode="replace")
|
||
async with SessionLocal() as session:
|
||
await session.execute(update(TableRecipe).where(TableRecipe.id == base["commit"]["recipe_id"])
|
||
.values(origin="rules_redraft"))
|
||
await session.commit()
|
||
raw, fn = flow_workbook(SEP, 30, seed=345)
|
||
st = await reupload(client, base["source_id"], raw, fn)
|
||
assert st["recipe_origin"] == "rules" and st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
assert "redraft_adopted" not in confirms(st)
|
||
resp = await client.delete(f"{API}/imports/{st['id']}")
|
||
assert resp.status_code in (200, 204), resp.text
|
||
resp = await client.post(f"{API}/{base['source_id']}/redraft", json={})
|
||
assert resp.status_code == 201, resp.text
|
||
assert resp.json()["recipe_origin"] == "rules"
|
||
|
||
|
||
async def test_redraft_rules_without_a_recipe_leaves_alignment_empty(client):
|
||
"""规则起草没得到配方时,失败原因只在 draft.failures;alignment 是「名字对齐」的报告,为空(界面把它放在那个标题下,
|
||
抄一份过去同一句话就出现两次,check-manage 伪造的也是 [],评审意见)。"""
|
||
import io
|
||
|
||
import openpyxl
|
||
|
||
base = await first_import(client, unique(), seed=339)
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "客流汇总"
|
||
ws["A1"] = "说明文字甲"
|
||
ws["A3"] = "说明文字乙"
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
st = await reupload(client, base["source_id"], buf.getvalue(), "说明.xlsx")
|
||
resp = await client.post(f"{API}/imports/{st['id']}/redraft-rules", json={})
|
||
assert resp.status_code == 200, resp.text
|
||
out = resp.json()
|
||
assert out["draft"]["recipe"] is None and out["draft"]["failures"]
|
||
assert out["aligned_recipe"] is None and out["compare"] is None and out["alignment"] == []
|
||
|
||
|
||
async def test_same_file_again_with_an_acceptance_is_a_new_import(client):
|
||
"""带接受的重传照常新建导入记录,接受要重写(第 5 节:unchanged 只适用于没有接受的情况)。回执旁给出上次(没作废的)
|
||
接受理由作参考,不预填。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=340)
|
||
source_id = base["source_id"]
|
||
raw, fn = DRIFT_CASES["D26"].build(340)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
t = st["trial"]
|
||
assert t["status"] == "needs_decision", t["problems"]
|
||
first = await commit(client, st, acceptances=[{"check_id": c, "reason": "合成理由甲"} for c in t["acceptable"]])
|
||
assert first.status_code == 201, first.text
|
||
st = await reupload(client, source_id, raw, fn)
|
||
t = st["trial"]
|
||
assert t["accumulate"]["action"] == "replace_period" and t["same_as_import"]["id"] == first.json()["import_id"]
|
||
assert [a["reason"] for a in t["prior_acceptances"]] == ["合成理由甲"] * len(t["acceptable"])
|
||
resp = await commit(client, st, acceptances=[{"check_id": c, "reason": "合成理由乙"} for c in t["acceptable"]])
|
||
assert resp.status_code == 201, resp.text
|
||
out = resp.json()
|
||
assert out["unchanged"] is False and out["import_id"] != first.json()["import_id"] and out["parts"] == 2
|
||
imp = await db_get(TableImport, out["import_id"])
|
||
assert [o["reason"] for o in imp.overrides or []] + [w["reason"] for w in imp.waivers or []] \
|
||
== ["合成理由乙"] * len(t["acceptable"])
|
||
async with SessionLocal() as session:
|
||
n = len(list((await session.execute(select(TableImport).where(TableImport.source_id == source_id))).scalars()))
|
||
assert n == 3
|
||
|
||
|
||
def _drop_total_table(recipe: dict[str, Any]) -> dict[str, Any]:
|
||
"""合计行改为「不另存」(keep_as 置空),「时段客流_表内合计」从目标配方里去掉。"""
|
||
rec = json.loads(json.dumps(recipe, ensure_ascii=False))
|
||
n = 0
|
||
for sheet in rec["sheets"]:
|
||
for block in sheet["blocks"]:
|
||
for seg in block.get("segments", []):
|
||
if seg.get("keep_as"):
|
||
seg["keep_as"] = None
|
||
n += 1
|
||
rec["tables"] = [t for t in rec["tables"] if t["name"] != "时段客流_表内合计"]
|
||
assert n
|
||
return rec
|
||
|
||
|
||
async def test_a_table_retired_once_is_not_retired_again_next_month(client):
|
||
"""退役链(2.5):9 月去掉合计表(必勾 retire:时段客流_表内合计,早期各期保留原值);10 月同一配方,这张表已是
|
||
「早已退役」(快照清单 columns 的 status),不再出 retire:*。试运行要把快照清单的退役记录传给分档,漏传就每个月
|
||
都再报一次退役(评审意见:变异「不传 retired_status」没有测试抓到)。"""
|
||
name = unique()
|
||
base = await first_import(client, name, seed=341)
|
||
source_id = base["source_id"]
|
||
raw, fn = flow_workbook(SEP, 30, seed=342)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
resp = await client.put(f"{API}/imports/{st['id']}/recipe", json={"recipe": _drop_total_table(st["recipe"])})
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["recipe_problems"] == []
|
||
st = await trial(client, st["id"])
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
plan = st["trial"]["accumulate"]
|
||
assert plan["change"] == "retire" and plan["retired_new"] == [{"table": "时段客流_表内合计", "column": None}]
|
||
assert "retire:时段客流_表内合计" in confirms(st)
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
snap = await db_get(SourceSnapshot, resp.json()["snapshot_id"])
|
||
columns = artifact_store.load(snap.manifest_artifact)["columns"]["时段客流_表内合计"]
|
||
assert columns and all(c["status"] == "retired" and c["since"] == "2026-09-01~2026-09-30" for c in columns.values())
|
||
assert await count(client, name, "时段客流_表内合计") == 93
|
||
|
||
raw, fn = flow_workbook(OCT, 31, seed=343)
|
||
st = await reupload(client, source_id, raw, fn)
|
||
t = st["trial"]
|
||
assert t["status"] == "passed", t["problems"]
|
||
assert t["accumulate"]["retired_new"] == []
|
||
assert t["accumulate"]["retired_existing"] == [{"table": "时段客流_表内合计", "column": None}]
|
||
assert not [i for i in confirms(st) if i.startswith("retire")], confirms(st)
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
assert await count(client, name, "时段客流_表内合计") == 93
|
||
|
||
|
||
async def test_appending_after_removing_the_only_period_with_a_column_asks_no_phantom_retire(client):
|
||
"""试运行路径的幽灵退役项(WP-8 修补,WP-5 交付说明「需要你处理」第 2 条):8 月 R0;9 月 D13 修复加「分区丙」(R1);
|
||
移除 9 月之后当前版本按 R1 物化成单期并集,「分区丙」是一列全为空值的目标列;10 月修复去掉分区丙(R2)追加。
|
||
结果各期(8 月、10 月)和当前版本的任何一期都没有分区丙的数据,不该出 retire:日客流.分区丙,并集里也不再有这一列。
|
||
改回「把未筛选的当前表结构传给累积计划」,这里会多出一条任何一期都没有数据的必勾退役项。"""
|
||
from tests.test_source_versions_api import _fix_until_passed
|
||
|
||
name = unique()
|
||
base = await first_import(client, name, seed=351)
|
||
sid = base["source_id"]
|
||
raw, fn = DRIFT_CASES["D13"].build(351)
|
||
st = await _fix_until_passed(client, await reupload(client, sid, raw, fn))
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
sep, s2 = resp.json()["import_id"], resp.json()["snapshot_id"]
|
||
resp = await client.post(f"{API}/{sid}/imports/{sep}/remove",
|
||
json={"confirm": True, "expected_current_snapshot_id": s2, "reason": "合成理由"})
|
||
assert resp.status_code == 200, resp.text
|
||
s3 = await db_get(SourceSnapshot, resp.json()["snapshot_id"])
|
||
assert Path(s3.db_path).parent.name == "snapshots", "8 月的配方不是目标配方:按 R1 物化成单期并集"
|
||
assert "分区丙" in [c["name"] for c in s3.schema_cache["tables"]["日客流"]["columns"]], "目标列照旧在,全为空值"
|
||
|
||
raw, fn = flow_workbook(OCT, 31, seed=352)
|
||
st = await _fix_until_passed(client, await reupload(client, sid, raw, fn))
|
||
t = st["trial"]
|
||
assert t["status"] == "passed", (t["problems"], st["recipe_problems"])
|
||
assert t["accumulate"]["action"] == "append"
|
||
assert {"table": "日客流", "column": "分区丙"} not in t["accumulate"]["retired_new"], t["accumulate"]
|
||
assert {"table": "日客流", "column": "分区丙"} not in t["accumulate"]["retired_existing"], t["accumulate"]
|
||
assert not [i for i in confirms(st) if i.startswith("retire")], confirms(st)
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
snap = await db_get(SourceSnapshot, resp.json()["snapshot_id"])
|
||
assert "分区丙" not in [c["name"] for c in snap.schema_cache["tables"]["日客流"]["columns"]]
|
||
assert "分区丙" not in artifact_store.load(snap.manifest_artifact)["columns"].get("日客流", {})
|
||
assert await count(client, name, "日客流") == 62
|
||
|
||
|
||
|
||
async def test_a_target_column_without_data_is_not_reported_as_added_again(client):
|
||
"""WP-8 修补的另一半:筛当前表结构时,目标配方里有的列照旧留着。8 月 R0;9 月 D13 修复加「分区丙」(R1);移除 9 月之后
|
||
当前版本按 R1 物化,分区丙是全为空值的目标列;10 月的文件又有分区丙、按 R1 直接重放:当前版本本来就有这一列,计划里
|
||
不该再报「新增列」(2.5 第 2 步按当前版本实际的表结构比)。只按各期配方筛、不留目标配方的列,这里会多出一条新增。"""
|
||
from tests.test_source_versions_api import _fix_until_passed
|
||
|
||
name = unique()
|
||
base = await first_import(client, name, seed=361)
|
||
sid = base["source_id"]
|
||
raw, fn = DRIFT_CASES["D13"].build(361)
|
||
st = await _fix_until_passed(client, await reupload(client, sid, raw, fn))
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
sep, s2 = resp.json()["import_id"], resp.json()["snapshot_id"]
|
||
resp = await client.post(f"{API}/{sid}/imports/{sep}/remove",
|
||
json={"confirm": True, "expected_current_snapshot_id": s2, "reason": "合成理由"})
|
||
assert resp.status_code == 200, resp.text
|
||
raw, fn = flow_workbook(OCT, 31, seed=362, variant="D13")
|
||
st = await reupload(client, sid, raw, fn)
|
||
t = st["trial"]
|
||
assert t["status"] == "passed", (t["problems"], st["recipe_problems"])
|
||
assert t["accumulate"]["action"] == "append"
|
||
assert t["accumulate"]["added"] == [] and t["accumulate"]["retired_new"] == [], t["accumulate"]
|
||
assert not [d for d in t["diff"] or [] if d["kind"] == "columns_added"], t["diff"]
|
||
|
||
|
||
async def test_replacing_the_only_period_with_a_column_asks_breaking_not_a_phantom_retire(client):
|
||
"""替换该期的幽灵退役项(WP-8 评审意见 2):8 月 R0;9 月 D13 修复加「分区丙」(R1)后提交;再上传不含分区丙的 9 月
|
||
(修复去掉分区丙后为 R2),替换该期。结果各期(8 月 R0、新 9 月 R2)都没有分区丙:不出 retire:*(文案写「早期各期
|
||
保留原值」,可哪一期都没有这一列),改出必勾的 breaking:日客流(列从现行配方里消失,仍要人确认,不会变成静默);
|
||
提交后并集里没有分区丙,也没有全为空值的列。改回「把要被替换的那一期也算进结果各期」,这里会出 retire:日客流.分区丙,
|
||
并集多出一列全空的分区丙。"""
|
||
import sqlite3
|
||
from contextlib import closing
|
||
|
||
from tests.test_source_versions_api import _fix_until_passed
|
||
|
||
name = unique()
|
||
base = await first_import(client, name, seed=361)
|
||
sid = base["source_id"]
|
||
raw, fn = DRIFT_CASES["D13"].build(361)
|
||
st = await _fix_until_passed(client, await reupload(client, sid, raw, fn))
|
||
assert st["trial"]["status"] == "passed", st["trial"]["problems"]
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
sep_r1 = resp.json()["import_id"]
|
||
|
||
raw, fn = flow_workbook(SEP, 30, seed=362)
|
||
st = await _fix_until_passed(client, await reupload(client, sid, raw, fn))
|
||
t = st["trial"]
|
||
assert t["status"] == "passed", (t["problems"], st["recipe_problems"])
|
||
assert t["accumulate"]["action"] == "replace_period" and t["accumulate"]["replaces"]["import_id"] == sep_r1
|
||
assert {"table": "日客流", "column": "分区丙"} not in t["accumulate"]["retired_new"], t["accumulate"]
|
||
assert {"table": "日客流", "column": "分区丙"} not in t["accumulate"]["retired_existing"], t["accumulate"]
|
||
ids = confirms(st)
|
||
assert not [i for i in ids if i.startswith("retire")], ids
|
||
assert "breaking:日客流" in ids and "period_replace:2026-09-01~2026-09-30" in ids, ids
|
||
resp = await commit(client, st, confirmations=[i for i in ids if i != "breaking:日客流"])
|
||
assert resp.status_code == 422 and resp.json()["code"] == "confirm_required", resp.text
|
||
assert "breaking:日客流" in resp.json()["detail"]
|
||
resp = await commit(client, st)
|
||
assert resp.status_code == 201, resp.text
|
||
snap = await db_get(SourceSnapshot, resp.json()["snapshot_id"])
|
||
assert "分区丙" not in [c["name"] for c in snap.schema_cache["tables"]["日客流"]["columns"]]
|
||
assert "分区丙" not in artifact_store.load(snap.manifest_artifact)["columns"].get("日客流", {})
|
||
empty = []
|
||
with closing(sqlite3.connect(f"file:{snap.db_path}?mode=ro", uri=True)) as conn:
|
||
for table, info in snap.schema_cache["tables"].items():
|
||
for col in info["columns"]:
|
||
n = conn.execute(f'SELECT COUNT("{col["name"]}") FROM "{table}"').fetchone()[0]
|
||
if n == 0:
|
||
empty.append(f"{table}.{col['name']}")
|
||
assert empty == [], f"并集里不该有全为空值的列:{empty}"
|
||
assert await count(client, name, "日客流") == 61
|