Files
vrsub/tests/app/test_maintenance/test_cleaner.py
T
cat-shark 8a715a8064 test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、
不保留全局 conftest.py、测试过程只调用真实生产代码。

结构(73 个文件、30 个模块目录、477 用例):
- tests/nodes/  15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/
  subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/
  proper_nouns/adaptive_pool/vad_profiler/echo);
- tests/app/    11 个模块目录(db/scheduler/batch/maintenance/registry/seed/
  storage/config/logging/main/routers 三组 API);
- tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。

测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore
的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。

顺带发现并修复三个真实缺陷:
- nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位),
  改为正文行遇时间戳行即报错;
- src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI
  抛 ValueError 导致任务误判失败,改为同时捕获;
- nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT,
  改用生产模块 nodes/srt.py。

真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py
(运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py
(无 Key / 余额 / 限流转跳过)。全量 477 passed。
2026-09-13 15:40:56 +08:00

258 lines
9.2 KiB
Python

"""src/wov_app/maintenance.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`src/wov_app/maintenance.py`(孤儿数据清理:只删明确死数据),
可独立调用。用例在临时目录构造真实存储布局与真实 SQLite 记录,验证
"该删的删、不该删的绝不删"(R01/R03 的安全约定)。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
from wov_app.db import Database
from wov_app.maintenance import OrphanCleaner
def _now_iso(offset_seconds: int = 0) -> str:
"""返回当前 UTC 时间字符串,可偏移秒数(构造过期/未过期数据)。"""
return (datetime.now(timezone.utc) + timedelta(seconds=offset_seconds)).isoformat()
def _db_with_workflow(tmp_path: Path) -> Database:
"""建好工作流(任务表对 workflow_id 有外键约束)的临时库。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "wf", "name": "流程", "description": ""})
db.create_workflow_version("wf", 1, {"nodes": []})
return db
def _run(run_id: str, **overrides) -> dict:
"""构造真实任务记录。"""
record = {
"id": run_id, "workflow_id": "wf", "workflow_version": 1, "status": "COMPLETED",
"current_node_id": None, "progress": 1.0, "error": None, "input_uri": None,
"param_overrides": None, "source": "upload",
"created_at": _now_iso(-7200), "updated_at": _now_iso(-7200),
}
record.update(overrides)
return record
def _cleaner(db: Database, storage: Path, grace_seconds: int = 3600) -> OrphanCleaner:
"""构造清理器(不启动后台线程,直接调用 clean_once)。"""
return OrphanCleaner(db, storage, interval_seconds=999, grace_seconds=grace_seconds)
# ---------------------------------------------------------------------------
# 残留目录清理
# ---------------------------------------------------------------------------
def test_removes_dangling_upload_and_run_dirs(tmp_path: Path) -> None:
"""无对应任务记录的 uploads/runs 残留目录被删除。"""
# 数据:两个残留目录(库中无记录)。
storage = tmp_path / "storage"
(storage / "uploads" / "ghost-1").mkdir(parents=True)
(storage / "runs" / "ghost-2").mkdir(parents=True)
(storage / "uploads" / "ghost-1" / "video.mp4").write_bytes(b"data")
db = _db_with_workflow(tmp_path)
# 测试过程
removed = _cleaner(db, storage).clean_once()
# 验证结果:两个目录都被清除。
assert removed == 2
assert not (storage / "uploads" / "ghost-1").exists()
assert not (storage / "runs" / "ghost-2").exists()
def test_keeps_dirs_with_task_records(tmp_path: Path) -> None:
"""有任务记录的目录不删(即使任务已完成)。"""
# 数据:一个有效任务及其目录。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
db.create_run(_run("run-1"))
(storage / "runs" / "run-1").mkdir(parents=True)
(storage / "runs" / "run-1" / "out.srt").write_text("字幕", encoding="utf-8")
# 测试过程
_cleaner(db, storage).clean_once()
# 验证结果:目录与任务记录都保留。
assert (storage / "runs" / "run-1" / "out.srt").is_file()
assert db.get_run("run-1") is not None
# ---------------------------------------------------------------------------
# 任务记录清理
# ---------------------------------------------------------------------------
def test_removes_expired_completed_run_without_files(tmp_path: Path) -> None:
"""COMPLETED、超过宽限期、产物文件全失的任务记录被删除。"""
# 数据:过期完成任务,目录为空。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
db.create_run(_run("run-1"))
(storage / "runs" / "run-1").mkdir(parents=True)
# 测试过程
removed = _cleaner(db, storage).clean_once()
# 验证结果
assert removed == 1
assert db.get_run("run-1") is None
def test_keeps_failed_run(tmp_path: Path) -> None:
"""FAILED 任务绝不自动删除(用户可重试)。"""
# 数据:过期失败任务,无产物文件。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
db.create_run(_run("run-failed", status="FAILED", error="boom"))
# 测试过程
removed = _cleaner(db, storage).clean_once()
# 验证结果
assert removed == 0
assert db.get_run("run-failed") is not None
def test_keeps_running_and_queued_runs(tmp_path: Path) -> None:
"""QUEUED / RUNNING 任务不删(可能仍在执行或等待执行)。"""
# 数据:排队中与运行中的任务。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
db.create_run(_run("run-q", status="QUEUED"))
db.create_run(_run("run-r", status="RUNNING"))
# 测试过程
removed = _cleaner(db, storage).clean_once()
# 验证结果
assert removed == 0
assert db.get_run("run-q") is not None
assert db.get_run("run-r") is not None
def test_keeps_completed_run_within_grace_period(tmp_path: Path) -> None:
"""宽限期内的完成任务不删(下载可能还在进行)。"""
# 数据:刚完成、无产物文件的任务。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
db.create_run(_run("run-fresh", updated_at=_now_iso(-10)))
# 测试过程
removed = _cleaner(db, storage, grace_seconds=3600).clean_once()
# 验证结果
assert removed == 0
assert db.get_run("run-fresh") is not None
def test_keeps_completed_run_with_existing_files(tmp_path: Path) -> None:
"""仍有产物文件的完成任务不删(下载仍可用)。"""
# 数据:过期但产物仍存在的任务。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
db.create_run(_run("run-1"))
payload = storage / "runs" / "run-1" / "finals" / "out.srt"
payload.parent.mkdir(parents=True)
payload.write_text("字幕", encoding="utf-8")
# 测试过程
removed = _cleaner(db, storage).clean_once()
# 验证结果
assert removed == 0
assert payload.is_file()
def test_skips_batch_source_runs(tmp_path: Path) -> None:
"""source=batch 的运行跳过清理(工作空间在私有层级,用户媒体目录必须保留)。"""
# 数据:过期完成的批量运行,产物不在主 runs 目录下。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
db.create_run(_run("run-batch", source="batch"))
# 用户媒体目录(若被误删会造成数据丢失)。
media_dir = tmp_path / "user-videos"
media_dir.mkdir()
(media_dir / "movie.mp4").write_bytes(b"video")
# 测试过程
removed = _cleaner(db, storage).clean_once()
# 验证结果:批量 run 与用户媒体都保留。
assert removed == 0
assert db.get_run("run-batch") is not None
assert (media_dir / "movie.mp4").is_file()
def test_keeps_external_input_directory(tmp_path: Path) -> None:
"""删除孤儿任务时不动其 input_uri 指向的外部目录(R01:不误删媒体库)。"""
# 数据:过期完成、无产物的任务,但 input_uri 指向用户媒体目录。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
media_dir = tmp_path / "media"
media_dir.mkdir()
video = media_dir / "movie.mp4"
video.write_bytes(b"video")
db.create_run(_run("run-1", input_uri=str(video)))
# 测试过程
_cleaner(db, storage).clean_once()
# 验证结果:任务记录被清理,但用户媒体目录完整保留。
assert db.get_run("run-1") is None
assert video.is_file()
def test_invalid_timestamp_is_kept(tmp_path: Path) -> None:
"""时间戳无法解析时保守保留(不因数据损坏误删)。"""
# 数据:updated_at 非法。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
db.create_run(_run("run-1", updated_at="not-a-timestamp"))
# 测试过程
removed = _cleaner(db, storage).clean_once()
# 验证结果
assert removed == 0
assert db.get_run("run-1") is not None
# ---------------------------------------------------------------------------
# 线程生命周期
# ---------------------------------------------------------------------------
def test_start_and_stop_are_idempotent(tmp_path: Path) -> None:
"""start 重复调用不产生多个线程;stop 能正常结束线程。"""
# 数据:清理器(间隔很大,循环来不及真正清理)。
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path)
cleaner = OrphanCleaner(db, storage, interval_seconds=999, grace_seconds=3600)
# 测试过程
cleaner.start()
first_thread = cleaner._thread
cleaner.start()
second_thread = cleaner._thread
cleaner.stop()
# 验证结果:同一线程对象,停止后引用清空。
assert first_thread is second_thread
assert cleaner._thread is None
def test_stop_without_start_is_safe(tmp_path: Path) -> None:
"""未启动就 stop 不报错。"""
# 数据:未启动的清理器。
db = _db_with_workflow(tmp_path)
# 测试过程与验证结果:不抛异常。
OrphanCleaner(db, tmp_path / "storage").stop()