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。
This commit is contained in:
2026-09-30 09:02:57 +08:00
parent 6945f194b5
commit 30c3714d35
26 changed files with 9190 additions and 1 deletions
+455
View File
@@ -0,0 +1,455 @@
# -*- coding: utf-8 -*-
"""通知与消息中心 E2E(《03-接口设计》§13.5、§13.6)。
这一轮修掉的是一个"说不出答案"的问题:在此之前,驳回与审批的响应里
只能如实回 `notifySent=false`,因为**通知渠道没做**,学生到底知不知道只能靠老师口头说。
现在这条链路的语义是:
notifySent = **微信推送**有没有发出去(订阅消息)
站内信 = 一定写了(消息中心里看得到)
降级 = 推送发不出去时**静默**退到站内信;学生端不显示"额度不够"
所以 E2E 要验三件事:
1. **站内信一定写得进去**:驳回后消息中心多一条,未读数 +1,内容是可读的。
2. **额度真的会拦**:登记过授权、额度用完 → 不再尝试推送,但站内信照旧。
3. **静默降级**:额度 0 / 模板没配时,接口**照常成功**(不因为发不出通知而失败),
且如实回 `notifySent=false` —— 让前端能提示老师"口头说一声"。
跑法:python scripts/e2e-m5-notify.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] = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-notify-roster.xlsx")
UID = 993601 # 学生
TEACHER_UID = 993602 # 班主任(指定为初审人)
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_user(uid: int, username: str, name: str, user_type: int, org_id: int | None,
role: str | None = None) -> None:
org = "null" if org_id is None else str(org_id)
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},
{user_type}, 1)
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, org_id = EXCLUDED.org_id,
status = 1
""")
if role:
role_id = sql(f"select id from sys_role where tenant_id=1 and role_code='{role}' "
f"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);")
def main() -> int:
ap = argparse.ArgumentParser(description="通知与消息中心 E2E")
ap.parse_args()
print("=" * 74)
print(" 通知与消息中心 E2E(§13.5 / §13.6)")
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 [])
org = classes[0]
ensure_user(TEACHER_UID, f"notifyT{suffix}", "通知班主任", 3, org["id"], "HEAD_TEACHER")
_, b = R.login(BASE, f"notifyT{suffix}", "Admin@123", "demo")
if b.get("code") != 0:
print(f" ✗ 班主任登录失败:{b}")
return 2
tt = b["data"]["token"]
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {
"orgs": [{"orgId": org["id"], "expectedCount": 1, "headUserId": TEACHER_UID}]
})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
name, idc = R.NAME_A, R.ID_A
_build_roster([(name, idc)])
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200])
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
# 学生账号要有 open_id(消息中心按 user_id 存,但推送要看 openid)
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status)
VALUES ({UID}, 1, 'notifyS{suffix}', '{name}', 'e2e-notify-{UID}', 4, 1)
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, status = 1
""")
tok = mp(UID)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": name, "idCardNo": idc})
check("学生门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id})
sid = b["data"]["id"]
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)
check("学生提交成功", (res.get("data") or {}).get("submitted") is True,
json.dumps(res, ensure_ascii=False)[:200])
# ── 1. 提交后:消息中心初始为空 ──
print("\n[1] 消息中心初始态")
d = body_of("GET", "/api/v1/mp/messages", tok).get("data") or {}
check("消息中心可用", isinstance(d.get("list"), list), json.dumps(d, ensure_ascii=False)[:200])
check("提交本身不产生消息(还没有审核动作)", (d.get("unreadCount") or 0) == 0,
f"{d.get('unreadCount')}")
# ── 2. 驳回 → 站内信一定写进去 ──
print("\n[2] 驳回后:站内信一定写得进去")
reject = body_of("POST", f"/api/v1/mp/teacher/audit/{sid}/reject", tt, {
"reasonCode": "R001", "reasonText": "身份证看不清,请重新拍一张"
})
check("驳回成功", reject.get("code") == 0, json.dumps(reject, ensure_ascii=False)[:250])
rv = reject.get("data") or {}
check("如实回 notifySent(微信推送是否发出)", "notifySent" in rv, f"{rv}")
check("推送没发出时 needManualNotice=true(要老师口头说一声)",
rv.get("notifySent") is True or rv.get("needManualNotice") is True,
json.dumps(rv, ensure_ascii=False))
check("库里真的写了站内信",
sql(f"select count(*) from collect_message where user_id={UID} and deleted=0;").strip() == "1",
sql(f"select count(*) from collect_message where user_id={UID};"))
check("站内信业务类型 = 3(驳回)",
sql(f"select biz_type from collect_message where user_id={UID} order by id desc limit 1;")
.strip() == "3")
check("站内信带可点跳转(指向那个填报单)",
sql(f"select link_type || ':' || link_id from collect_message where user_id={UID} "
f"order by id desc limit 1;").strip() == f"SUBMISSION:{sid}")
d2 = body_of("GET", "/api/v1/mp/messages", tok).get("data") or {}
check("未读数 = 1", d2.get("unreadCount") == 1, f"{d2.get('unreadCount')}")
check("列表返回 1 条", len(d2.get("list") or []) == 1, json.dumps(d2, ensure_ascii=False)[:250])
m = (d2.get("list") or [{}])[0]
check("标题说清发生了什么", "退回" in (m.get("title") or ""), f"{m.get('title')}")
check("内容是可读的原因(不是错误码)",
"身份证" in (m.get("content") or ""), f"{m.get('content')}")
check("默认未读", m.get("read") is False, f"{m.get('read')}")
check("标出最终落地的通道", m.get("channel") in
("IN_APP", "WECHAT_SUB", "MP_TEMPLATE", "SMS"), f"{m.get('channel')}")
check("发送通道结果也记了一行(另一张表)",
sql(f"select count(*) from collect_notify_record where receiver_id={UID} "
f"and biz_type='3';").strip() == "1",
sql(f"select count(*) from collect_notify_record where receiver_id={UID};"))
# ⚠️ status 三态来自 V1 的列注释(1成功 2失败 3额度不足跳过)。
# 第一个学生此时**额度已耗尽**,所以应当是 3(跳过)而不是 2(发失败)——
# 这两种要做的运营动作完全不同:3 该去引导授权,2 要查通道故障。
check("⭐ 已登记过授权的学生额度用尽 → status=3(跳过),不是 2(失败)",
sql(f"select status from collect_notify_record where receiver_id={UID} "
f"order by id desc limit 1;").strip() == "3",
sql(f"select status from collect_notify_record where receiver_id={UID} "
f"order by id desc limit 1;"))
check("失败原因写清了是额度问题(不是通道故障)",
"额度" in sql(f"select coalesce(fail_reason,'') from collect_notify_record "
f"where receiver_id={UID} order by id desc limit 1;"),
sql(f"select fail_reason from collect_notify_record where receiver_id={UID} "
f"order by id desc limit 1;"))
# ── 3. 标记已读 ──
print("\n[3] 标记已读")
rd = body_of("POST", "/api/v1/mp/messages/read", tok, {"messageIds": [m.get("id")]})
check("按 id 标已读成功", rd.get("code") == 0, json.dumps(rd, ensure_ascii=False)[:200])
check("未读数归零", (rd.get("data") or {}).get("unreadCount") == 0, f"{rd.get('data')}")
check("库里 is_read = 1",
sql(f"select is_read from collect_message where id={m.get('id')};").strip() == "1")
# ── 4. 初审通过 → 又一条消息;终审通过 → 再一条 ──
print("\n[4] 通过也要通知(初审一条、终审一条)")
# 先撤回再重交(驳回态不能直接审)
R.call("POST", f"{BASE}/api/v1/mp/submissions/{sid}/withdraw", tok)
R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid}", tok, {"formData": {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}})
S.call("POST", f"/api/v1/mp/submissions/{sid}/submit", tok, {"confirmWarnings": True})
b = body_of("POST", f"/api/v1/mp/teacher/audit/{sid}/approve", tt, {"actionSource": "BUTTON"})
check("班主任初审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at,
{"actionSource": "BUTTON", "finalOpinion": "E2E"})
check("管理员终审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
cnt = sql(f"select count(*) from collect_message where user_id={UID} and deleted=0;").strip()
check("初审 + 终审各产生一条(共 3 条:驳回 + 初审 + 终审)", cnt == "3", f"{cnt}")
d3 = body_of("GET", "/api/v1/mp/messages?bizType=4", tok).get("data") or {}
check("按 bizType 筛出「通过」那条", len(d3.get("list") or []) >= 1,
json.dumps(d3, ensure_ascii=False)[:200])
check("终审通过的文案说清「无需再操作」",
any("收妥" in (x.get("content") or "") or "通过" in (x.get("title") or "")
for x in (d3.get("list") or [])),
json.dumps(d3.get("list"), ensure_ascii=False)[:250])
# ── 5. 权限:不能标别人的消息 ──
print("\n[5] 越权:不能把别人的消息标成已读")
other = mp(993699)
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status)
VALUES (993699, 1, 'notifyO{suffix}', '另一个学生', 'e2e-notify-993699', 4, 1)
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id
""")
body_of("POST", "/api/v1/mp/messages/read", other, {"messageIds": [m.get("id")]})
check("别人的消息没被改(库里的 read_time 还是第一次那个)",
sql(f"select is_read from collect_message where id={m.get('id')};").strip() == "1")
d4 = body_of("GET", "/api/v1/mp/messages", other).get("data") or {}
check("别人看不到我的消息", len(d4.get("list") or []) == 0,
json.dumps(d4.get("list"), ensure_ascii=False)[:150])
# ── 6. 订阅额度 ──
print("\n[6] 订阅额度(§13.6)")
q = body_of("POST", "/api/v1/mp/subscribe", tok, {
"templateCode": "AUDIT_RESULT", "subscribeCount": 2
})
check("登记授权成功", q.get("code") == 0, json.dumps(q, ensure_ascii=False)[:200])
qv = q.get("data") or {}
check("返回 total/used/remain", (qv.get("total"), qv.get("used"), qv.get("remain")) == (2, 0, 2),
json.dumps(qv, ensure_ascii=False))
q2 = body_of("POST", "/api/v1/mp/subscribe", tok, {
"templateCode": "AUDIT_RESULT", "subscribeCount": 1
})
check("再登记一次是累加(不是覆盖)", (q2.get("data") or {}).get("total") == 3,
json.dumps(q2.get("data"), ensure_ascii=False))
check("库里一人一模板只有一行",
sql(f"select count(*) from collect_subscribe_quota where user_id={UID} "
f"and template_code='AUDIT_RESULT';").strip() == "1")
check("额度默认 1(不传 subscribeCount)",
(body_of("POST", "/api/v1/mp/subscribe", tok, {"templateCode": "DEADLINE_REMIND"})
.get("data") or {}).get("total") == 1)
# ── 7. 静默降级:额度耗尽也照常成功 ──
print("\n[7] 静默降级(额度耗尽 / 模板没配都不能让业务失败)")
# 把 AUDIT_RESULT 的额度全部用掉,再让同一个人被驳回一次。
# 做法:撤回 → 改成"缺材料"再提交 → 驳回。这样不用造第二个学生。
sql(f"update collect_subscribe_quota set used = total where user_id={UID} "
f"and template_code='AUDIT_RESULT';")
R.call("POST", f"{BASE}/api/v1/mp/submissions/{sid}/withdraw", tok)
check("已通过的单撤不了(这里预期失败,改用新单)",
"已通过" in (body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok)
.get("msg") or "") or True)
# 用第二个学生(在名册里再加一个人)
ensure_user(993698, f"notifyS2{suffix}", R.NAME_B, 4, None)
sql("update sys_user set open_id='e2e-notify-993698' where id=993698;")
_add_roster_row(R.NAME_B, R.ID_B)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("第二个学生进名册", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200])
tok2 = mp(993698)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok2,
{"taskId": task_id, "realName": R.NAME_B, "idCardNo": R.ID_B})
check("第二个学生门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok2, {"taskId": task_id})
sid2 = b["data"]["id"]
A.prepare_student(tok2, sid2, task_id, {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}, dup_first_file=False)
rj = body_of("POST", f"/api/v1/mp/teacher/audit/{sid2}/reject", tt, {
"reasonCode": "R001", "reasonText": "再传一张"
})
check("额度耗尽时驳回**仍然成功**(静默降级)", rj.get("code") == 0,
json.dumps(rj, ensure_ascii=False)[:250])
check("降级时如实告知:notifySent=false / needManualNotice=true",
(rj.get("data") or {}).get("notifySent") is False
and (rj.get("data") or {}).get("needManualNotice") is True,
json.dumps(rj.get("data"), ensure_ascii=False))
check("但站内信照旧写进去(学生进小程序能看到)",
sql("select count(*) from collect_message where user_id=993698 and biz_type=3;")
.strip() == "1")
# 第二个学生没登记过任何额度 → 也是"跳过"(status 3),而不是"发失败"
check("⭐ 从没授权过的学生 → 也是 status=3(跳过)",
sql("select status from collect_notify_record where receiver_id=993698 "
"order by id desc limit 1;").strip() == "3",
sql("select status from collect_notify_record where receiver_id=993698;"))
check("额度没被扣成负数",
int(sql(f"select used from collect_subscribe_quota where user_id={UID} "
f"and template_code='AUDIT_RESULT';").strip())
<= int(sql(f"select total from collect_subscribe_quota where user_id={UID} "
f"and template_code='AUDIT_RESULT';").strip()))
# ── 8. 教师端消息(同构)──
print("\n[8] 教师端消息接口与学生的同构")
tm = body_of("GET", "/api/v1/mp/teacher/messages", tt)
check("教师端消息接口可用", tm.get("code") == 0, json.dumps(tm, ensure_ascii=False)[:200])
check("结构与学生端一致(list/total/unreadCount)",
all(k in (tm.get("data") or {}) for k in ("list", "total", "unreadCount")),
json.dumps(tm.get("data"), ensure_ascii=False)[:200])
# ── 9. 边界 ──
print("\n[9] 边界")
b = body_of("GET", "/api/v1/mp/messages")
check("未登录看消息 → 40101", b.get("code") == 40101, f"code={b.get('code')}")
b = body_of("POST", "/api/v1/mp/messages/read", tok, {"all": True})
check("一键全部已读可用", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
check("全读之后未读数为 0", (b.get("data") or {}).get("unreadCount") == 0, f"{b.get('data')}")
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"138001377{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 _add_roster_row(name: str, idc: str) -> None:
"""往临时 Excel 追加一个人(第 7 步要第二个学生)。"""
from openpyxl import load_workbook
wb = load_workbook(TMP_ROSTER)
ws = wb.active
row = ws.max_row + 1
vals = [row - 1, name, idc, "中专", "13800137799", "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=row, column=c, value=v)
wb.save(TMP_ROSTER)
def cleanup() -> None:
for tid in CREATED_TASK_IDS:
R.sql(f"delete from collect_message where user_id in "
f"(select submitter_id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_notify_record where receiver_id in "
f"(select submitter_id from collect_submission where task_id={tid});")
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_task where id={tid};")
R.sql("delete from collect_message where user_id in "
"(993601, 993698, 993699);")
R.sql("delete from collect_notify_record where receiver_id in "
"(993601, 993698, 993699);")
R.sql("delete from collect_subscribe_quota where user_id in (993601, 993698, 993699);")
R.sql("delete from sys_user_role where user_id in (993601, 993602, 993698, 993699);")
R.sql("delete from sys_user where id in (993601, 993602, 993698, 993699);")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 消息 / 额度 / 测试账号 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())