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。
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user