#!/usr/bin/env python3 # -*- coding: utf-8 -*- """默认测试套件:合同到期智能预警与续聘辅助。 设计原则: - 仅用标准库(不依赖 pytest / jinja2),CI 与本地均能直接 `python tests/run_tests.py` 跑 - 用 sqlite3 ":memory:" 隔离 DB,不污染仓库 data/ 目录 - 覆盖:util.date_util 纯函数 / util.constants 版本对齐 / repository 新增功能 (parse_sandbox_card_performance + upsert_performance_record)/ scanner 分级 / task_generator 规则版 / html_renderer 降级路径 - 退出码:0 = 全部通过,1 = 有失败 - -v 开关:详细输出每个用例的状态 release.ps1 在打 tag 前会自动跑 `python tests/run_tests.py -v`,任何一个 test_* 返回 (False, msg) 即 abort 发布。 """ from __future__ import annotations import argparse import json import os import re import shutil import sqlite3 import sys import tempfile import traceback from datetime import date from pathlib import Path from types import SimpleNamespace from typing import Any, Callable # 让 scripts/ 下的模块可导入 _REPO_ROOT = Path(__file__).resolve().parent.parent _SCRIPTS_DIR = _REPO_ROOT / "scripts" sys.path.insert(0, str(_SCRIPTS_DIR)) # ---------- 测试基础设施 ---------- # 测试结果收集器 _RESULTS: list[tuple[str, bool, str]] = [] _VERBOSE = False def _run(name: str, fn: Callable[[], tuple[bool, str]]) -> None: """运行单个 test_*,记录结果。""" try: ok, msg = fn() except Exception as e: # 测试本身抛异常也算失败 tb = traceback.format_exc(limit=3) ok, msg = False, f"exception: {e}\n{tb}" _RESULTS.append((name, ok, msg)) if _VERBOSE or not ok: marker = "PASS" if ok else "FAIL" print(f" [{marker}] {name}: {msg}", flush=True) def _make_conn() -> sqlite3.Connection: """建一张 in-memory DB,应用所有 DDL + 迁移(不加载 seed)。""" from db.connection import DDL_STATEMENTS from db.migrations import apply_all conn = sqlite3.connect(":memory:") conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON;") for ddl in DDL_STATEMENTS: conn.execute(ddl) apply_all(conn) conn.commit() return conn class _FakeRow: """Mock 一行 sqlite3.Row:同时支持下标访问 (r["x"]) 和属性访问 (r.x)。 production 代码里 task_generator.suggest() 用的是 r["moral_teaching_score"] 下标语法,所以 fake row 必须也支持。 """ __slots__ = ("year", "performance_score", "moral_teaching_score", "research_score", "teaching_score") def __init__(self, year: int, score: float, moral: float): self.year = year self.performance_score = score self.moral_teaching_score = moral self.research_score = score self.teaching_score = score def __getitem__(self, key: str) -> Any: return getattr(self, key) def _fake_perf(year: int, score: float, moral: float | None = None) -> _FakeRow: """构造一个 performance_records-like 行(用于纯函数测试,不入 DB)。""" m = moral if moral is not None else score return _FakeRow(year=year, score=score, moral=m) def _insert_stub_contract(conn: sqlite3.Connection, contract_id: str) -> None: """插入一份占位合同(performance_records 的 FK 目标)。""" conn.execute( """INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status, last_material_id, renewed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '可续聘', NULL, NULL)""", (contract_id, f"E-{contract_id}", f"占位-{contract_id}", "U-TEST", "测试系", "讲师", "正式", "2022-01-01", "2099-12-31", "2022-07-01"), ) conn.commit() # ---------- util.date_util ---------- def test_parse_iso_date() -> tuple[bool, str]: from util.date_util import parse_iso_date cases = [ ("2024-03-15", date(2024, 3, 15)), ("2024-03-15T08:30:00", date(2024, 3, 15)), (" 2024-03-15 ", date(2024, 3, 15)), (date(2024, 3, 15), date(2024, 3, 15)), # 已为 date ] for inp, want in cases: got = parse_iso_date(inp) if got != want: return False, f"parse_iso_date({inp!r}) = {got}, want {want}" return True, "4 cases ok" def test_days_to() -> tuple[bool, str]: from util.date_util import days_to if days_to(date(2024, 3, 15), today=date(2024, 1, 1)) != 74: return False, "days_to 正向计算错" if days_to(date(2023, 12, 31), today=date(2024, 1, 1)) != -1: return False, "days_to 已过期返回负数错" # 默认 today=今天 if days_to(date(2099, 1, 1)) <= 0: return False, "days_to 默认 today 应为今天" return True, "正/负/默认 today 三种场景" def test_classify_alert() -> tuple[bool, str]: from util.date_util import classify_alert t = (90, 60, 30) cases = [ (100, None), # 超出预警范围 (91, None), # 边界外 (90, "yellow"), (61, "yellow"), (60, "orange"), (31, "orange"), (30, "red"), (1, "red"), (0, "red"), (-5, "red"), # 已过期仍属 red ] for days, want in cases: got = classify_alert(days, t) if got != want: return False, f"classify_alert({days}) = {got}, want {want}" return True, "10 边界 + 越界场景" def test_month_match() -> tuple[bool, str]: from util.date_util import month_match d = date(2024, 3, 15) if not month_match(d, "2024-03"): return False, "同月应命中" if month_match(d, "2024-04"): return False, "不同月不应命中" if not month_match(d, ""): return False, "空 month_str 应命中全部" if month_match(d, "garbage"): return False, "非法 month_str 不应命中" return True, "命中 / 未命中 / 空 / 非法 四种" # ---------- util.constants 版本对齐(POLICY-RELEASE-001) ---------- def test_skill_md_version_alignment() -> tuple[bool, str]: """POLICY-RELEASE-001:SKILL.md version == SKILL_VERSION == CHANGELOG 顶部版本""" from util.constants import SKILL_VERSION skill_md = (_REPO_ROOT / "SKILL.md").read_text(encoding="utf-8") m = re.search(r'(?m)^version:\s*["\']?(\d+\.\d+\.\d+)', skill_md) if not m: return False, "SKILL.md 缺顶层 version: X.Y.Z" md_ver = m.group(1) if md_ver != SKILL_VERSION: return False, f"SKILL.md={md_ver} != SKILL_VERSION={SKILL_VERSION}" # CHANGELOG 顶部应有 ## X.Y.Z cl = (_REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") cl_m = re.search(r'(?m)^##\s+(\d+\.\d+\.\d+)', cl) if not cl_m: return False, "CHANGELOG.md 缺顶层 ## X.Y.Z" cl_ver = cl_m.group(1) if cl_ver != SKILL_VERSION: return False, f"CHANGELOG 顶部={cl_ver} != SKILL_VERSION={SKILL_VERSION}" return True, f"SKILL.md={md_ver} == CHANGELOG={cl_ver} == SKILL_VERSION={SKILL_VERSION}" def test_skill_md_slug_alignment() -> tuple[bool, str]: """SKILL.md metadata.openclaw.slug == SKILL_SLUG""" from util.constants import SKILL_SLUG skill_md = (_REPO_ROOT / "SKILL.md").read_text(encoding="utf-8") m = re.search(r'(?m)^\s*slug:\s*["\']?([a-z0-9_-]+)', skill_md) if not m: return False, "SKILL.md 缺 metadata.openclaw.slug" md_slug = m.group(1) if md_slug != SKILL_SLUG: return False, f"SKILL.md slug={md_slug} != SKILL_SLUG={SKILL_SLUG}" return True, f"slug={md_slug}" def test_data_management_actions() -> tuple[bool, str]: """数据管理动作必须绑定业务表,并映射到实际 CLI 命令。""" actions_path = _REPO_ROOT / "assets" / "actions.json" actions = json.loads(actions_path.read_text(encoding="utf-8"))["actions"] by_id = {action["id"]: action for action in actions} expected = { "scan-expiring-contracts": "toolbar", "collect-from-platform": "toolbar", "generate-renewal-task": "row", "dispatch-task-notice": "batch", "mark-contract-renewed": "row", } for action_id, placement in expected.items(): action = by_id.get(action_id) if not action: return False, f"缺少数据管理动作 {action_id}" if placement not in action.get("placements", []): return False, f"{action_id} 缺少 {placement} placement" if "合同台账" not in action.get("bind", {}).get("tables", []): return False, f"{action_id} 未绑定合同台账" if action.get("entrypoint", {}).get("command") != action_id: return False, f"{action_id} CLI command 映射错误" # v1.1.18: download-materials 是 async form action,绑定 download_tasks 队列表 dl = by_id.get("download-materials") if not dl: return False, "缺少 download-materials 动作" if dl.get("executionProfile") != "async": return False, f"download-materials 应为 async,实际 {dl.get('executionProfile')}" if dl.get("interaction") != "form": return False, f"download-materials 应为 form interaction" if "download_tasks" not in dl.get("bind", {}).get("tables", []): return False, f"download-materials 未绑定 download_tasks" schema_props = dl.get("inputSchema", {}).get("properties", {}) if "itemIds" not in schema_props or "pickCount" not in schema_props: return False, f"download-materials inputSchema 缺 itemIds/pickCount: {schema_props}" # v1.1.18: show-config / logs-list 是 sync 运维动作 for sync_id in ("show-config", "logs-list"): a = by_id.get(sync_id) if not a: return False, f"缺少 {sync_id} 动作" if a.get("executionProfile") != "sync": return False, f"{sync_id} 应为 sync" return True, "5 个数据管理动作 + download-materials (async form) + show-config/logs-list (sync) 全部就绪" def test_collect_platform_defaults_to_browser() -> tuple[bool, str]: """平台采集默认显示浏览器;--headless 仅用于已登录自动化。""" from cli.app import build_parser parser = build_parser() default_args = parser.parse_args(["collect-from-platform"]) if default_args.headless: return False, "collect-from-platform 默认不应使用无头模式" headless_args = parser.parse_args(["collect-from-platform", "--headless"]) if not headless_args.headless: return False, "--headless 未启用无头模式" legacy_args = parser.parse_args(["collect-from-platform", "--show-browser"]) if legacy_args.headless: return False, "--show-browser 兼容别名未保留浏览器模式" return True, "默认浏览器模式,--headless 和 --show-browser 行为正确" # ---------- db.repository:v1.1.2 新增的 sandbox card 解析 ---------- def test_parse_sandbox_card_row_format() -> tuple[bool, str]: """v1.1.2:行格式 '2024\\t称职'""" from db import repository as repo conn = _make_conn() _insert_stub_contract(conn, "C001") card_text = "year\tresult\n2023\t优秀\n2024\t称职\n2025\t基本称职\n" n = repo.parse_sandbox_card_performance(conn, "C001", card_text) if n != 3: return False, f"写入 {n} 条,期望 3" rows = repo.list_performance_by_contract(conn, "C001") if len(rows) != 3: return False, f"DB 行数 {len(rows)},期望 3" by_year = {r["year"]: r["performance_score"] for r in rows} expected = {2023: 90.0, 2024: 75.0, 2025: 60.0} for y, want in expected.items(): if by_year.get(y) != want: return False, f"{y} 年分数={by_year.get(y)},期望 {want}" return True, "3 行(优秀/称职/基本称职)解析 + 写入正确" def test_parse_sandbox_card_narrative_format() -> tuple[bool, str]: """v1.1.2:叙述式 '2024年:等次「称职」'""" from db import repository as repo conn = _make_conn() _insert_stub_contract(conn, "C002") card_text = "考核情况:\n2023年:等次「优秀」。\n2024年:等次「称职」。\n2025年:等次「不称职」。\n" n = repo.parse_sandbox_card_performance(conn, "C002", card_text) if n != 3: return False, f"写入 {n} 条,期望 3" rows = repo.list_performance_by_contract(conn, "C002") by_year = {r["year"]: r["performance_score"] for r in rows} expected = {2023: 90.0, 2024: 75.0, 2025: 30.0} for y, want in expected.items(): if by_year.get(y) != want: return False, f"{y} 年分数={by_year.get(y)},期望 {want}" return True, "3 行(优秀/称职/不称职)叙述式解析正确" def test_parse_sandbox_card_empty_and_invalid() -> tuple[bool, str]: """空字符串 / 无匹配应返回 0,不报错""" from db import repository as repo conn = _make_conn() _insert_stub_contract(conn, "C001") if repo.parse_sandbox_card_performance(conn, "C001", "") != 0: return False, "空字符串应返回 0" if repo.parse_sandbox_card_performance(conn, "C001", "没有任何年份/等次信息") != 0: return False, "无匹配应返回 0" rows = repo.list_performance_by_contract(conn, "C001") if rows: return False, "无匹配不应写入任何行" return True, "空 / 无匹配场景" def test_parse_sandbox_card_mixed_formats() -> tuple[bool, str]: """同卡片混用两种格式时按行去重(PK = P--)""" from db import repository as repo conn = _make_conn() _insert_stub_contract(conn, "C003") card_text = ( "2024年:等次「称职」。\n" # 叙述式 "2025\t优秀\n" # 行格式 "2026年:等次「基本称职」\n" ) n = repo.parse_sandbox_card_performance(conn, "C003", card_text) if n != 3: return False, f"混合解析写入 {n} 条,期望 3" return True, "叙述式 + 行格式混合解析" def test_upsert_performance_record_grade_mapping() -> tuple[bool, str]: """grade → score 映射 + ON CONFLICT upsert""" from db import repository as repo conn = _make_conn() _insert_stub_contract(conn, "C001") if not repo.upsert_performance_record(conn, "C001", 2024, "优秀"): return False, "优秀应返回 True" if not repo.upsert_performance_record(conn, "C001", 2024, "优秀"): return False, "upsert 已存在应仍返回 True" if not repo.upsert_performance_record(conn, "C001", 2025, "称职"): return False, "称职应返回 True" if not repo.upsert_performance_record(conn, "C001", 2026, "基本称职"): return False, "基本称职应返回 True" if not repo.upsert_performance_record(conn, "C001", 2027, "不称职"): return False, "不称职应返回 True" if not repo.upsert_performance_record(conn, "C001", 2028, "不合格"): return False, "不合格应返回 True" if repo.upsert_performance_record(conn, "C001", 2029, "未知等次"): return False, "未知等次应返回 False" rows = repo.list_performance_by_contract(conn, "C001") # 期望 5 行:2024/2025/2026/2027/2028 # 第二次 (2024, 优秀) 是 upsert 覆盖不新增;2029 未知等次不入库 if len(rows) != 5: return False, f"DB 行数 {len(rows)},期望 5(upsert 同年不新增;未知等次不入库)" by_year = {r["year"]: r["performance_score"] for r in rows} expected = {2024: 90.0, 2025: 75.0, 2026: 60.0, 2027: 30.0, 2028: 30.0} for y, want in expected.items(): if by_year.get(y) != want: return False, f"{y} 年分数={by_year.get(y)},期望 {want}" # 验证 2024 的分数确实被 upsert 覆盖(不是 30/60/75 等其他值) if by_year[2024] != 90.0: return False, f"upsert 未保持分数:2024={by_year[2024]},want 90.0" return True, "grade 映射 + upsert 幂等 + 未知等次返回 False" # ---------- service.task_generator.suggest 规则版 ---------- def test_suggest_empty_records() -> tuple[bool, str]: """无业绩记录 → observe(数据缺失)""" from service.task_generator import suggest s, r = suggest([]) if s != "observe": return False, f"suggest([]) = {s}, want observe" if "缺失" not in r: return False, f"reason 应提示缺失,实际 {r!r}" return True, "observe + '缺失'" def test_suggest_low_moral() -> tuple[bool, str]: """师德 < 60 → non_renew""" from service.task_generator import suggest rows = [_fake_perf(2024, 85, moral=50)] s, r = suggest(rows) if s != "non_renew": return False, f"suggest(师德 50) = {s}, want non_renew" if "师德" not in r: return False, f"reason 应含「师德」,实际 {r!r}" return True, "non_renew + '师德'" def test_suggest_fail_year() -> tuple[bool, str]: """任意一年考核 < 60 → observe(复核)""" from service.task_generator import suggest rows = [ _fake_perf(2023, 80, moral=85), _fake_perf(2024, 50, moral=85), # 不及格 _fake_perf(2025, 85, moral=85), ] s, r = suggest(rows) if s != "observe": return False, f"suggest(有不及格年) = {s}, want observe" if "复核" not in r and "不合格" not in r: return False, f"reason 应提示不合格/复核,实际 {r!r}" return True, "observe + '不合格'" def test_suggest_renew_upward() -> tuple[bool, str]: """全部 >= 80 且逐年上升 → renew""" from service.task_generator import suggest rows = [ _fake_perf(2023, 80, moral=85), _fake_perf(2024, 85, moral=85), _fake_perf(2025, 90, moral=85), ] s, r = suggest(rows) if s != "renew": return False, f"suggest(全部 80+ 上升) = {s}, want renew" return True, "renew + 上升趋势" def test_suggest_observe_equal_scores_not_renew() -> tuple[bool, str]: """持平不算逐年上升;应回落到 observe""" from service.task_generator import suggest rows = [ _fake_perf(2023, 80, moral=85), _fake_perf(2024, 80, moral=85), _fake_perf(2025, 90, moral=85), ] s, r = suggest(rows) if s != "observe": return False, f"suggest(80,80,90) = {s}, want observe" return True, "观察:持平不等于逐年上升" def test_suggest_observe_default() -> tuple[bool, str]: """达标但非逐年上升 → observe(常规评审)""" from service.task_generator import suggest rows = [ _fake_perf(2023, 85, moral=85), _fake_perf(2024, 80, moral=85), # 回落 _fake_perf(2025, 85, moral=85), ] s, r = suggest(rows) if s != "observe": return False, f"suggest(回落) = {s}, want observe" return True, "observe(常规评审)" # ---------- service.scanner 端到端 ---------- def test_scanner_no_alerts() -> tuple[bool, str]: """所有合同都在 90 天外 → 0 预警""" from service import scanner conn = _make_conn() conn.executemany( """INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status, last_material_id, renewed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '可续聘', NULL, NULL)""", [ ("C-FAR-1", "T001", "张三", "U1", "数学系", "教授", "正式", "2022-01-01", "2099-12-31", "2022-07-01"), ], ) conn.commit() result = scanner.scan(conn, today=date(2024, 1, 15)) if result["scanned_count"] != 1: return False, f"scanned={result['scanned_count']}, want 1" if result["yellow_count"] + result["orange_count"] + result["red_count"] != 0: return False, f"应无预警,实测 yellow={result['yellow_count']} orange={result['orange_count']} red={result['red_count']}" return True, "1 合同全在 90 天外 → 0 预警" def test_scanner_three_levels() -> tuple[bool, str]: """3 份合同分别命中黄/橙/红""" from service import scanner conn = _make_conn() conn.executemany( """INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status, last_material_id, renewed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '可续聘', NULL, NULL)""", [ ("C-YEL", "T001", "黄老师", "U1", "数学系", "教授", "正式", "2022-01-01", "2024-05-15", "2022-07-01"), # 距 2024-02-15 = 90 天 → yellow ("C-ORA", "T002", "橙老师", "U1", "物理系", "讲师", "正式", "2022-01-01", "2024-04-15", "2022-07-01"), # 距 2024-02-15 = 60 天 → orange ("C-RED", "T003", "红老师", "U1", "化学系", "副教授", "正式", "2022-01-01", "2024-03-15", "2022-07-01"), # 距 2024-02-15 = 29 天 → red ], ) conn.commit() result = scanner.scan(conn, today=date(2024, 2, 15)) if result["scanned_count"] != 3: return False, f"scanned={result['scanned_count']}, want 3" if result["yellow_count"] != 1 or result["orange_count"] != 1 or result["red_count"] != 1: return False, (f"分级数 yellow={result['yellow_count']} " f"orange={result['orange_count']} red={result['red_count']}, " f"want 1/1/1") # 验证 alerts 表实际写入 alerts = conn.execute("SELECT contract_id, alert_level FROM alerts").fetchall() by_cid = {a["contract_id"]: a["alert_level"] for a in alerts} if by_cid.get("C-YEL") != "yellow": return False, f"C-YEL 写入等级 {by_cid.get('C-YEL')}, want yellow" if by_cid.get("C-ORA") != "orange": return False, f"C-ORA 写入等级 {by_cid.get('C-ORA')}, want orange" if by_cid.get("C-RED") != "red": return False, f"C-RED 写入等级 {by_cid.get('C-RED')}, want red" return True, "3 合同 → 1 黄 + 1 橙 + 1 红,alerts 表写入正确" def test_scanner_skips_non_active_contracts() -> tuple[bool, str]: """status != '可续聘' 的合同不应被扫描""" from service import scanner conn = _make_conn() conn.execute( """INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status, last_material_id, renewed_at) VALUES ('C-DONE', 'T001', '已续签老师', 'U1', '数学系', '教授', '正式', '2022-01-01', '2024-03-15', '2022-07-01', '已续签', NULL, NULL)""" ) conn.commit() result = scanner.scan(conn, today=date(2024, 2, 15)) if result["scanned_count"] != 0: return False, f"已续签合同不应被扫描,scanned={result['scanned_count']}" alerts = conn.execute("SELECT COUNT(*) AS n FROM alerts").fetchone()["n"] if alerts != 0: return False, f"应无 alerts 写入,实测 {alerts}" return True, "已续签合同被跳过" # ---------- service.html_renderer 降级路径 ---------- def test_html_render_basic() -> tuple[bool, str]: """HTML 渲染(无 jinja2 走 str.replace 降级路径)""" from service import html_renderer tmpdir = Path(tempfile.mkdtemp(prefix="alert-hr-test-")) saved = os.environ.get("HTML_OUTPUT_DIR") os.environ["HTML_OUTPUT_DIR"] = str(tmpdir) try: conn = _make_conn() conn.execute( """INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status, last_material_id, renewed_at) VALUES ('C-HTML', 'T100', '渲染测试', 'U9', '测试系', '教授', '正式', '2022-01-01', '2024-06-30', '2022-07-01', '可续聘', NULL, NULL)""" ) conn.execute( """INSERT INTO alerts VALUES ('A-HTML', 'C-HTML', 'orange', 60, 'test', '2024-04-01T00:00:00', 'open')""" ) conn.execute( """INSERT INTO renewal_tasks VALUES ('T-HTML', 'C-HTML', 'A-HTML', 'renew', '近 3 年考核良好', '业绩良好', '["材料 A", "材料 B"]', '2024-04-01T00:00:00', 'pending', 'rules', '')""" ) conn.commit() path = html_renderer.render_task_html(conn, "T-HTML") if not path.exists(): return False, f"输出文件不存在 {path}" text = path.read_text(encoding="utf-8") if "渲染测试" not in text: return False, "HTML 应含教师姓名" if "续聘准备任务书" not in text: return False, "HTML 应含 TITLE" if "橙色" not in text: return False, "HTML 应含「橙色」预警等级标签" if "建议续聘" not in text: return False, "HTML 应含「建议续聘」建议标签" return True, f"HTML 渲染 OK → {path.name}" finally: if saved is None: os.environ.pop("HTML_OUTPUT_DIR", None) else: os.environ["HTML_OUTPUT_DIR"] = saved shutil.rmtree(tmpdir, ignore_errors=True) def test_html_render_unknown_task_raises() -> tuple[bool, str]: """渲染不存在的 task_id 应抛 ValueError""" from service import html_renderer conn = _make_conn() try: html_renderer.render_task_html(conn, "T-NONEXISTENT") except ValueError as e: return True, f"ValueError OK: {e}" return False, "应抛 ValueError,实际未抛" # ---------- v1.1.6:service.platform_collector 纯函数 ---------- def test_gen_contract_id_emp_id() -> tuple[bool, str]: """_gen_contract_id 工号派生(v1.1.1 起用工号作主键)""" from service import platform_collector as pc cases = [ (("T20190033", "2026-04-15"), "RPA-T20190033-20260415"), (("T20200055", "2026-06-30"), "RPA-T20200055-20260630"), (("T20180021", "2026-08-31"), "RPA-T20180021-20260831"), ] for (emp_id, end_date), expected in cases: got = pc._gen_contract_id(emp_id, end_date) if got != expected: return False, f"({emp_id!r}, {end_date!r}) → {got!r},want {expected!r}" return True, f"3 工号派生正确" def test_gen_contract_id_special_chars() -> tuple[bool, str]: """_gen_contract_id 工号含特殊字符时正确清洗(保留前 10 字符)""" from service import platform_collector as pc # 工号含非 \w 字符 → 清洗;超过 10 字符截断 got = pc._gen_contract_id("T-2019.0033", "2026-04-15") # 清洗后保留 \w,"T-2019.0033" → "T20190033"(8 字符,未截断) if "RPA-" not in got or "20260415" not in got: return False, f"清洗后格式错:{got!r}" # 长工号 → 截断到 10 字符 got2 = pc._gen_contract_id("ABCDEFGHIJKLMNO", "2026-01-01") # "ABCDEFGHIJ"(10 字符) if not got2.startswith("RPA-ABCDEFGHIJ-"): return False, f"长工号未截断到 10 字符:{got2!r}" return True, "清洗 + 截断 OK" def test_normalize_date_iso() -> tuple[bool, str]: """_normalize_date ISO 日期直通""" from service import platform_collector as pc cases = [ ("2026-04-15", "2026-04-15"), ("2024-01-01", "2024-01-01"), ] for inp, want in cases: got = pc._normalize_date(inp) if got != want: return False, f"{inp!r} → {got!r},want {want!r}" return True, "ISO 日期直通" def test_normalize_date_fallback() -> tuple[bool, str]: """_normalize_date 非标准格式截前 10 字符""" from service import platform_collector as pc # 非标准但带 ISO 前缀 got = pc._normalize_date("2026-04-15T00:00:00") if got != "2026-04-15": return False, f"ISO 前缀应保留前 10 字符 → {got!r}" # 完全非日期 → 截前 10 字符 got2 = pc._normalize_date("20260415X") if got2 != "20260415X": # 10 字符 return False, f"非日期应截前 10 字符 → {got2!r}" # 带空白 got3 = pc._normalize_date(" 2026-04-15 ") if got3 != "2026-04-15": return False, f"带空白应去除 → {got3!r}" return True, "非标准格式 fallback OK" def test_parse_days_positive() -> tuple[bool, str]: """_parse_days 正天数""" from service import platform_collector as pc cases = [ ("30", 30), ("90", 90), ("还剩 45 天", 45), ("0", 0), ] for inp, want in cases: got = pc._parse_days(inp) if got != want: return False, f"{inp!r} → {got!r},want {want!r}" return True, "正天数解析 OK" def test_parse_days_negative() -> tuple[bool, str]: """_parse_days 负数 / 兜底""" from service import platform_collector as pc # 含负号 if pc._parse_days("-15") != -15: return False, "负天数未解析" # 已过期(带 "已过期" 文本) if pc._parse_days("已过期 7 天") != 7: return False, "已过期文本未解析数字部分" # 无法解析 if pc._parse_days("") is not None: return False, "空字符串应返回 None" if pc._parse_days("无数字") is not None: return False, "纯文本应返回 None" return True, "负数 + 兜底 OK" def test_parse_alert_red_orange_yellow_unknown() -> tuple[bool, str]: """_parse_alert 红/橙/黄/未知四级映射""" from service import platform_collector as pc cases = [ ("红色预警", "red"), ("红牌", "red"), ("橙色预警", "orange"), ("黄灯", "yellow"), ("黄色预警", "yellow"), ("无", "unknown"), ("", "unknown"), ("blue", "unknown"), ] for inp, want in cases: got = pc._parse_alert(inp) if got != want: return False, f"{inp!r} → {got!r},want {want!r}" return True, "四级映射 OK" # ---------- v1.1.18:日志 / 路径 / 输入目录 / 队列 / 配置 / 日志查看 ---------- def test_logging_rotating_file_handler() -> tuple[bool, str]: """LOG_FILE 已设时,setup_skill_logging 应追加 RotatingFileHandler。""" import importlib from util.runtime_paths import get_logs_dir log_path = get_logs_dir() / "test-rotate.log" os.environ["LOG_FILE"] = str(log_path) os.environ.pop("LOG_MAX_BYTES", None) os.environ.pop("LOG_BACKUP_COUNT", None) os.environ.pop("LOG_LEVEL", None) import util.logging_setup as ls importlib.reload(ls) ls.reset_for_tests() logger = ls.setup_skill_logging() logger.info("hello rotating") found = [h for h in logger.handlers if h.__class__.__name__ == "RotatingFileHandler"] if not found: ls.reset_for_tests() os.environ.pop("LOG_FILE", None) return False, "未发现 RotatingFileHandler" if not log_path.exists(): ls.reset_for_tests() os.environ.pop("LOG_FILE", None) return False, f"日志文件未生成: {log_path}" content = log_path.read_text(encoding="utf-8") if "hello rotating" not in content: ls.reset_for_tests() os.environ.pop("LOG_FILE", None) return False, f"日志内容缺 'hello rotating': {content[:200]}" ls.reset_for_tests() os.environ.pop("LOG_FILE", None) importlib.reload(ls) return True, "RotatingFileHandler 写入成功" def test_logging_fallback_when_log_file_unset() -> tuple[bool, str]: """LOG_FILE 未设时,logger 只有 StreamHandler(行为等价 v1.1.17)。""" import importlib os.environ.pop("LOG_FILE", None) os.environ.pop("LOG_LEVEL", None) import util.logging_setup as ls importlib.reload(ls) ls.reset_for_tests() logger = ls.setup_skill_logging() file_handlers = [h for h in logger.handlers if "File" in h.__class__.__name__] if file_handlers: ls.reset_for_tests() return False, f"应有 0 个 FileHandler,实际 {len(file_handlers)}" ls.reset_for_tests() return True, "无 LOG_FILE 时无 FileHandler(兼容 v1.1.17)" def test_logging_level_env() -> tuple[bool, str]: """LOG_LEVEL=DEBUG 应让 logger.level == logging.DEBUG。""" import importlib import logging os.environ["LOG_LEVEL"] = "DEBUG" os.environ.pop("LOG_FILE", None) import util.logging_setup as ls importlib.reload(ls) ls.reset_for_tests() logger = ls.setup_skill_logging() got = logger.level if got != logging.DEBUG: ls.reset_for_tests() os.environ.pop("LOG_LEVEL", None) return False, f"logger.level={got} (want {logging.DEBUG})" ls.reset_for_tests() os.environ.pop("LOG_LEVEL", None) return True, "LOG_LEVEL=DEBUG 生效" def test_logs_dir_default_under_data() -> tuple[bool, str]: """JIANGCHANG_LOGS_DIR 未设时,get_logs_dir() = get_data_dir() / 'logs'。""" os.environ.pop("JIANGCHANG_LOGS_DIR", None) os.environ.pop("LOG_FILE", None) import util.logging_setup as ls # 触发 runtime_paths 副作用 ls.reset_for_tests() from util.runtime_paths import get_logs_dir, get_data_dir expected = get_data_dir() / "logs" if get_logs_dir() != expected: return False, f"get_logs_dir()={get_logs_dir()} != {expected}" if not expected.exists(): return False, f"ensure_dirs 没创建 {expected}" return True, f"get_logs_dir={expected}" def _isolated_input_dirs(monkeypatch_env: dict[str, str | None]) -> tuple[Path, Path]: """把 INPUT_DIR 重定向到 tmpdir,返回 (input_root, materials_target) 的 Path。 不会污染仓库 data/input/。 """ import tempfile tmp = Path(tempfile.mkdtemp(prefix="alert-hr-importer-test-")) input_root = tmp / "input" (input_root / "contracts").mkdir(parents=True) (input_root / "materials").mkdir(parents=True) monkeypatch_env["INPUT_DIR"] = str(input_root) os.environ["INPUT_DIR"] = str(input_root) # 关键:进程 env 也得设,否则 runtime_paths 读不到 return input_root, tmp def _restore_input_env(saved: dict[str, str | None]) -> None: for k, v in saved.items(): if v is None: os.environ.pop(k, None) else: os.environ[k] = v def test_importer_contracts_csv_upsert() -> tuple[bool, str]: """input/contracts/*.csv → contracts 表 upsert。""" saved = {k: os.environ.get(k) for k in ("INPUT_DIR",)} try: input_root, tmp = _isolated_input_dirs(saved) contracts_dir = input_root / "contracts" csv_path = contracts_dir / "batch1.csv" csv_path.write_text( "contract_id,teacher_name,unit_name,contract_end,employee_id\n" "C001,张三,物理学院,2026-04-15,T001\n" "C002,李四,计算机学院,2026-08-31,T002\n", encoding="utf-8", ) conn = _make_conn() try: from service.importer import import_input_dir result = import_input_dir(conn) if result["contracts_added"] != 2: return False, f"contracts_added={result['contracts_added']} (want 2)" if result["errors"]: return False, f"errors={result['errors']}" rows = conn.execute( "SELECT contract_id, teacher_name, unit_name, contract_end " "FROM contracts ORDER BY contract_id" ).fetchall() if len(rows) != 2: return False, f"DB 中合同数={len(rows)} (want 2)" if rows[0]["teacher_name"] != "张三": return False, f"row[0].teacher_name={rows[0]['teacher_name']}" return True, f"contracts_added={result['contracts_added']}" finally: conn.close() shutil.rmtree(tmp, ignore_errors=True) finally: _restore_input_env(saved) def test_importer_materials_pdf_enqueue() -> tuple[bool, str]: """input/materials/{cid}__{f}.pdf → materials_content (parse_method='pending')。""" saved = {k: os.environ.get(k) for k in ("INPUT_DIR",)} try: input_root, tmp = _isolated_input_dirs(saved) materials_dir = input_root / "materials" pdf_path = materials_dir / "RPA-C001__考核表.pdf" pdf_path.write_bytes(b"%PDF-1.4 fake test file") conn = _make_conn() try: # 先插入 contract(materials_content FK 到 contracts.contract_id) conn.execute( """INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status) VALUES ('RPA-C001', 'T001', '张三', 'U001', '物理学院', '教授', '固定期限', '2024-01-01', '2026-04-15', '2024-04-15', '可续聘')""" ) conn.commit() from service.importer import import_input_dir result = import_input_dir(conn) if result["materials_added"] != 1: return False, f"materials_added={result['materials_added']} (want 1) errors={result['errors']}" row = conn.execute( "SELECT contract_id, parse_method FROM materials_content " "WHERE contract_id = 'RPA-C001'" ).fetchone() if not row: return False, "materials_content 未写入" if row["parse_method"] != "pending": return False, f"parse_method={row['parse_method']} (want pending)" return True, "PDF 入库 parse_method=pending" finally: conn.close() shutil.rmtree(tmp, ignore_errors=True) finally: _restore_input_env(saved) def test_importer_idempotent() -> tuple[bool, str]: """同一 CSV 跑两次,第二次 contracts_added=0(upsert 幂等)。""" saved = {k: os.environ.get(k) for k in ("INPUT_DIR",)} try: input_root, tmp = _isolated_input_dirs(saved) (input_root / "contracts" / "x.csv").write_text( "contract_id,teacher_name\nC100,重复测试\n", encoding="utf-8" ) conn = _make_conn() try: from service.importer import import_input_dir r1 = import_input_dir(conn) r2 = import_input_dir(conn) if r1["contracts_added"] != 1: return False, f"first={r1['contracts_added']} (want 1)" if r2["contracts_added"] != 1: # 注意:CSV 仍按行 upsert,第 2 次相当于 1 次 UPDATE,contracts_added 仍计数 return False, f"second={r2['contracts_added']} (want 1)" n = conn.execute("SELECT COUNT(*) AS n FROM contracts").fetchone()["n"] if n != 1: return False, f"DB count={n} (want 1,幂等)" return True, "幂等 OK:DB count=1" finally: conn.close() shutil.rmtree(tmp, ignore_errors=True) finally: _restore_input_env(saved) def test_importer_empty_input_dir() -> tuple[bool, str]: """空 input 目录 → no-op,返回全 0。""" saved = {k: os.environ.get(k) for k in ("INPUT_DIR",)} try: input_root, tmp = _isolated_input_dirs(saved) conn = _make_conn() try: from service.importer import import_input_dir result = import_input_dir(conn) if result["contracts_added"] or result["materials_added"]: return False, f"空目录应返回全 0,实际 {result}" if result["files_scanned"] != 0: return False, f"files_scanned={result['files_scanned']} (want 0)" return True, "空目录 no-op" finally: conn.close() shutil.rmtree(tmp, ignore_errors=True) finally: _restore_input_env(saved) def test_download_tasks_enqueue_idempotent() -> tuple[bool, str]: """enqueue 同一合同两次:第 2 次返回已有 task_id,DB 仅 1 行。""" from db import repository as repo conn = _make_conn() try: conn.execute( """INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status) VALUES ('C100', 'T100', 'X', 'U1', 'Y', '', '', '2024-01-01', '2026-04-15', '2024-04-15', '可续聘')""" ) conn.commit() t1 = repo.enqueue_download_task(conn, "C100", "platform_button") t2 = repo.enqueue_download_task(conn, "C100", "platform_button") if t1 != t2: return False, f"t1={t1} t2={t2} (应相同)" n = conn.execute( "SELECT COUNT(*) AS n FROM download_tasks WHERE contract_id = 'C100'" ).fetchone()["n"] if n != 1: return False, f"DB count={n} (want 1)" return True, f"idempotent: task_id={t1}" finally: conn.close() def test_download_tasks_claim_atomic() -> tuple[bool, str]: """claim_pending_tasks:第一次拿 N 个,第二次拿 0 个(被抢光)。""" from db import repository as repo conn = _make_conn() try: # 准备 5 份合同 for i in range(5): conn.execute( f"""INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status) VALUES ('C{i}', 'T{i}', 'X', 'U{i}', 'Y', '', '', '2024-01-01', '2026-04-15', '2024-04-15', '可续聘')""" ) conn.commit() for i in range(5): repo.enqueue_download_task(conn, f"C{i}", "platform_button") conn.commit() first = repo.claim_pending_tasks(conn, limit=5) if len(first) != 5: return False, f"第一次领到 {len(first)} (want 5)" # claim 返回的 Row 是查询时刻的快照(UPDATE 在 fetchall 之后才发); # 重新查 DB 验证 status 已变 running for t in first: cur = conn.execute( "SELECT status FROM download_tasks WHERE task_id = ?", (t["task_id"],), ).fetchone() if cur["status"] != "running": return False, f"task {t['task_id']} DB status={cur['status']} (want running)" second = repo.claim_pending_tasks(conn, limit=5) if len(second) != 0: return False, f"第二次领到 {len(second)} (want 0)" return True, "claim 原子:5+0" finally: conn.close() def test_download_tasks_mark_success_and_failed() -> tuple[bool, str]: """mark_success: status=success + result_path; mark_failed 计数 + 重试状态切换。""" from db import repository as repo conn = _make_conn() try: conn.execute( """INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status) VALUES ('C200', 'T200', 'X', 'U1', 'Y', '', '', '2024-01-01', '2026-04-15', '2024-04-15', '可续聘')""" ) conn.commit() t = repo.enqueue_download_task(conn, "C200", "platform_button") # 1. 标记 success repo.mark_task_success(conn, t, "sandbox://hr/contracts/C200") conn.commit() row = conn.execute( "SELECT status, result_path FROM download_tasks WHERE task_id = ?", (t,) ).fetchone() if row["status"] != "success": return False, f"success 后 status={row['status']}" if row["result_path"] != "sandbox://hr/contracts/C200": return False, f"result_path={row['result_path']}" # 2. 失败 ≤ max_attempts:status 回退 pending t2 = repo.enqueue_download_task(conn, "C200", "platform_button_v2") repo.mark_task_failed(conn, t2, "first attempt failed") conn.commit() row2 = conn.execute( "SELECT status, attempts FROM download_tasks WHERE task_id = ?", (t2,) ).fetchone() if row2["status"] != "pending": return False, f"第 1 次失败应 pending,实际 {row2['status']}" if row2["attempts"] != 1: return False, f"attempts={row2['attempts']} (want 1)" # 3. 失败 ≥ max_attempts:status=failed repo.mark_task_failed(conn, t2, "second attempt failed") repo.mark_task_failed(conn, t2, "third attempt failed") conn.commit() row3 = conn.execute( "SELECT status, attempts FROM download_tasks WHERE task_id = ?", (t2,) ).fetchone() if row3["status"] != "failed": return False, f"第 3 次失败应 failed,实际 {row3['status']}" return True, "mark_success/mark_failed 全 OK" finally: conn.close() def test_download_tasks_count_by_status() -> tuple[bool, str]: """count_tasks_by_status 返回 {pending: N, success: M, ...}。""" from db import repository as repo conn = _make_conn() try: for i in range(3): conn.execute( f"""INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status) VALUES ('CT{i}', 'T{i}', 'X', 'U{i}', 'Y', '', '', '2024-01-01', '2026-04-15', '2024-04-15', '可续聘')""" ) conn.commit() t0 = repo.enqueue_download_task(conn, "CT0", "platform_button") t1 = repo.enqueue_download_task(conn, "CT1", "platform_button") repo.enqueue_download_task(conn, "CT2", "platform_button") conn.commit() repo.mark_task_success(conn, t0, "x") repo.mark_task_failed(conn, t1, "err") # 再失败 2 次让 t1 进 failed repo.mark_task_failed(conn, t1, "err") repo.mark_task_failed(conn, t1, "err") conn.commit() counts = repo.count_tasks_by_status(conn) if counts.get("success", 0) < 1: return False, f"success 计数 < 1: {counts}" if counts.get("failed", 0) < 1: return False, f"failed 计数 < 1: {counts}" if counts.get("pending", 0) < 1: return False, f"pending 计数 < 1: {counts}" return True, f"counts={counts}" finally: conn.close() def test_enqueue_download_tasks_safe_basic() -> tuple[bool, str]: """_enqueue_download_tasks_safe: 全部成功入队。""" from service.platform_collector import _enqueue_download_tasks_safe # 准备 3 个合同(in-memory DB,函数不会关闭) conn = _make_conn() for i in range(3): conn.execute( f"""INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status) VALUES ('ENQ-{i}', 'T{i}', 'X', 'U{i}', 'Y', '', '', '2024-01-01', '2026-04-15', '2024-04-15', '可续聘')""" ) conn.commit() summary = _enqueue_download_tasks_safe(["ENQ-0", "ENQ-1", "ENQ-2"], conn=conn) if summary["enqueued"] != 3: return False, f"enqueued={summary['enqueued']} (want 3) - {summary}" if summary["failed"] != 0: return False, f"failed={summary['failed']} - {summary}" if summary["errors"]: return False, f"errors={summary['errors']}" return True, f"3 个合同全部入队: {summary}" def test_enqueue_download_tasks_safe_idempotent() -> tuple[bool, str]: """_enqueue_download_tasks_safe: 同 contract_id 第二次调用 → skipped。""" from service.platform_collector import _enqueue_download_tasks_safe conn = _make_conn() conn.execute( f"""INSERT INTO contracts (contract_id, employee_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end, status) VALUES ('ENQ-IDM', 'T1', 'X', 'U1', 'Y', '', '', '2024-01-01', '2026-04-15', '2024-04-15', '可续聘')""" ) conn.commit() s1 = _enqueue_download_tasks_safe(["ENQ-IDM"], conn=conn) s2 = _enqueue_download_tasks_safe(["ENQ-IDM"], conn=conn) if s1["enqueued"] != 1: return False, f"第一次 enqueued={s1['enqueued']} (want 1) - {s1}" if s2["skipped"] != 1: return False, f"第二次 skipped={s2['skipped']} (want 1) - {s2}" if s2["enqueued"] != 0: return False, f"第二次不应新增 enqueued={s2['enqueued']} - {s2}" return True, f"幂等 OK: 第一次 {s1}, 第二次 {s2}" def test_enqueue_download_tasks_safe_fk_failure() -> tuple[bool, str]: """_enqueue_download_tasks_safe: contract_id 不存在 → 计入 failed,不抛异常。""" from service.platform_collector import _enqueue_download_tasks_safe # 不插入合同,直接入队 → FK 失败(in-memory DB,函数不关闭) conn = _make_conn() summary = _enqueue_download_tasks_safe(["DOES-NOT-EXIST"], conn=conn) if summary["failed"] != 1: return False, f"failed={summary['failed']} (want 1) - {summary}" if not summary["errors"]: return False, f"errors 应非空: {summary}" if "FOREIGN KEY" not in summary["errors"][0] and "foreign key" not in summary["errors"][0].lower(): return False, f"错误信息应提及 FOREIGN KEY: {summary['errors'][0]}" return True, f"FK 失败被吞掉: failed=1, errors[0]={summary['errors'][0][:80]}" def test_enqueue_download_tasks_safe_empty_list() -> tuple[bool, str]: """_enqueue_download_tasks_safe: 空列表 → no-op,不开 DB 连接。""" from service.platform_collector import _enqueue_download_tasks_safe summary = _enqueue_download_tasks_safe([]) if summary != {"enqueued": 0, "skipped": 0, "failed": 0, "errors": []}: return False, f"空列表返回值异常: {summary}" return True, "空列表 no-op OK" def test_enqueue_download_tasks_safe_auto_create_table_v1_29() -> tuple[bool, str]: """v1.1.29:用户没跑 init-db 时,首次调用 `_enqueue_download_tasks_safe` 不传 conn(owns_conn=True),函数内部 idempotent 创建 download_tasks 表 而非抛 "no such table"。 """ import os import sqlite3 import tempfile from pathlib import Path from service.platform_collector import _enqueue_download_tasks_safe # 1. 临时文件 DB,**只建 contracts 表**(模拟老 DB 没 download_tasks) with tempfile.TemporaryDirectory() as tmp: tmp_db = Path(tmp) / "old.db" # 重定向 JIANGCHANG_DATA_ROOT 或 get_db_path 用临时路径 # 这里用 monkeypatch get_db_path + get_connection from util import runtime_paths as rp from db import connection as dbconn orig_rp_db_path = rp.get_db_path orig_dbconn_db_path = dbconn.get_db_path rp.get_db_path = lambda: tmp_db # type: ignore[assignment] dbconn.get_db_path = lambda: tmp_db # type: ignore[assignment] try: # 模拟老 DB:只有 contracts 表(v1.1.17 之前 init 过) _tmp_conn = sqlite3.connect(str(tmp_db)) try: _tmp_conn.executescript( """ CREATE TABLE contracts ( contract_id TEXT PRIMARY KEY, teacher_name TEXT, unit_id TEXT, unit_name TEXT, position TEXT, contract_type TEXT, contract_start TEXT, contract_end TEXT, trial_period_end TEXT, status TEXT DEFAULT '可续聘', last_material_id TEXT, renewed_at TEXT ); INSERT INTO contracts (contract_id, teacher_name, unit_id, unit_name, position, contract_type, contract_start, contract_end, trial_period_end) VALUES ('ENQ-CREATE-1', '测试', 'U1', '物理学院', '实验师', '专任教师聘用合同', '2024-01-01', '2026-12-31', '2024-04-01'); """ ) _tmp_conn.commit() finally: _tmp_conn.close() # 2. 不传 conn 调用(owns_conn=True,会走 get_connection → 触发 ensure 建表) summary = _enqueue_download_tasks_safe(["ENQ-CREATE-1"]) if summary.get("failed", 0) > 0: return False, f"应自动建表后入队成功,但 failed={summary}" # 3. 验证表确实被自动创建了 conn = sqlite3.connect(str(tmp_db)) try: count = conn.execute( "SELECT COUNT(*) FROM download_tasks" ).fetchone()[0] finally: conn.close() if count != 1: return False, f"download_tasks 应有 1 行,实际 {count}" return True, f"ensure 建表 + 入队 OK(enqueued={summary.get('enqueued')})" finally: rp.get_db_path = orig_rp_db_path # type: ignore[assignment] dbconn.get_db_path = orig_dbconn_db_path # type: ignore[assignment] # ---------- v1.1.19: _wait_for_url_stable + login flow ---------- class _FakePage: """v1.1.19 login flow 测试用的简易 page mock。 - .url: 字符串 - 支持外部修改 .url 模拟用户登录跳转 - v1.1.24:新增 .locator() 返回 _FakeLocatorNoOp,让 _wait_for_login_inputs_filled 的 selector 探测不抛 AttributeError """ def __init__(self, initial_url: str = "about:blank") -> None: self.url = initial_url self.goto_calls: list[str] = [] self.reload_calls: int = 0 self.click_calls: int = 0 def locator(self, selector: str): # noqa: ARG002 """v1.1.24 扩展:测试 _wait_for_login_inputs_filled 用,返回空 locator。""" return _FakeLocator(count=0) def test_wait_for_url_stable_immediate_match() -> tuple[bool, str]: """_wait_for_url_stable: URL 已含目标 + 稳定 → 立即返回 True。""" from service.platform_collector import _wait_for_url_stable page = _FakePage("https://example.com/app/contracts") if not _wait_for_url_stable(page, "/app/", max_wait_sec=5): return False, "URL 已含 /app/ 应立即返回 True" if page.goto_calls or page.reload_calls: return False, f"不应触发 goto/reload: {page.goto_calls}/{page.reload_calls}" return True, "URL 已含 /app/ 立即返回 OK" def test_wait_for_url_stable_transition() -> tuple[bool, str]: """_wait_for_url_stable: URL 经历 login → /app/ 过渡,等稳定后返回 True。""" import threading import time as _time from service.platform_collector import _wait_for_url_stable page = _FakePage("https://example.com/login") def flip_to_app(): _time.sleep(0.6) page.url = "https://example.com/app/home" threading.Thread(target=flip_to_app, daemon=True).start() if not _wait_for_url_stable(page, "/app/", max_wait_sec=5): return False, f"应等 URL 稳定到 /app/,但超时(最终 URL={page.url})" return True, f"URL 过渡后稳定 OK(最终 URL={page.url})" def test_wait_for_url_stable_timeout() -> tuple[bool, str]: """_wait_for_url_stable: URL 始终未跳到目标 → 超时返回 False。""" from service.platform_collector import _wait_for_url_stable page = _FakePage("https://example.com/login") if _wait_for_url_stable(page, "/app/", max_wait_sec=2): return False, "URL 永远含 login,应超时返回 False" return True, "超时返回 False OK" def test_wait_for_manual_login_no_goto() -> tuple[bool, str]: """_wait_for_manual_login: 等待期间 **不应** 触发 goto / reload / click。""" import threading import time as _time from service import platform_collector as pc # platform_collector 在 import 时已经把常量复制到模块 globals; # 直接覆盖模块属性最稳。缩短 HUMAN_POLL_INTERVAL 和 URL_STABLE_REQUIRED_S, # 让 fake_user 0.8s 翻 URL + 0.3s 稳定窗口能凑进 wait_sec=5。 orig_poll = pc.HUMAN_POLL_INTERVAL orig_stable = pc.URL_STABLE_REQUIRED_S pc.HUMAN_POLL_INTERVAL = 0.3 pc.URL_STABLE_REQUIRED_S = 0.3 try: page = _FakePage("https://example.com/login") def fake_user_login(): _time.sleep(0.8) page.url = "https://example.com/app/home" threading.Thread(target=fake_user_login, daemon=True).start() try: pc._wait_for_manual_login(page, wait_sec=5) except Exception as e: return False, f"应等用户登录成功,但抛异常: {e}" if page.goto_calls: return False, f"等待期间不应 goto: {page.goto_calls}" if page.reload_calls: return False, f"等待期间不应 reload: {page.reload_calls}" return True, "等用户登录期间无打断 OK" finally: pc.HUMAN_POLL_INTERVAL = orig_poll pc.URL_STABLE_REQUIRED_S = orig_stable # ---------- v1.1.24: _try_auto_sign_in SPA 二级登录页(中文「登录」按钮)---------- class _FakeLocator: """v1.1.24 _try_auto_sign_in 测试用的简易 Playwright Locator mock。 支持的 API: - .count() -> int - .input_value() -> str - .click() -> None - .first -> 自身 """ def __init__( self, count: int = 0, value: str = "", click_log: list | None = None, ) -> None: self._count = count self._value = value self._click_log = click_log if click_log is not None else [] @property def first(self) -> "_FakeLocator": return self def count(self) -> int: return self._count def input_value(self) -> str: return self._value def click(self) -> None: self._click_log.append(self._value or "clicked") def fill(self, value: str) -> None: """v1.1.27:fill 后 value 立即更新。""" self._value = value class _FakeLocatorRouter: """根据 selector 返回对应 _FakeLocator 的路由器。 selectors: dict[selector_str, _FakeLocator] """ def __init__(self, selectors: dict[str, _FakeLocator], page) -> None: self._selectors = selectors self._page = page def __call__(self, sel: str): """page.locator(sel) 形式调用。""" if sel in self._selectors: return self._selectors[sel] return _FakeLocator(count=0) def __getattr__(self, sel: str): # loc.page 已存在则按 selector 路由 if sel in self._selectors: return self._selectors[sel] # fallback:未注册的 selector 都返回空 locator return _FakeLocator(count=0) class _FakePlaywrightPage: """v1.1.24 _try_auto_sign_in 测试用的 page mock。 usage: page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/industries/campus/smartcampus/login", selectors={ "input[type='tel']": _FakeLocator(count=1, value="18677308191"), "input[type='password']": _FakeLocator(count=1, value="***"), "button:has-text('登录')": _FakeLocator(count=1, click_log=click_log), }, wait_for_selector_log=wait_log, ) """ def __init__( self, url: str = "about:blank", selectors: dict[str, _FakeLocator] | None = None, wait_for_selector_log: list | None = None, wait_selector_should_raise: bool = False, ) -> None: self.url = url self._selectors = selectors or {} self._wait_log = wait_for_selector_log if wait_for_selector_log is not None else [] self._wait_should_raise = wait_selector_should_raise def wait_for_selector(self, selector: str, timeout: int = 0) -> None: self._wait_log.append(selector) if self._wait_should_raise: raise Exception("fake: timeout") @property def locator(self): return _FakeLocatorRouter(self._selectors, self) def test_try_auto_sign_in_spa_login_chinese_button() -> tuple[bool, str]: """v1.1.24:SPA 二级登录页(/industries/campus/smartcampus/login), input 用 `input[type='tel']` / `input[type='password']`, 按钮中文「登录」。预填有值 → 自动点登录。 """ from service.platform_collector import _try_auto_sign_in click_log: list[str] = [] page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/industries/campus/smartcampus/login", selectors={ "input[type='tel']": _FakeLocator(count=1, value="18677308191"), "input[type='password']": _FakeLocator(count=1, value="dummy"), "button:has-text('登录')": _FakeLocator(count=1, click_log=click_log), }, ) if not _try_auto_sign_in(page, wait_input_sec=2): return False, "SPA 二级登录页预填应自动点 登录" if not click_log: return False, f"未触发点击: {click_log}" return True, f"SPA 二级登录页中文按钮自动点 OK(click_log={click_log})" def test_try_auto_sign_in_root_sign_in_english_button() -> tuple[bool, str]: """v1.1.24(向后兼容):根域登录页(https://sandbox.jc2009.com/), input 用 `#jc-user` / `#jc-pass`,按钮英文「Sign in」。预填有值 → 自动点。 """ from service.platform_collector import _try_auto_sign_in click_log: list[str] = [] page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/", selectors={ "#jc-user": _FakeLocator(count=1, value="demo"), "#jc-pass": _FakeLocator(count=1, value="123456"), "button:has-text('Sign in')": _FakeLocator(count=1, click_log=click_log), }, ) if not _try_auto_sign_in(page, wait_input_sec=2): return False, "根域登录页预填应自动点 Sign in" if not click_log: return False, f"未触发点击: {click_log}" return True, f"根域登录页 Sign in 自动点 OK(click_log={click_log})" def test_try_auto_sign_in_no_input_returns_false() -> tuple[bool, str]: """v1.1.24:input 为空(用户还没输入)→ 返回 False,不点按钮。""" from service.platform_collector import _try_auto_sign_in click_log: list[str] = [] page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/industries/campus/smartcampus/login", selectors={ "input[type='tel']": _FakeLocator(count=1, value=""), # 空 "input[type='password']": _FakeLocator(count=1, value="123"), "button:has-text('登录')": _FakeLocator(count=1, click_log=click_log), }, ) if _try_auto_sign_in(page, wait_input_sec=2): return False, "user 空时不应自动点" if click_log: return False, f"未填完不应点击: {click_log}" return True, "input 空返回 False 不点 OK" def test_try_auto_sign_in_selector_timeout_returns_false() -> tuple[bool, str]: """v1.1.24:等 input 超时(如 URL 已是 SPA home,无登录 input)→ 返回 False。""" from service.platform_collector import _try_auto_sign_in click_log: list[str] = [] page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/industries/campus/smartcampus/app/home", selectors={}, # 没有任何 input wait_selector_should_raise=True, ) if _try_auto_sign_in(page, wait_input_sec=1): return False, "无登录 input 应返回 False" if click_log: return False, f"无 input 不应点击: {click_log}" return True, "无登录 input 超时返回 False OK" def test_wait_for_login_inputs_filled_auto_click_v1_26() -> tuple[bool, str]: """v1.1.26:`_wait_for_login_inputs_filled` **自动点登录按钮**—— 按用户最新要求:「到了 /industries/campus/smartcampus/login 整个页面, 有账号和密码可用直接点登录跳转」。 input 有值时点击 `button:has-text('登录')` 或 `Sign in`(多按钮文本优先级)。 """ from service import platform_collector as pc orig_poll = pc.HUMAN_POLL_INTERVAL orig_prompt = pc.HUMAN_PROMPT_INTERVAL pc.HUMAN_POLL_INTERVAL = 0.2 pc.HUMAN_PROMPT_INTERVAL = 0.2 try: click_log: list[str] = [] page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/industries/campus/smartcampus/login", selectors={ "input[type='tel']": _FakeLocator(count=1, value="18677308191"), "input[type='password']": _FakeLocator(count=1, value="dummy"), "button:has-text('登录')": _FakeLocator(count=1, click_log=click_log), }, ) result = pc._wait_for_login_inputs_filled(page, wait_sec=5) if not result: return False, "input 有值应自动点登录 → return True" if not click_log: return False, f"v1.1.26 应自动点登录按钮,但 click_log={click_log}" return True, f"v1.1.26 自动点登录按钮 OK(click_log={click_log})" finally: pc.HUMAN_POLL_INTERVAL = orig_poll pc.HUMAN_PROMPT_INTERVAL = orig_prompt def test_fill_demo_on_spa_login_basic_v1_27() -> tuple[bool, str]: """v1.1.27:`_fill_demo_on_spa_login` ——SPA 二级登录页 input 默认不预填, 必须主动 fill demo/123456 再点登录按钮。 """ click_log: list[str] = [] page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/industries/campus/smartcampus/login", selectors={ "#jc-user": _FakeLocator(count=1, value=""), # 空 → 要 fill "#jc-pass": _FakeLocator(count=1, value=""), "button:has-text('Sign in')": _FakeLocator(count=1, click_log=click_log), }, ) from service.platform_collector import _fill_demo_on_spa_login if not _fill_demo_on_spa_login(page): return False, "SPA login fill demo 应成功" if not click_log: return False, f"应点 Sign in,但 click_log={click_log}" return True, f"SPA 二级登录页 demo/123456 自动 fill+点 OK(click_log={click_log})" def test_fill_demo_on_spa_login_chinese_button_v1_27() -> tuple[bool, str]: """v1.1.27:中文「登录」按钮(用户截图版本)也能 fill demo/123456 并点。""" click_log: list[str] = [] page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/industries/campus/smartcampus/login", selectors={ "input[type='tel']": _FakeLocator(count=1, value=""), "input[type='password']": _FakeLocator(count=1, value=""), "button:has-text('登录')": _FakeLocator(count=1, click_log=click_log), }, ) from service.platform_collector import _fill_demo_on_spa_login if not _fill_demo_on_spa_login(page): return False, "SPA login 中文按钮 fill demo 应成功" if not click_log: return False, f"应点 登录,但 click_log={click_log}" return True, f"SPA 二级登录页中文「登录」按钮 OK(click_log={click_log})" def test_fill_demo_on_spa_login_no_input_returns_false_v1_27() -> tuple[bool, str]: """v1.1.27:找不到 user input(页面不是 login 页)→ 返回 False。""" page = _FakePlaywrightPage( url="https://sandbox.jc2009.com/industries/campus/smartcampus/app/home", selectors={}, ) from service.platform_collector import _fill_demo_on_spa_login if _fill_demo_on_spa_login(page): return False, "无 login input 应返回 False" return True, "无 login input 返回 False OK" # ---------- v1.1.18: show-config / logs-list ---------- def _isolated_env_file(tmp_env_path: Path) -> None: """把 .env 路径重定向到 tmpdir;不影响其他 env。""" import util.runtime_paths as rp # runtime_paths.get_env_path() 总是返回 get_skill_root() / ".env" # 这里通过 monkey-patch 函数对象实现隔离 rp.get_env_path = lambda: tmp_env_path # type: ignore[assignment] def test_show_config_masks_tokens() -> tuple[bool, str]: """含 TOKEN 的 key 自动脱敏。""" import tempfile from pathlib import Path tmp = Path(tempfile.mkdtemp(prefix="alert-hr-config-")) env_path = tmp / ".env" env_path.write_text( "WECOM_API_TOKEN=abc123def456\n" "HR_API_TOKEN=xx\n" "LOG_LEVEL=INFO\n", encoding="utf-8", ) import service.config_viewer as cv orig_get_env_path = cv.get_env_path cv.get_env_path = lambda: env_path # type: ignore[assignment] try: result = cv.read_env_config() cfg = result["config"] if cfg.get("WECOM_API_TOKEN") != "ab***56": return False, f"WECOM_API_TOKEN={cfg.get('WECOM_API_TOKEN')} (want ab***56)" if cfg.get("HR_API_TOKEN") != "***": return False, f"HR_API_TOKEN={cfg.get('HR_API_TOKEN')} (want *** for ≤4 chars)" if cfg.get("LOG_LEVEL") != "INFO": return False, f"LOG_LEVEL={cfg.get('LOG_LEVEL')} (want INFO)" return True, "tokens masked OK" finally: cv.get_env_path = orig_get_env_path # type: ignore[assignment] shutil.rmtree(tmp, ignore_errors=True) def test_show_config_skips_empty() -> tuple[bool, str]: """.env 中空 value 的 key 不出现在 config 中。""" import tempfile from pathlib import Path tmp = Path(tempfile.mkdtemp(prefix="alert-hr-config-")) env_path = tmp / ".env" env_path.write_text( "DB_PATH=\nLOG_LEVEL=INFO\n", encoding="utf-8", ) import service.config_viewer as cv orig = cv.get_env_path cv.get_env_path = lambda: env_path # type: ignore[assignment] try: cfg = cv.read_env_config()["config"] if "DB_PATH" in cfg: return False, f"DB_PATH 应该跳过,仍在 cfg: {cfg}" if cfg.get("LOG_LEVEL") != "INFO": return False, f"LOG_LEVEL={cfg.get('LOG_LEVEL')}" return True, "空 value 跳过 OK" finally: cv.get_env_path = orig # type: ignore[assignment] shutil.rmtree(tmp, ignore_errors=True) def test_show_config_skips_comments() -> tuple[bool, str]: """# 注释行不计入 config。""" import tempfile from pathlib import Path tmp = Path(tempfile.mkdtemp(prefix="alert-hr-config-")) env_path = tmp / ".env" env_path.write_text( "# 这是注释\n" " # 也是注释\n" "LOG_LEVEL=INFO\n", encoding="utf-8", ) import service.config_viewer as cv orig = cv.get_env_path cv.get_env_path = lambda: env_path # type: ignore[assignment] try: cfg = cv.read_env_config()["config"] if len(cfg) != 1 or "LOG_LEVEL" not in cfg: return False, f"应仅 1 项 LOG_LEVEL,实际 {list(cfg.keys())}" return True, "注释跳过 OK" finally: cv.get_env_path = orig # type: ignore[assignment] shutil.rmtree(tmp, ignore_errors=True) def test_show_config_handles_quotes() -> tuple[bool, str]: """.env 中带双引号/单引号的值自动去引号。""" import tempfile from pathlib import Path tmp = Path(tempfile.mkdtemp(prefix="alert-hr-config-")) env_path = tmp / ".env" env_path.write_text( 'HR_API_BASE_URL="https://hr.example.edu/api/v1"\n' "GREETING='hello world'\n" "NOTE=no_quotes\n", encoding="utf-8", ) import service.config_viewer as cv orig = cv.get_env_path cv.get_env_path = lambda: env_path # type: ignore[assignment] try: cfg = cv.read_env_config()["config"] if cfg.get("HR_API_BASE_URL") != "https://hr.example.edu/api/v1": return False, f"双引号未去: {cfg.get('HR_API_BASE_URL')}" if cfg.get("GREETING") != "hello world": return False, f"单引号未去: {cfg.get('GREETING')}" if cfg.get("NOTE") != "no_quotes": return False, f"无引号: {cfg.get('NOTE')}" return True, "引号处理 OK" finally: cv.get_env_path = orig # type: ignore[assignment] shutil.rmtree(tmp, ignore_errors=True) def test_logs_viewer_nonexistent_file() -> tuple[bool, str]: """日志文件不存在时,返回 exists=False,lines=[]。""" import tempfile from pathlib import Path tmp = Path(tempfile.mkdtemp(prefix="alert-hr-logs-")) fake_log = tmp / "no-such-file.log" os.environ["LOG_FILE"] = str(fake_log) try: from service.logs_viewer import tail_log_file result = tail_log_file(tail=50) if result["exists"]: return False, "exists 应为 False" if result["lines"]: return False, f"lines 应为空: {result['lines']}" if result["returned_lines"] != 0: return False, f"returned_lines={result['returned_lines']}" return True, "non-existent file OK" finally: os.environ.pop("LOG_FILE", None) shutil.rmtree(tmp, ignore_errors=True) def test_logs_viewer_tail_and_filter() -> tuple[bool, str]: """tail + level 过滤:INFO 默认只显示 INFO/WARNING/ERROR。""" import tempfile from pathlib import Path tmp = Path(tempfile.mkdtemp(prefix="alert-hr-logs-")) log_path = tmp / "tail-test.log" lines: list[str] = [] for i in range(300): if i % 3 == 0: lines.append(f"2026-09-15 12:34:5{i%10} [alert-contract-expiring-hr] INFO 第{i}条") elif i % 3 == 1: lines.append(f"2026-09-15 12:34:5{i%10} [alert-contract-expiring-hr] WARNING 第{i}条") else: lines.append(f"2026-09-15 12:34:5{i%10} [alert-contract-expiring-hr] DEBUG 第{i}条") log_path.write_text("\n".join(lines), encoding="utf-8") os.environ["LOG_FILE"] = str(log_path) try: from service.logs_viewer import tail_log_file # INFO+:最后 50 行中含 INFO + WARNING,去掉 DEBUG # 300 行每 3 行一循环:i%3==0 INFO, i%3==1 WARN, i%3==2 DEBUG # 最后 50 行是 i=250..299:第 250=INFO 251=WARN 252=DEBUG ... 共 50 行 # 分布:i%3==0 → 17 (250, 253, ..., 299 但 299%3=0)... 实际 250..299 中 i%3==0 有 17 个 result_info = tail_log_file(tail=50, min_level="INFO") if result_info["returned_lines"] != 33: return False, f"INFO+ returned_lines={result_info['returned_lines']} (want 33)" if any(" DEBUG " in ln for ln in result_info["lines"]): return False, "INFO 过滤应排除 DEBUG 行" # WARNING+:仅 WARNING result_warn = tail_log_file(tail=50, min_level="WARNING") if result_warn["returned_lines"] != 17: return False, f"WARNING+ returned_lines={result_warn['returned_lines']} (want 17)" if not all(" WARNING " in ln for ln in result_warn["lines"]): return False, "WARNING 过滤应只剩 WARNING 行" # DEBUG:全部 50 行都返回 result_debug = tail_log_file(tail=50, min_level="DEBUG") if result_debug["returned_lines"] != 50: return False, f"DEBUG returned_lines={result_debug['returned_lines']} (want 50)" return True, f"tail+filter OK: DEBUG+={result_debug['returned_lines']}, INFO+={result_info['returned_lines']}, WARN+={result_warn['returned_lines']}" finally: os.environ.pop("LOG_FILE", None) shutil.rmtree(tmp, ignore_errors=True) def test_logs_viewer_resolves_relative_path() -> tuple[bool, str]: """LOG_FILE 设相对路径时,解析到 get_logs_dir() 下。""" from util.runtime_paths import get_logs_dir os.environ["LOG_FILE"] = "relative-test.log" os.environ.pop("JIANGCHANG_LOGS_DIR", None) try: from service.logs_viewer import _resolve_log_path got = _resolve_log_path() expected = get_logs_dir() / "relative-test.log" if got != expected: return False, f"_resolve_log_path={got} (want {expected})" return True, f"resolved to {got}" finally: os.environ.pop("LOG_FILE", None) # ---------- 入口 ---------- def main() -> int: parser = argparse.ArgumentParser(description="默认 release 测试套件") parser.add_argument("-v", "--verbose", action="store_true", help="详细输出每个用例结果") args = parser.parse_args() global _VERBOSE _VERBOSE = args.verbose print("=== alert-contract-expiring-hr release tests ===", flush=True) _run("util/parse_iso_date", test_parse_iso_date) _run("util/days_to", test_days_to) _run("util/classify_alert", test_classify_alert) _run("util/month_match", test_month_match) _run("constants/skill_md_version_alignment", test_skill_md_version_alignment) _run("constants/skill_md_slug_alignment", test_skill_md_slug_alignment) _run("assets/data_management_actions", test_data_management_actions) _run("cli/collect_platform_browser_default", test_collect_platform_defaults_to_browser) _run("db/parse_sandbox_card_row", test_parse_sandbox_card_row_format) _run("db/parse_sandbox_card_narrative", test_parse_sandbox_card_narrative_format) _run("db/parse_sandbox_card_empty_invalid", test_parse_sandbox_card_empty_and_invalid) _run("db/parse_sandbox_card_mixed", test_parse_sandbox_card_mixed_formats) _run("db/upsert_performance_record", test_upsert_performance_record_grade_mapping) _run("task_generator/suggest_empty", test_suggest_empty_records) _run("task_generator/suggest_low_moral", test_suggest_low_moral) _run("task_generator/suggest_fail_year", test_suggest_fail_year) _run("task_generator/suggest_renew", test_suggest_renew_upward) _run("task_generator/suggest_equal_scores", test_suggest_observe_equal_scores_not_renew) _run("task_generator/suggest_observe", test_suggest_observe_default) _run("scanner/no_alerts", test_scanner_no_alerts) _run("scanner/three_levels", test_scanner_three_levels) _run("scanner/skips_non_active", test_scanner_skips_non_active_contracts) _run("html_renderer/basic", test_html_render_basic) _run("html_renderer/unknown_task_raises", test_html_render_unknown_task_raises) _run("platform_collector/gen_contract_id_emp_id", test_gen_contract_id_emp_id) _run("platform_collector/gen_contract_id_special", test_gen_contract_id_special_chars) _run("platform_collector/normalize_date_iso", test_normalize_date_iso) _run("platform_collector/normalize_date_fallback", test_normalize_date_fallback) _run("platform_collector/parse_days_positive", test_parse_days_positive) _run("platform_collector/parse_days_negative", test_parse_days_negative) _run("platform_collector/parse_alert_levels", test_parse_alert_red_orange_yellow_unknown) _run("logging/rotating_file_handler", test_logging_rotating_file_handler) _run("logging/fallback_when_log_file_unset", test_logging_fallback_when_log_file_unset) _run("logging/level_env", test_logging_level_env) _run("logging/logs_dir_default", test_logs_dir_default_under_data) _run("importer/contracts_csv_upsert", test_importer_contracts_csv_upsert) _run("importer/materials_pdf_enqueue", test_importer_materials_pdf_enqueue) _run("importer/idempotent", test_importer_idempotent) _run("importer/empty_input_dir", test_importer_empty_input_dir) _run("download_tasks/enqueue_idempotent", test_download_tasks_enqueue_idempotent) _run("download_tasks/claim_atomic", test_download_tasks_claim_atomic) _run("download_tasks/mark_success_and_failed", test_download_tasks_mark_success_and_failed) _run("download_tasks/count_by_status", test_download_tasks_count_by_status) _run("download_tasks/enqueue_safe_basic", test_enqueue_download_tasks_safe_basic) _run("download_tasks/enqueue_safe_idempotent", test_enqueue_download_tasks_safe_idempotent) _run("download_tasks/enqueue_safe_fk_failure", test_enqueue_download_tasks_safe_fk_failure) _run("download_tasks/enqueue_safe_empty_list", test_enqueue_download_tasks_safe_empty_list) _run("download_tasks/enqueue_safe_auto_create_table_v1_29", test_enqueue_download_tasks_safe_auto_create_table_v1_29) _run("login_flow/wait_url_stable_immediate", test_wait_for_url_stable_immediate_match) _run("login_flow/wait_url_stable_transition", test_wait_for_url_stable_transition) _run("login_flow/wait_url_stable_timeout", test_wait_for_url_stable_timeout) _run("login_flow/wait_manual_login_no_goto", test_wait_for_manual_login_no_goto) _run("login_flow/auto_sign_in_spa_chinese_button", test_try_auto_sign_in_spa_login_chinese_button) _run("login_flow/auto_sign_in_root_english_button", test_try_auto_sign_in_root_sign_in_english_button) _run("login_flow/auto_sign_in_no_input_returns_false", test_try_auto_sign_in_no_input_returns_false) _run("login_flow/auto_sign_in_selector_timeout_returns_false", test_try_auto_sign_in_selector_timeout_returns_false) _run("login_flow/wait_login_inputs_filled_auto_click_v1_26", test_wait_for_login_inputs_filled_auto_click_v1_26) _run("login_flow/fill_demo_on_spa_login_basic_v1_27", test_fill_demo_on_spa_login_basic_v1_27) _run("login_flow/fill_demo_on_spa_login_chinese_button_v1_27", test_fill_demo_on_spa_login_chinese_button_v1_27) _run("login_flow/fill_demo_on_spa_login_no_input_returns_false_v1_27", test_fill_demo_on_spa_login_no_input_returns_false_v1_27) _run("show_config/masks_tokens", test_show_config_masks_tokens) _run("show_config/skips_empty", test_show_config_skips_empty) _run("show_config/skips_comments", test_show_config_skips_comments) _run("show_config/handles_quotes", test_show_config_handles_quotes) _run("logs_viewer/nonexistent_file", test_logs_viewer_nonexistent_file) _run("logs_viewer/tail_and_filter", test_logs_viewer_tail_and_filter) _run("logs_viewer/resolves_relative_path", test_logs_viewer_resolves_relative_path) total = len(_RESULTS) passed = sum(1 for _, ok, _ in _RESULTS if ok) failed = total - passed print() print(f"=== Summary: {passed}/{total} passed, {failed} failed ===", flush=True) if failed: print("\nFailures:") for name, ok, msg in _RESULTS: if not ok: print(f" - {name}: {msg}") return 1 return 0 if __name__ == "__main__": raise SystemExit(main())