Files
xiaotutongxue/scripts/e2e-m5-teacher-dashboard.py
T
lovife 30c3714d35 test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。

压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。

度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。

验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
2026-09-30 09:02:57 +08:00

316 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""班主任端看板 E2E(《03-接口设计》§10.1)。
这一屏最容易做错的两件事,脚本就是为它们写的:
1. **口径铁律**:`submitRate` 是**已提交率**,与 `approvedCount` **分开呈现**。
合并成一个"完成率"会让"有人没交"(去催报)和"有人没过"(去看材料)
变成同一个数字 —— 老师看到 60% 不知道该做哪件事。
E2E 刻意造出"3 人全交、只过 1 人"的数据,两个数字必须同时正确且不相等。
2. **范围只算"我负责的班"**:同校另一个班主任的班不能出现在我的看板里,
`rosterTotal` 也不能把他的班算进来。(只查权限不查范围 = 全校数字,
而老师会以为那是自己班的。)
跑法:python scripts/e2e-m5-teacher-dashboard.py
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-dash-roster.xlsx")
T1 = 993501 # 班主任甲(指定为 A 班的初审人)
T2 = 993502 # 班主任乙(指定为 B 班的初审人)
STUDENTS = [993511, 993512, 993513]
def _load(module_name: str, filename: str):
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
S = _load("e2e_m3_submit", "e2e-m3-submit.py")
A = _load("e2e_m5_audit", "e2e-m5-audit.py")
BASE = "http://localhost:8080"
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> str:
_, out = R.sql(stmt)
return out
def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict:
_, b = R.call(method, f"{BASE}{path}", token, payload)
return b
def mp(uid: int) -> str:
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str:
"""建一个真实班主任账号(看板走的是"老师"这条路,需要一个能登录的账号)。"""
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status)
VALUES ({uid}, 1, '{username}', '{name}',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org_id}, 3, 1)
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, status = 1
""")
role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' "
"and deleted=0 limit 1;").strip()
sql(f"delete from sys_user_role where user_id={uid};")
sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);")
_, b = R.login(BASE, username, "Admin@123", "demo")
if b.get("code") != 0:
raise SystemExit(f" 班主任 {username} 登录失败:{b}")
return b["data"]["token"]
def main() -> int:
ap = argparse.ArgumentParser(description="班主任看板 E2E")
ap.parse_args()
print("=" * 74)
print(" 班主任端看板 E2E(§10.1)")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(f" ✗ 管理员登录失败:{adm}")
return 2
at = adm["data"]["token"]
# ── 0. 夹具:一个任务、两个班、两个班主任 ──
print("\n[0] 摆夹具(一个任务、两个班、两个班主任)")
suffix = os.urandom(3).hex()
_, b = R.call("POST", f"{BASE}/api/v1/tasks", at, {
"taskName": f"E2E班主任看板 {suffix}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
if b.get("code") != 0:
print(f" ✗ 建任务失败:{b}")
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
_, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
if len(classes) < 2:
print(" ✗ 需要至少两个班级组织")
return 2
org_a, org_b = classes[0], classes[1]
t1_tok = ensure_teacher(T1, f"dashT1{suffix}", "看板班主任甲", org_a["id"])
ensure_teacher(T2, f"dashT2{suffix}", "看板班主任乙", org_b["id"])
CREATED_USER_IDS.extend([T1, T2])
ok("两个班主任账号就绪(都能登录)")
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [
{"orgId": org_a["id"], "expectedCount": len(STUDENTS), "headUserId": T1},
{"orgId": org_b["id"], "expectedCount": 1, "headUserId": T2}
]})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
rows = [("看板甲", R.with_checksum("36102920080101021")),
("看板乙", R.with_checksum("36102920080202022")),
("看板丙", R.with_checksum("36102920080303023"))]
_build_roster(rows)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org_a["id"]})
if body.get("code") != 0:
print(f" ✗ 导名册失败:{body}")
return 2
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
ok("任务已发布(A 班 3 人,B 班 0 人)")
# ── 1. 三个学生全交 ──
print("\n[1] A 班三人全部提交(已提交率应当 100%)")
sids = []
for i, (uid, (name, idc)) in enumerate(zip(STUDENTS, rows)):
tok = mp(uid)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": name, "idCardNo": idc})
if b.get("code") != 0:
print(f" ✗ 门禁失败({name}):{b}")
return 2
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id})
sid = b["data"]["id"]
sids.append(sid)
res = A.prepare_student(tok, sid, task_id, {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}, dup_first_file=False)
if not (res.get("data") or {}).get("submitted"):
print(f" ✗ 提交失败({name}):{json.dumps(res, ensure_ascii=False)[:200]}")
return 2
check("三人都已提交", sql(f"select count(*) from collect_submission where task_id={task_id} "
f"and status >= 1 and deleted=0;").strip() == "3")
# ── 2. 只通过一个人 → 让两个数字**不相等** ──
print("\n[2] 只终审通过 1 人(这样「已提交 3」与「已通过 1」必然不同)")
b = body_of("POST", f"/api/v1/audit/submissions/{sids[0]}/approve", t1_tok,
{"actionSource": "BUTTON"})
check("班主任初审通过 1 人", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
b = body_of("POST", f"/api/v1/audit/submissions/{sids[0]}/approve", at,
{"actionSource": "BUTTON", "finalOpinion": "E2E"})
check("管理员终审通过 1 人", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
# ── 3. 看板口径 ──
print("\n[3] 看板口径(本节是重点)")
d = body_of("GET", "/api/v1/mp/teacher/dashboard", t1_tok).get("data") or {}
check("看板接口可用", isinstance(d.get("summary"), dict),
json.dumps(d, ensure_ascii=False)[:200])
s = d.get("summary") or {}
check("应填 3 人", s.get("rosterTotal") == 3, f"{s.get('rosterTotal')}")
check("已提交 3 人", s.get("submittedCount") == 3, f"{s.get('submittedCount')}")
check("⭐ 已通过 1 人(与已提交分开)", s.get("approvedCount") == 1,
f"{s.get('approvedCount')}")
check("⭐ submitRate 是**已提交率** 100.0%(不是通过率的 33.3%)",
s.get("submitRate") == "100.0%", f"{s.get('submitRate')}")
check("未提交 0 人", s.get("unsubmittedCount") == 0, f"{s.get('unsubmittedCount')}")
check("待我初审 2 人(三人里过了 1 个)", (d.get("todos") or {}).get("auditCount") == 2,
json.dumps(d.get("todos"), ensure_ascii=False))
orgs = d.get("myOrgs") or []
check("⭐ 只看到我负责的班(1 个,不是 2 个)", len(orgs) == 1,
json.dumps([o.get("orgName") for o in orgs], ensure_ascii=False))
if orgs:
o = orgs[0]
check("班级卡片:人数 3 / 已提交 3 / 已通过 1",
(o.get("rosterTotal"), o.get("submittedCount"), o.get("approvedCount")) == (3, 3, 1),
json.dumps(o, ensure_ascii=False))
check("班级卡片也给出已提交率", o.get("submitRate") == "100.0%", f"{o.get('submitRate')}")
check("标出我是这个班的主班主任", o.get("isHead") is True, f"{o.get('isHead')}")
check("卡片带待审角标(2)", o.get("auditCount") == 2, f"{o.get('auditCount')}")
check("带最近截止的任务信息", (d.get("deadline") or {}).get("taskId") is not None,
json.dumps(d.get("deadline"), ensure_ascii=False))
# ── 4. 另一个班主任看不到我的班 ──
print("\n[4] 范围:另一个班主任看不到我的班")
_, b = R.login(BASE, f"dashT2{suffix}", "Admin@123", "demo")
t2_tok = b["data"]["token"]
d2 = body_of("GET", "/api/v1/mp/teacher/dashboard", t2_tok).get("data") or {}
orgs2 = d2.get("myOrgs") or []
check("⭐ 他只看得到自己的班", all(o.get("orgId") != org_a["id"] for o in orgs2),
json.dumps([o.get("orgName") for o in orgs2], ensure_ascii=False))
check("⭐ 他的人数是 0(我的 3 人没被算进去)",
(d2.get("summary") or {}).get("rosterTotal") == 0,
f"{(d2.get('summary') or {}).get('rosterTotal')}")
check("他的待审也是 0", (d2.get("todos") or {}).get("auditCount") == 0,
json.dumps(d2.get("todos"), ensure_ascii=False))
# ── 5. 空班:分母为 0 时不能显示 0% ──
print("\n[5] 分母为 0 时显示「—」而不是 0%")
if orgs2:
o2 = orgs2[0]
check("空班的已提交率是「—」(0 人 ≠ 没人交)", o2.get("submitRate") == "—",
f"{o2.get('submitRate')}")
# ── 6. 权限 ──
print("\n[6] 权限与学生不该看到看板")
stok = mp(STUDENTS[0])
b = body_of("GET", "/api/v1/mp/teacher/dashboard", stok)
check("学生看班主任看板 → 40301 或 403",
b.get("code") in (40301, 403) or b.get("_http") == 403,
f"code={b.get('code')} http={b.get('_http')}")
b = body_of("GET", "/api/v1/mp/teacher/dashboard")
check("未登录 → 40101", b.get("code") == 40101, f"code={b.get('code')}")
cleanup()
print("\n" + "=" * 74)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 74)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
return 1 if FAILED else 0
def _build_roster(rows: list[tuple[str, str]]) -> None:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "看板班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, idc) in enumerate(rows):
vals = [i + 1, name, idc, "中专", f"138001388{i}", "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=i + 2, column=c, value=v)
wb.save(TMP_ROSTER)
def cleanup() -> None:
for tid in CREATED_TASK_IDS:
R.sql(f"delete from collect_audit_record where submission_id in "
f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_file where submission_id in "
f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_submission where task_id={tid};")
R.sql(f"delete from collect_roster where task_id={tid};")
R.sql(f"delete from collect_task_org where task_id={tid};")
R.sql(f"delete from collect_material_def where task_id={tid};")
R.sql(f"delete from collect_join_request where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};")
for uid in CREATED_USER_IDS:
R.sql(f"delete from sys_user_role where user_id={uid};")
R.sql(f"delete from sys_user where id={uid};")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 材料 / 班主任账号 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())