Commit 9b014b66 authored by wenwen.tang's avatar wenwen.tang 😕

test: 沙箱补 DRAFT_DIR 防污染;smoke 重写、round6 覆盖草稿

- ui_verify_runner + ui_verify7/8/9/10/11 + check_scheduler:沙箱配置枚举里补
  DRAFT_DIR 指向沙箱 data/drafts —— 不补会让草稿写进真实 data/(同 17 批次泄漏那类坑)
- test_round6:删 TestKeepVerify(9 项,服务端不再保留核对),新增 5 项草稿用例
- 新增 tools/smoke_verify_edit.py:核对态编辑 → 确认 → 改动落盘且核对状态保持,
  外加「部分核对 ok=True partial=True」共 16 断言,全部跑在沙箱
parent fff9b2a6
......@@ -49,6 +49,7 @@ def sandbox():
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
# -*- coding: utf-8 -*-
"""核对中就地改数据 + 录入临时保存的端到端冒烟 —— **必须经 ui_verify_runner 沙箱跑**。
覆盖(2026-10-14 新口径):
A. 核对里改数据 —— 前端是「先重编、再补一次账期确认」,故服务端表现为:
1. 建一个含 3 行明细的 2099年01月 电费批次
2. PUT 改一行(= 重编),核对状态被清空(服务端不猜哪些行变了)
3. 紧接着 POST /api/periods/verify {rows, done:true}
→ 改动落盘 + 用户勾的行重新算已核对(**改数据不影响核对状态**)
4. 只勾一部分行确认 → 部分核对也算 ok,能发送
B. 录入草稿(临时保存)
5. PUT /api/drafts/elec 存草稿 → GET 读回内容一致
6. 换账号互不影响(guest 有 elec 能力)
7. DELETE 清掉 → GET 返回 null
用法:python tools/ui_verify_runner.py tools/smoke_verify_edit.py
"""
import json
import os
import sys
import urllib.error
import urllib.request
sys.stdout = __import__("io").TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
BASE = os.environ["RENTOS_BASE"]
TOKEN = ""
FAILED = []
class ApiError(Exception):
def __init__(self, code, payload):
super().__init__("HTTP %s %s" % (code, payload))
self.code = code
self.payload = payload
def call(path, method="GET", body=None, token=None):
req = urllib.request.Request(BASE + path, method=method)
req.add_header("Authorization", "Bearer " + (token or TOKEN))
data = None
if body is not None:
req.add_header("Content-Type", "application/json")
data = json.dumps(body).encode("utf-8")
try:
with urllib.request.urlopen(req, data, timeout=30) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raise ApiError(e.code, e.read().decode("utf-8")) from None
def check(name, cond, detail=""):
print((" PASS " if cond else " FAIL ") + name + (" " + str(detail) if detail else ""))
if not cond:
FAILED.append(name)
def login(username="admin", password="1989@rent05"):
req = urllib.request.Request(BASE + "/api/login", method="POST")
req.add_header("Content-Type", "application/json")
d = json.dumps({"username": username, "password": password}).encode("utf-8")
with urllib.request.urlopen(req, d, timeout=20) as r:
return json.loads(r.read().decode("utf-8"))["token"]
PERIOD = "2099年01月"
KEYS = ["302室|照明", "302室|空调", "303室|照明"]
def mkrows():
return [{"unit": "302室", "meter": "照明", "prev": 100, "cur": 180},
{"unit": "302室", "meter": "空调", "prev": 100, "cur": 200},
{"unit": "303室", "meter": "照明", "prev": 100, "cur": 140}]
def payload_of(details):
"""按前端 elecRowsPayload 同构回填(全字段 + images)。"""
return [{"unit": x.get("unit"), "meter": x.get("meter"), "prev": x.get("prev"),
"cur": x.get("cur"), "price": x.get("price"), "factor": x.get("factor"),
"company": x.get("company"), "owner": x.get("owner"),
"note": x.get("note"), "images": x.get("images") or []}
for x in details]
def main():
global TOKEN
TOKEN = login()
bid = None
try:
print("== A1. 建批次 ==")
d = call("/api/batches", "POST",
{"rows": mkrows(), "period_label": PERIOD,
"meter_date": "2099-01-05", "notice_date": "2099-01-05",
"due_date": "2099-01-20"})
bid = d["batch"]["batch_id"]
check("批次已建", bool(bid), bid)
print("== A2. 先整期确认一次(基线:3/3 已核对)==")
ps = call("/api/periods/verify", "POST",
{"period": PERIOD, "rows": KEYS, "done": True})["period"]
check("确认后 ok=True", ps["ok"] is True)
check("确认后 3/3 行", ps["verified"] == 3 and ps["total"] == 3)
print("== A3. 核对里改一行:PUT 重编 → 核对被清空(服务端不猜)==")
det = call("/api/batches/" + bid)["batch"]["details"]
rows2 = payload_of(det)
idx = [i for i, r in enumerate(rows2) if r["unit"] == "303室"][0]
rows2[idx]["cur"] = 150
call("/api/batches/" + bid, "PUT", {"rows": rows2, "period_label": PERIOD})
v = call("/api/batches/" + bid)["batch"]["verify"]
check("重编后核对清空", not (v.get("rows") or []) and v.get("done") is False, v)
print("== A4. 紧接着账期确认 → 改动落盘 + 勾选重新算已核对 ==")
ps2 = call("/api/periods/verify", "POST",
{"period": PERIOD, "rows": KEYS, "done": True})["period"]
b2 = call("/api/batches/" + bid)["batch"]
cur303 = [x for x in b2["details"] if x["unit"] == "303室"][0]["cur"]
check("改动的数据已落盘(303 本期=150)", cur303 == 150, cur303)
check("改数据不影响核对状态:仍 3/3 ok", ps2["verified"] == 3 and ps2["ok"] is True, ps2)
print("== A5. 部分核对(只勾 2 行)也允许确认 ==")
call("/api/batches/" + bid, "PUT",
{"rows": payload_of(b2["details"]), "period_label": PERIOD}) # 又一次重编
ps3 = call("/api/periods/verify", "POST",
{"period": PERIOD, "rows": ["302室|照明", "302室|空调"],
"done": True})["period"]
check("部分核对 ok=True", ps3["ok"] is True, ps3.get("ok"))
check("部分核对 partial=True", ps3["partial"] is True)
check("部分核对 2/3 行", ps3["verified"] == 2 and ps3["total"] == 3)
print("== B1. 录入草稿:临时保存 / 读回 ==")
draft_rows = [{"unit": "302室", "meter": "照明", "prev": 100, "cur": ""},
{"unit": "303室", "meter": "空调", "prev": 50, "cur": 90}]
d1 = call("/api/drafts/elec", "PUT",
{"rows": draft_rows, "period_label": "2099年02月",
"meter_date": "2099-02-05"})
check("草稿已保存且带行数", d1["draft"]["count"] == 2, d1["draft"])
check("草稿带落盘时间", bool(d1["draft"]["updated_at"]), d1["draft"].get("updated_at"))
got = call("/api/drafts/elec")["draft"]
check("读回内容一致", got["rows"] == draft_rows, got.get("rows"))
check("读回期间字段一致", got["period_label"] == "2099年02月")
print("== B2. 换账号(guest)读不到 admin 的草稿 ==")
try:
gt = login("guest", "guest@2026")
gd = call("/api/drafts/elec", token=gt)["draft"]
check("guest 看不到 admin 的草稿", gd is None, gd)
call("/api/drafts/elec", "DELETE", token=gt)
still = call("/api/drafts/elec")["draft"]
check("guest 清草稿不影响 admin 的", still is not None and still["rows"] == draft_rows)
except ApiError as e:
check("guest 账号可用(跳过隔离断言)", False, "HTTP %s" % e.code)
print("== B3. 清掉草稿 ==")
call("/api/drafts/elec", "DELETE")
check("删除后读回 null", call("/api/drafts/elec")["draft"] is None)
finally:
print("== 清理 ==")
if bid:
try:
call("/api/batches/" + bid, "DELETE", {})
print(" 已删除沙箱批次", bid)
except ApiError as e: # noqa: PERF203
print(" 删除批次失败(忽略):", e)
try:
call("/api/drafts/elec", "DELETE")
except ApiError:
pass
print("\n" + ("全部通过" if not FAILED else "失败 %d 项: %s" % (len(FAILED), FAILED)))
return 1 if FAILED else 0
if __name__ == "__main__":
sys.exit(main())
......@@ -16,9 +16,10 @@
· service.reingest:覆盖式重编(batch_id 不变、created_at 保留)、
发送状态按文件名沿用、核对状态重置、重编次数累加、清旧 PDF、
房租批次与不存在的批次一律拒绝
· verify_summary / set_verify:逐室进度、未勾完不许确认、确认后 ok、
勾选回退自动撤销确认、未知房间被过滤、房租批次不走核对
· store.list_batches:核对摘要字段(verify_total / verify_units / verify_ok)
· verify_summary / set_verify:逐**行**(单元|类目)进度、允许部分核对也能确认、
确认后 ok、勾选回退自动撤销确认、未知行被过滤、房租批次不走核对
· store 草稿:录入「临时保存」按账号隔离落盘,能读回 / 删掉,账号名净化
· store.list_batches:核对摘要字段(verify_rows / verify_rows_done / verify_ok)
运行:python -m unittest discover -s tools -p "test_*.py"
"""
......@@ -317,6 +318,49 @@ class TestReingest(FakeElecBackend):
self.assertEqual(b2["totals"]["row_count"], 1)
# ============================ 4c. 临时保存草稿(落盘 data/drafts/,按账号一份)
class TestElecDraft(FakeElecBackend):
"""录入页「临时保存」:草稿按账号隔离落盘,能原样读回、能删掉。"""
def setUp(self):
super().setUp()
# 只把草稿目录指到临时目录,draft_path 的账号名净化逻辑仍走真实实现
p = mock.patch.object(C, "DRAFT_DIR", os.path.join(self.tmpdir, "drafts"))
p.start()
self.addCleanup(p.stop)
def test_roundtrip(self):
rows = [{"unit": "302室", "meter": "照明", "prev": 100, "cur": 180},
{"unit": "303室", "meter": "空调", "prev": 0, "cur": ""}]
store.save_draft("elec__admin",
{"rows": rows, "period_label": "2026年10月", "count": 2})
d = store.load_draft("elec__admin")
self.assertEqual(d["rows"], rows)
self.assertEqual(d["period_label"], "2026年10月")
def test_missing_draft_is_none(self):
self.assertIsNone(store.load_draft("elec__nobody"))
def test_delete_draft(self):
store.save_draft("elec__admin", {"rows": []})
self.assertTrue(store.delete_draft("elec__admin"))
self.assertIsNone(store.load_draft("elec__admin"))
self.assertFalse(store.delete_draft("elec__admin")) # 再删返回 False
def test_accounts_are_isolated(self):
store.save_draft("elec__a", {"rows": [1]})
store.save_draft("elec__b", {"rows": [1, 2]})
self.assertEqual(len(store.load_draft("elec__a")["rows"]), 1)
self.assertEqual(len(store.load_draft("elec__b")["rows"]), 2)
store.delete_draft("elec__a")
self.assertIsNone(store.load_draft("elec__a"))
self.assertIsNotNone(store.load_draft("elec__b")) # 删一个不影响另一个
def test_draft_path_sanitizes_account_name(self):
p = store.draft_path("elec__admin@x.y-z")
self.assertTrue(p.endswith("elec__admin_x_y-z.json"), p)
# ================================================================ 5. 核对(按行:单元|类目)
class TestVerify(FakeElecBackend):
def _b3(self):
......
......@@ -62,6 +62,7 @@ def start_sandbox():
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -56,6 +56,7 @@ def start_sandbox():
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -56,6 +56,7 @@ def start_sandbox():
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -58,6 +58,7 @@ def start_sandbox():
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -54,6 +54,7 @@ def start_sandbox():
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
......@@ -45,6 +45,7 @@ def start_sandbox():
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"DRAFT_DIR": os.path.join(data, "drafts"), # 电费录入草稿(临时保存)
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment