Files
test/tests/api_check.py
T
2026-09-21 19:03:31 +08:00

630 lines
27 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.
"""端到端自检脚本(不依赖 pytest,直接 python tests/api_check.py 就能跑)。
它按"一个教务老师一天的活儿"的顺序把系统走一遍:
建班 -> 建老师/顾问 -> 录学生(含 Excel 批量)-> 录成绩(触发红线)->
登记就业(触发状态流转)-> 转班(验证冗余字段同步)-> 删就业(状态回退)->
各项统计 -> 高级筛选 -> 权限与异常分支。
用法:
python tests/api_check.py [--base http://127.0.0.1:8000]
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
from datetime import date, timedelta
from pathlib import Path
import httpx
BASE = "http://127.0.0.1:8000"
PASS, FAIL, WARN = [], [], []
class Ctx:
token: str | None = None
client: httpx.Client
def call(method: str, path: str, *, expect_code: int = 0, **kw):
"""发一个请求并断言业务 code。"""
headers = kw.pop("headers", {})
if Ctx.token:
headers["Authorization"] = f"Bearer {Ctx.token}"
response = Ctx.client.request(method, path, headers=headers, **kw)
try:
body = response.json()
except Exception: # noqa: BLE001
body = {"code": -1, "msg": response.text[:200], "data": None}
if expect_code is not None and body.get("code") != expect_code:
raise AssertionError(
f"{method} {path} 期望 code={expect_code},实际 code={body.get('code')} msg={body.get('msg')}"
)
return body
def step(title: str):
"""装饰器:包一层异常捕获,定义即执行。"""
def decorator(func):
def wrapper():
try:
detail = func()
PASS.append((title, detail))
print(f" [PASS] {title} {detail or ''}")
except AssertionError as exc:
FAIL.append((title, str(exc)))
print(f" [FAIL] {title}\n {exc}")
except Exception as exc: # noqa: BLE001
FAIL.append((title, f"{type(exc).__name__}: {exc}"))
print(f" [FAIL] {title}\n {type(exc).__name__}: {exc}")
wrapper()
return func
return decorator
# ==================================================================== 只读接口
def check_reads():
print("\n== 第一部分:登录与只读接口 ==")
@step("健康检查")
def _():
data = call("GET", "/api/health")["data"]
return f"dialect={data['dialect']}"
@step("未登录访问受保护接口应被拒绝")
def _():
token = Ctx.token
Ctx.token = None
body = call("GET", "/students", expect_code=None)
Ctx.token = token
assert body["code"] == 401, f"期望 401,实际 {body['code']}"
return body["msg"]
@step("错误密码登录")
def _():
body = call("POST", "/auth/login", expect_code=None, json={"username": "admin", "password": "wrong"})
assert body["code"] == 401, f"期望 401,实际 {body['code']}"
return body["msg"]
@step("学生列表 + 分页")
def _():
data = call("GET", "/students?page=1&page_size=5")["data"]
assert len(data["items"]) == 5
assert data["pages"] >= 1
return f"total={data['total']} pages={data['pages']}"
@step("学生按班级编号筛选")
def _():
data = call("GET", "/students?class_no=JAVA202601&page_size=100")["data"]
assert data["total"] > 0
assert all(i["class_no"] == "JAVA202601" for i in data["items"])
return f"{data['total']} 人"
@step("学生按年龄区间筛选")
def _():
data = call("GET", "/students?age_min=20&age_max=24&page_size=100")["data"]
assert all(20 <= i["age"] <= 24 for i in data["items"])
return f"{data['total']} 人"
for path, name in [
("/classes?page_size=100", "班级列表"),
("/teachers?page_size=100", "老师列表(含带班)"),
("/advisors?page_size=100", "顾问列表"),
("/scores?page_size=5", "成绩列表"),
("/employments?page_size=5", "就业列表"),
("/students/meta/options", "学生下拉选项"),
("/classes/meta/options", "班级下拉选项"),
("/scores/meta", "成绩元信息"),
("/employments?salary_min=15000&salary_max=8000&page_size=5", "薪资区间写反自动纠正"),
]:
path_local, name_local = path, name
@step(name_local)
def _(p=path_local):
body = call("GET", p)
return f"code={body['code']}"
@step("就业按公司模糊 + 薪资区间查询")
def _():
data = call("GET", "/employments?company=科技&salary_min=10000&page_size=100")["data"]
return f"{data['total']} 条"
# ==================================================================== 写流程
def check_write_flow():
print("\n== 第二部分:完整业务链路 ==")
stamp = date.today().strftime("%m%d")
ctx: dict = {}
@step("新增顾问")
def _():
body = call("POST", "/advisors", json={"name": f"自检顾问{stamp}", "gender": 2, "dept": "自检部"})
ctx["advisor_id"] = body["data"]["id"]
return f"编号 {body['data']['advisor_no']}"
@step("新增老师(工号自动生成 T+年份+序号)")
def _():
body = call("POST", "/teachers", json={"name": f"自检老师{stamp}", "gender": 1, "subject": "自检方向"})
ctx["teacher_id"] = body["data"]["id"]
assert body["data"]["teacher_no"].startswith("T")
return f"工号 {body['data']['teacher_no']}"
@step("新增班级(编号自动生成为 方向+年份+序号)")
def _():
body = call(
"POST", "/classes",
json={
"name": f"自检班{stamp}", "direction": "SelfTest", "capacity": 30,
"open_date": "2026-09-01", "head_teacher_id": ctx["teacher_id"],
"advisor_id": ctx["advisor_id"], "teacher_ids": [ctx["teacher_id"]],
},
)
data = body["data"]
ctx["class_id"] = data["id"]
assert data["teacher_ids"] == [ctx["teacher_id"]], "多对多老师没保存上"
return f"编号 {data['class_no']}"
@step("新增学生(学号按规则生成)")
def _():
body = call(
"POST", "/students",
json={
"name": f"自检学生A{stamp}", "gender": "女", "birth_date": "2003-04-05",
"native_place": "广东广州", "graduate_school": "自检学院", "major": "软件",
"education": "大专", "enroll_date": "2026-09-01", "class_id": ctx["class_id"],
"advisor_id": ctx["advisor_id"], "phone": "13000000001",
},
)
ctx["stu_a"] = body["data"]["id"]
ctx["stu_a_no"] = body["data"]["stu_no"]
assert body["data"]["age"] and body["data"]["class_name"]
return f"学号 {body['data']['stu_no']},年龄 {body['data']['age']},班级 {body['data']['class_name']}"
@step("只给年龄不给生日也能建学生(birth_date_estimated=1)")
def _():
body = call(
"POST", "/students",
json={"name": f"自检学生B{stamp}", "gender": 1, "age": 21, "class_id": ctx["class_id"]},
)
ctx["stu_b"] = body["data"]["id"]
assert body["data"]["birth_date_estimated"] == 1
assert body["data"]["age"] == 21
return f"学号 {body['data']['stu_no']},推算生日 {body['data']['birth_date']}"
@step("学号重复应被拒绝")
def _():
body = call(
"POST", "/students", expect_code=None,
json={"name": "重号测试", "gender": 1, "age": 20, "stu_no": ctx["stu_a_no"]},
)
assert body["code"] == 409, f"期望 409,实际 {body['code']} {body['msg']}"
return body["msg"]
@step("毕业时间早于入学时间应被拒绝")
def _():
body = call(
"POST", "/students", expect_code=None,
json={"name": "日期测试", "gender": 1, "age": 20, "enroll_date": "2026-09-01", "graduate_date": "2025-01-01"},
)
assert body["code"] == 400, f"期望 400,实际 {body['code']}"
return body["msg"]
@step("录入正常成绩")
def _():
body = call("POST", "/scores", json={"stu_id": ctx["stu_a"], "exam_seq": 1, "score": 88, "exam_name": "自检一考"})
assert body["data"]["warning"] is False
return f"均分 {body['data']['avg_score']}"
@step("录入低分成绩应触发红线预警")
def _():
body = call("POST", "/scores", json={"stu_id": ctx["stu_a"], "exam_seq": 2, "score": 45, "exam_name": "自检二考"})
data = body["data"]
assert data["warning"] is True, "没触发预警"
assert data["record"]["is_warning"] is True
return data["warning_msg"]
@step("预警会写进学生备注")
def _():
body = call("GET", f"/students/{ctx['stu_a']}")
remark = body["data"]["remark"] or ""
assert "成绩预警" in remark, f"备注里没有预警:{remark}"
return remark[:60]
@step("同一序次重复录入应提示改用修改")
def _():
body = call(
"POST", "/scores", expect_code=None,
json={"stu_id": ctx["stu_a"], "exam_seq": 2, "score": 70},
)
assert body["code"] == 409, f"期望 409,实际 {body['code']} {body['msg']}"
return body["msg"]
@step("修改成绩后预警标记自动撤销")
def _():
rows = call("GET", f"/scores?stu_id={ctx['stu_a']}&exam_seq=2")["data"]["items"]
assert rows, "没查到第 2 次考核成绩"
score_id = rows[0]["id"]
body = call("PUT", f"/scores/{score_id}", json={"score": 91})
assert body["data"]["warning"] is False, f"分数改到 91 分还在预警:{body['data']}"
return f"均分更新为 {body['data']['avg_score']}"
@step("批量录入同一学生多次成绩")
def _():
body = call(
"POST", "/scores/batch",
json={"stu_id": ctx["stu_b"], "scores": [
{"stu_id": ctx["stu_b"], "exam_seq": 1, "score": 55},
{"stu_id": ctx["stu_b"], "exam_seq": 2, "score": 92},
{"stu_id": ctx["stu_b"], "exam_seq": 3, "score": 58},
]},
)
data = body["data"]
assert data["count"] == 3
assert data["warning_count"] == 2, f"应预警 2 条,实际 {data['warning_count']}"
return body["msg"]
@step("登记就业:只填开放时间 -> 学生状态变「进入就业」")
def _():
body = call(
"POST", "/employments",
json={"stu_id": ctx["stu_a"], "open_date": "2026-09-10", "company": "自检科技", "salary": 12000},
)
data = body["data"]
assert data["status_changed"] is True, f"状态没变化:{data}"
assert data["to_status_text"] == "进入就业", data
ctx["emp_id"] = data["employment"]["id"]
return body["msg"]
@step("补填 offer 时间 -> 学生状态变「已就业」")
def _():
body = call(
"PUT", f"/employments/{ctx['emp_id']}",
json={"offer_date": "2026-09-25"},
)
data = body["data"]
assert data["to_status_text"] == "已就业", data
assert data["employment"]["duration_days"] == 15, data["employment"]["duration_days"]
return body["msg"]
@step("offer 时间早于开放时间应被拒绝")
def _():
body = call("PUT", f"/employments/{ctx['emp_id']}", expect_code=None, json={"offer_date": "2026-09-01"})
assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}"
return body["msg"]
@step("只填 offer 不填开放时间应被拒绝(否则就业时长算不出来)")
def _():
body = call(
"POST", "/employments", expect_code=None,
json={"stu_id": ctx["stu_b"], "offer_date": "2026-09-20", "company": "X", "salary": 9000},
)
assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}"
return body["msg"]
@step("学生转班 -> 就业表冗余班级同步")
def _():
classes = call("GET", "/classes?page_size=100")["data"]["items"]
other = next(c for c in classes if c["id"] != ctx["class_id"])
call("PUT", f"/students/{ctx['stu_a']}", json={"class_id": other["id"]})
emp = call("GET", f"/employments/student/{ctx['stu_a']}")["data"]
assert emp["class_id"] == other["id"], f"冗余班级没同步:{emp['class_id']} != {other['id']}"
ctx["other_class_id"] = other["id"]
return f"已同步到「{emp['class_name']}」"
@step("删除就业记录 -> 学生状态回退到「在读」")
def _():
body = call("DELETE", f"/employments/{ctx['emp_id']}")
assert body["data"]["status_changed"] is True
student = call("GET", f"/students/{ctx['stu_a']}")["data"]
assert student["status"] == 1, student
return body["msg"]
@step("有学生的班级不能被删")
def _():
body = call("DELETE", f"/classes/{ctx['other_class_id']}", expect_code=None)
assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}"
return body["msg"]
@step("Excel 模板可下载")
def _():
headers = {"Authorization": f"Bearer {Ctx.token}"}
response = Ctx.client.get("/students/import/template", headers=headers)
assert response.status_code == 200 and len(response.content) > 1000
ctx["template"] = response.content
return f"{len(response.content)} 字节"
@step("Excel 批量导入(3 行有效 + 1 行班级不存在)")
def _():
from io import BytesIO
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.append(["姓名", "性别", "出生日期", "班级编号", "顾问", "学历", "籍贯", "备注"])
classes = call("GET", "/classes?page_size=100")["data"]["items"]
class_no = classes[0]["class_no"]
ws.append([f"导入甲{stamp}", "男", "2004-01-02", class_no, "", "大专", "湖南", "自检"])
ws.append([f"导入乙{stamp}", "女", "2003-02-03", class_no, "", "本科", "湖北", "自检"])
ws.append([f"导入丙{stamp}", "男", "2002-03-04", "", "", "大专", "江西", "自检"])
ws.append([f"导入丁{stamp}", "男", "2002-03-04", "NOT-EXIST-999", "", "大专", "江西", ""])
buffer = BytesIO()
wb.save(buffer)
result = call(
"POST", "/students/import?dry_run=false",
files={"file": ("import.xlsx", buffer.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)["data"]
assert result["success"] == 3, f"应成功 3 行,实际 {result['success']}:{result['errors']}"
assert result["failed"] == 1, f"应失败 1 行,实际 {result['failed']}"
ctx["imported"] = [f"导入甲{stamp}", f"导入乙{stamp}", f"导入丙{stamp}"]
return f"成功 {result['success']},失败 {result['failed']},失败原因:{result['errors'][0]['reason']}"
@step("导入的学生确实入库了")
def _():
data = call("GET", f"/students?keyword=导入&page_size=100")["data"]
assert data["total"] >= 3, f"只查到 {data['total']} 个"
return f"{data['total']} 人"
@step("逻辑删除学生")
def _():
call("DELETE", f"/students/{ctx['stu_b']}")
body = call("GET", f"/students/{ctx['stu_b']}", expect_code=None)
assert body["code"] == 404, f"删除后还能查到:{body}"
return body["msg"]
@step("恢复被删除的学生")
def _():
call("POST", f"/students/{ctx['stu_b']}/restore")
body = call("GET", f"/students/{ctx['stu_b']}")
return f"已恢复 {body['data']['name']}"
return ctx
# ==================================================================== 统计与筛选
def check_statistics(ctx: dict):
print("\n== 第三部分:统计分析与高级筛选 ==")
checks = [
("2.6.1 动态年龄查询 gte", "/statistics/students/by-age?operator=gte&value=24&page_size=100"),
("2.6.1 动态年龄查询 between", "/statistics/students/by-age?operator=between&value=20&value2=24&page_size=100"),
("2.6.1 班级人数与性别分布", "/statistics/classes/overview"),
("2.6.1 年龄分布", "/statistics/students/age-distribution"),
("2.6.2 每考都在 80 分以上", "/statistics/scores/all-above?threshold=80"),
("2.6.2 不及格 2 次以上", "/statistics/scores/failures?threshold=60&min_times=2"),
("2.6.2 各班各场平均分(降序)", "/statistics/scores/class-average?order=desc"),
("2.6.2 各班各场平均分(升序)", "/statistics/scores/class-average?order=asc&exam_seq=1"),
("2.6.3 薪资 Top 5", "/statistics/employment/top-salary?top_n=5"),
("2.6.3 每人就业时长", "/statistics/employment/durations"),
("2.6.3 各班平均就业时长", "/statistics/employment/class-avg-duration"),
("2.7.2 成绩波动 Top 5(标准差)", "/statistics/scores/volatility?top_n=5&metric=stddev"),
("2.7.2 成绩波动 Top 5(最大分差)", "/statistics/scores/volatility?top_n=5&metric=range"),
("2.7.2 班级就业漏斗", "/statistics/employment/funnel"),
("总览", "/statistics/overview"),
]
for name, path in checks:
path_local, name_local = path, name
@step(name_local)
def _(p=path_local):
body = call("GET", p)
data = body["data"]
count = len(data) if isinstance(data, list) else len(data.get("items", [])) if isinstance(data, dict) else "-"
return f"code={body['code']} 返回 {count} 项"
# ---------------- 高级筛选
@step("2.7.1 需求文档原始示例(年龄+性别+嵌套 OR 薪资/班级)")
def _():
body = call("POST", "/advanced/query", json={
"model": "student",
"rules": [
{"field": "age", "operator": ">", "value": 25},
{"field": "gender", "operator": "=", "value": "男"},
{"logic": "OR", "sub_rules": [
{"field": "salary", "operator": ">=", "value": 15000},
{"field": "class_name", "operator": "like", "value": "Java"},
]},
],
"order_by": "salary", "order": "desc", "page": 1, "page_size": 10,
})
data = body["data"]
assert data["explain"], "没有返回 SQL 说明"
return f"命中 {data['total']} 条"
@step("2.7.1 嵌套两组 AND/OR 三层")
def _():
body = call("POST", "/advanced/query", json={
"model": "student",
"rules": [
{"logic": "AND", "sub_rules": [
{"field": "age", "operator": "between", "value": [19, 26]},
{"logic": "OR", "sub_rules": [
{"field": "education", "operator": "in", "value": ["大专", "本科"]},
{"logic": "AND", "sub_rules": [
{"field": "major", "operator": "like", "value": "软件"},
{"field": "gender", "operator": "=", "value": 2},
]},
]},
]},
],
"page_size": 5,
})
return f"命中 {body['data']['total']} 条"
@step("2.7.1 操作符 != / not_like / is_null / not_null / not_in")
def _():
total = 0
for rule in [
{"field": "gender", "operator": "!=", "value": "男"},
{"field": "major", "operator": "not_like", "value": "软件"},
{"field": "remark", "operator": "is_null"},
{"field": "phone", "operator": "not_null"},
{"field": "education", "operator": "not_in", "value": ["中专"]},
]:
body = call("POST", "/advanced/query", json={"model": "student", "rules": [rule], "page_size": 1})
total += body["data"]["total"]
return f"五种操作符均可用,命中数合计 {total}"
@step("2.7.1 非法字段给出可用字段提示")
def _():
body = call("POST", "/advanced/query", expect_code=422, json={
"model": "student", "rules": [{"field": "salary2", "operator": ">", "value": 1}]
})
assert "可用字段" in body["msg"]
return body["msg"][:50]
@step("2.7.1 非法操作符被拒")
def _():
return call("POST", "/advanced/query", expect_code=422, json={
"model": "student", "rules": [{"field": "age", "operator": "; drop table", "value": 1}]
})["msg"][:50]
@step("2.7.1 嵌套过深被拒")
def _():
rule = {"field": "age", "operator": ">", "value": 1}
for _i in range(10):
rule = {"logic": "AND", "sub_rules": [rule]}
body = call("POST", "/advanced/query", expect_code=422, json={"model": "student", "rules": [rule]})
return body["msg"][:50]
@step("2.7.2 通用聚合:按班级统计人数/平均分/最高分")
def _():
body = call("POST", "/advanced/aggregate", json={
"model": "score", "group_by": ["class_name"],
"metrics": [{"func": "count", "alias": "成绩数"},
{"func": "avg", "field": "score", "alias": "平均分"},
{"func": "max", "field": "score", "alias": "最高分"}],
"order_by": "平均分", "order": "desc", "limit": 10,
})
return f"{len(body['data']['rows'])} 行,列:{body['data']['columns']}"
@step("2.7.2 聚合 + HAVING 过滤")
def _():
body = call("POST", "/advanced/aggregate", json={
"model": "employment", "group_by": ["class_name"],
"metrics": [{"func": "count", "alias": "就业人数"},
{"func": "avg", "field": "salary", "alias": "平均薪资"}],
"having": [{"field": "平均薪资", "operator": ">", "value": 13000}],
"order_by": "平均薪资", "order": "desc", "limit": 10,
})
assert all(r["平均薪资"] > 13000 for r in body["data"]["rows"])
return f"{len(body['data']['rows'])} 个班达标"
# ---------------- 权限
@step("只读账号不能写")
def _():
admin_token = Ctx.token
Ctx.token = None
viewer = call("POST", "/auth/login", json={"username": "viewer", "password": "viewer123"})["data"]["access_token"]
token = Ctx.token
Ctx.token = viewer
body = call("POST", "/students", expect_code=None, json={"name": "越权", "gender": 1, "age": 20})
Ctx.token = admin_token
assert body["code"] == 403, f"期望 403,实际 {body['code']} {body['msg']}"
return body["msg"]
def cleanup_selftest() -> None:
"""删掉自检造出来的业务数据,避免污染演示库。
自检用的名字都带 `自检` / `导入` 前缀 + 当天日期戳(如 自检学生A0916),
这里按「前缀 + 日期戳」精确匹配删除,不会误伤正常的演示数据。
走 ORM 而不是 HTTP:要按外键顺序连删 成绩/就业/学生/班级/老师/顾问 + 中间表,
用 SQL 一次搞定最可靠。导入失败(比如脚本被拷到别的机器上跑)只提示一句,不算测试失败。
"""
stamp = date.today().strftime("%m%d")
# 以 `python tests/api_check.py` 方式运行时,sys.path[0] 是 tests/,
# 直接 import app 会失败 —— 这里把项目根补进搜索路径。
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
try:
from sqlalchemy import or_
from app.core.database import SessionLocal
from app.model import Advisor, Clazz, Employment, Score, Student, Teacher, class_teachers
except Exception as exc: # noqa: BLE001
print(f" [SKIP] 连不上数据库,跳过清理(要清理请跑 seed_data.py --reset):{exc}")
return
def name_hit(model):
return or_(model.name.like(f"%自检%{stamp}%"), model.name.like(f"%导入%{stamp}%"))
counts: dict[str, int] = {}
with SessionLocal() as db:
stu_ids = [r[0] for r in db.query(Student.id).filter(name_hit(Student)).all()]
cls_ids = [r[0] for r in db.query(Clazz.id).filter(name_hit(Clazz)).all()]
tea_ids = [r[0] for r in db.query(Teacher.id).filter(name_hit(Teacher)).all()]
adv_ids = [r[0] for r in db.query(Advisor.id).filter(name_hit(Advisor)).all()]
if stu_ids:
counts["成绩"] = db.query(Score).filter(Score.stu_id.in_(stu_ids)).delete(synchronize_session=False)
counts["就业"] = db.query(Employment).filter(Employment.stu_id.in_(stu_ids)).delete(
synchronize_session=False
)
counts["学生"] = db.query(Student).filter(Student.id.in_(stu_ids)).delete(synchronize_session=False)
if cls_ids:
db.execute(class_teachers.delete().where(class_teachers.c.class_id.in_(cls_ids)))
counts["班级"] = db.query(Clazz).filter(Clazz.id.in_(cls_ids)).delete(synchronize_session=False)
if tea_ids:
db.execute(class_teachers.delete().where(class_teachers.c.teacher_id.in_(tea_ids)))
counts["老师"] = db.query(Teacher).filter(Teacher.id.in_(tea_ids)).delete(synchronize_session=False)
if adv_ids:
counts["顾问"] = db.query(Advisor).filter(Advisor.id.in_(adv_ids)).delete(synchronize_session=False)
db.commit()
hit = ",".join(f"{k} {v} 条" for k, v in counts.items() if v)
print(f" [清理] 自检数据已删除:{hit or '无残留'}")
def main():
global BASE
parser = argparse.ArgumentParser()
parser.add_argument("--base", default=BASE)
parser.add_argument("--keep", action="store_true", help="保留自检造的数据(默认跑完会清掉)")
args = parser.parse_args()
BASE = args.base
Ctx.client = httpx.Client(base_url=BASE, timeout=60)
print("\n== 登录 ==")
try:
body = call("POST", "/auth/login", json={"username": "admin", "password": "admin123"})
Ctx.token = body["data"]["access_token"]
print(f" [PASS] 管理员登录:{body['msg']}")
PASS.append(("管理员登录", body["msg"]))
except Exception as exc: # noqa: BLE001
print(f" [FAIL] 登录失败:{exc}")
FAIL.append(("管理员登录", str(exc)))
sys.exit(1)
check_reads()
ctx = check_write_flow()
check_statistics(ctx)
if not args.keep:
print("\n== 收尾清理 ==")
cleanup_selftest()
print("\n" + "=" * 70)
print(f"通过 {len(PASS)} 项,失败 {len(FAIL)} 项")
if FAIL:
print("\n失败明细:")
for title, reason in FAIL:
print(f" - {title}\n {reason}")
sys.exit(2)
print("全部通过 ✅")
if __name__ == "__main__":
main()