2031 lines
82 KiB
Python
2031 lines
82 KiB
Python
#!/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-<contract>-<year>)"""
|
||
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())
|