chore: auto release commit (2026-09-15 00:32:44)
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
2026-09-15 00:32:44 +08:00
parent 693a5e4a53
commit 293b7d1591
15 changed files with 1715 additions and 16 deletions

View File

@@ -241,7 +241,30 @@ def test_data_management_actions() -> tuple[bool, str]:
return False, f"{action_id} 未绑定合同台账"
if action.get("entrypoint", {}).get("command") != action_id:
return False, f"{action_id} CLI command 映射错误"
return True, "5 个数据管理动作均绑定合同台账并映射到 CLI"
# 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]:
@@ -741,6 +764,643 @@ def test_parse_alert_red_orange_yellow_unknown() -> tuple[bool, str]:
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()
# ---------- 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:
@@ -785,6 +1445,25 @@ def main() -> int:
_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("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)