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,414 @@
|
||||
"""src/wov_app/batch.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/batch.py`(文件夹批量处理:扫描定位、旁挂字幕跳过、
|
||||
引擎执行与产物放置),可独立调用。用例在临时目录构造真实视频/字幕文件与
|
||||
真实 SQLite 记录,使用真实 echo 节点跑通执行链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wov_app import registry
|
||||
from wov_app.batch import (
|
||||
MARKER_NAME,
|
||||
SUBTITLE_EXTENSIONS,
|
||||
VIDEO_EXTENSIONS,
|
||||
BatchWorker,
|
||||
_sidecar_product_name,
|
||||
create_job,
|
||||
list_sidecar_subtitles,
|
||||
load_marker,
|
||||
remove_job_workspace,
|
||||
scan_videos,
|
||||
)
|
||||
from wov_app.db import Database
|
||||
from wov_sdk.models import WorkflowDefinition
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registry():
|
||||
"""用例前清空注册表、用例后恢复快照:保证用例看到的是干净基线,
|
||||
不受其他模块(如 main 生命周期 register_all)的注册结果影响。"""
|
||||
snapshot = dict(registry._registry)
|
||||
registry._registry.clear()
|
||||
yield
|
||||
registry._registry.clear()
|
||||
registry._registry.update(snapshot)
|
||||
|
||||
|
||||
def _make_video(path: Path) -> Path:
|
||||
"""创建真实可读的视频文件(内容不重要,但必须是真实文件)。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 64)
|
||||
return path
|
||||
|
||||
|
||||
def _echo_definition(final_output: str = "step.file_uri") -> WorkflowDefinition:
|
||||
"""单 echo 节点的真实工作流定义。"""
|
||||
return WorkflowDefinition.from_dict({
|
||||
"name": "批量流程",
|
||||
"version": 1,
|
||||
"nodes": [{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}],
|
||||
"edges": [],
|
||||
"entry_inputs": {"video_uri": "file"},
|
||||
"final_outputs": {"result": final_output},
|
||||
})
|
||||
|
||||
|
||||
def _published_db(tmp_path: Path, workflow_id: str = "wf") -> Database:
|
||||
"""建好已发布工作流(含版本)的临时库。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_workflow({
|
||||
"id": workflow_id, "name": "批量流程", "description": "", "published": 1,
|
||||
"latest_version": 1,
|
||||
})
|
||||
db.create_workflow_version(workflow_id, 1, _echo_definition().to_dict())
|
||||
return db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扫描与旁挂字幕判定
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_scan_videos_recursive_and_flat(tmp_path: Path) -> None:
|
||||
"""递归扫描包含子目录视频;非递归只扫顶层;结果按路径排序。"""
|
||||
# 数据:顶层 2 个视频 + 子目录 1 个视频 + 1 个非视频文件。
|
||||
_make_video(tmp_path / "b.mp4")
|
||||
_make_video(tmp_path / "a.mkv")
|
||||
_make_video(tmp_path / "sub" / "c.mp4")
|
||||
(tmp_path / "note.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
recursive = [p.name for p in scan_videos(tmp_path, recursive=True)]
|
||||
flat = [p.name for p in scan_videos(tmp_path, recursive=False)]
|
||||
|
||||
# 验证结果
|
||||
assert recursive == ["a.mkv", "b.mp4", "c.mp4"]
|
||||
assert flat == ["a.mkv", "b.mp4"]
|
||||
|
||||
|
||||
def test_video_extensions_are_lowercase_dotted() -> None:
|
||||
"""视频扩展名集合为小写带点形式(与 suffix.lower() 比较一致)。"""
|
||||
# 数据:模块常量。
|
||||
# 测试过程与验证结果
|
||||
assert all(ext.startswith(".") and ext.islower() for ext in VIDEO_EXTENSIONS)
|
||||
assert ".mp4" in VIDEO_EXTENSIONS and ".mkv" in VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
def test_list_sidecar_subtitles_matches_by_stem(tmp_path: Path) -> None:
|
||||
"""视频旁含视频主名的字幕文件被识别(含 CN/dual_eye 等约定命名)。"""
|
||||
# 数据:视频 + 三种约定命名的字幕 + 一个无关文件。
|
||||
video = _make_video(tmp_path / "movie.mp4")
|
||||
expected = [
|
||||
tmp_path / "movie.CN.srt",
|
||||
tmp_path / "movie.CN_dual_eye.ass",
|
||||
tmp_path / "movie.srt",
|
||||
]
|
||||
for path in expected:
|
||||
path.write_text("1\n", encoding="utf-8")
|
||||
(tmp_path / "other.srt").write_text("1\n", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
found = list_sidecar_subtitles(video)
|
||||
|
||||
# 验证结果:三个匹配、无关文件不在结果里。
|
||||
assert set(found) == set(expected)
|
||||
assert (tmp_path / "other.srt") not in found
|
||||
|
||||
|
||||
def test_list_sidecar_subtitles_ignores_non_subtitle_files(tmp_path: Path) -> None:
|
||||
"""同名但非字幕扩展名的文件不算旁挂字幕。"""
|
||||
# 数据:视频 + 同名字幕 + 同名文本。
|
||||
video = _make_video(tmp_path / "movie.mp4")
|
||||
srt = tmp_path / "movie.srt"
|
||||
srt.write_text("1\n", encoding="utf-8")
|
||||
(tmp_path / "movie.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
found = list_sidecar_subtitles(video)
|
||||
|
||||
# 验证结果
|
||||
assert found == [srt]
|
||||
|
||||
|
||||
def test_list_sidecar_subtitles_short_stem_requires_dot_prefix(tmp_path: Path) -> None:
|
||||
"""视频主名只有一个字符时只接受"主名."前缀,避免 a.mp4 误配 apple.srt。"""
|
||||
# 数据:a.mp4 + apple.srt(不应命中)+ a.srt(应命中)。
|
||||
video = _make_video(tmp_path / "a.mp4")
|
||||
(tmp_path / "apple.srt").write_text("1\n", encoding="utf-8")
|
||||
good = tmp_path / "a.srt"
|
||||
good.write_text("1\n", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
found = list_sidecar_subtitles(video)
|
||||
|
||||
# 验证结果
|
||||
assert found == [good]
|
||||
|
||||
|
||||
def test_subtitle_extensions_cover_common_formats() -> None:
|
||||
"""字幕扩展名覆盖 srt/ass/ssa/vtt。"""
|
||||
# 数据:模块常量。
|
||||
# 测试过程与验证结果
|
||||
assert SUBTITLE_EXTENSIONS == {".srt", ".ass", ".ssa", ".vtt"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 产物命名映射
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sidecar_product_name_maps_srt_and_ass() -> None:
|
||||
"""最终产物映射为媒体库约定名(.srt → CN.srt,.ass → CN_dual_eye.ass)。"""
|
||||
# 数据:视频与两类最终产物。
|
||||
video = Path("/videos/movie.mp4")
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert _sidecar_product_name(video, Path("/tmp/x.zh-CN.20260101.srt")) == "movie.CN.srt"
|
||||
assert _sidecar_product_name(video, Path("/tmp/x.ass")) == "movie.CN_dual_eye.ass"
|
||||
|
||||
|
||||
def test_sidecar_product_name_keeps_other_extensions() -> None:
|
||||
"""其他扩展名产物保留原文件名(不误改语义)。"""
|
||||
# 数据:vtt 产物。
|
||||
# 测试过程与验证结果
|
||||
assert _sidecar_product_name(Path("/v/movie.mp4"), Path("/tmp/movie.vtt")) == "movie.vtt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 完成标记与工作空间清理
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_marker_reads_valid_json(tmp_path: Path) -> None:
|
||||
"""旧版完成标记(batch.done.json)可读回字典。"""
|
||||
# 数据:真实标记文件。
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
payload = {"finals": {"cn_srt_uri": "/videos/movie.CN.srt"}}
|
||||
(work / MARKER_NAME).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
marker = load_marker(work)
|
||||
|
||||
# 验证结果
|
||||
assert marker == payload
|
||||
|
||||
|
||||
def test_load_marker_returns_none_for_corrupt_or_missing(tmp_path: Path) -> None:
|
||||
"""标记缺失或内容损坏时返回 None(走旁挂字幕判定,不报错)。"""
|
||||
# 数据:不存在标记 + 损坏标记。
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
assert load_marker(work) is None
|
||||
(work / MARKER_NAME).write_text("{broken", encoding="utf-8")
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert load_marker(work) is None
|
||||
|
||||
|
||||
def test_remove_job_workspace_only_touches_private_dir(tmp_path: Path, monkeypatch) -> None:
|
||||
"""删除任务只清理应用私有工作空间,不触碰用户视频目录。"""
|
||||
# 数据:私有工作空间 + 用户媒体目录。
|
||||
private = tmp_path / "storage" / "batch" / "job-1"
|
||||
private.mkdir(parents=True)
|
||||
(private / "temp.wav").write_bytes(b"x")
|
||||
media = tmp_path / "media"
|
||||
_make_video(media / "movie.mp4")
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
|
||||
# 测试过程
|
||||
remove_job_workspace("job-1")
|
||||
|
||||
# 验证结果:私有空间被删,用户目录完整。
|
||||
assert not private.exists()
|
||||
assert (media / "movie.mp4").is_file()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 创建批量任务:一次性定位
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_job_registers_pending_and_skipped(tmp_path: Path) -> None:
|
||||
"""创建任务一次性定位视频:无字幕记 PENDING,已有字幕记 SKIPPED。"""
|
||||
# 数据:3 个视频,其中一个已有旁挂字幕。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
_make_video(folder / "b.mp4")
|
||||
_make_video(folder / "c.mp4")
|
||||
(folder / "b.CN.srt").write_text("1\n", encoding="utf-8")
|
||||
db = _published_db(tmp_path)
|
||||
|
||||
# 测试过程
|
||||
job_id = create_job(db, str(folder), "wf", recursive=False)
|
||||
videos = db.list_batch_videos(job_id)
|
||||
|
||||
# 验证结果:状态分布正确,总数只算待处理。
|
||||
statuses = {Path(v["video_path"]).name: v["status"] for v in videos}
|
||||
assert statuses == {"a.mp4": "PENDING", "b.mp4": "SKIPPED", "c.mp4": "PENDING"}
|
||||
job = db.get_batch_job(job_id)
|
||||
assert job["total"] == 2
|
||||
assert job["status"] == "QUEUED"
|
||||
|
||||
|
||||
def test_create_job_completes_immediately_when_all_skipped(tmp_path: Path) -> None:
|
||||
"""全部视频都已有字幕时任务直接完成,不排队不触发流水线。"""
|
||||
# 数据:两个视频都带字幕。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
_make_video(folder / "b.mp4")
|
||||
(folder / "a.srt").write_text("1\n", encoding="utf-8")
|
||||
(folder / "b.srt").write_text("1\n", encoding="utf-8")
|
||||
db = _published_db(tmp_path)
|
||||
|
||||
# 测试过程
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
|
||||
# 验证结果
|
||||
job = db.get_batch_job(job_id)
|
||||
assert job["status"] == "COMPLETED"
|
||||
assert job["total"] == 0
|
||||
assert db.next_queued_batch_job() is None
|
||||
|
||||
|
||||
def test_create_job_uses_private_work_dir(tmp_path: Path, monkeypatch) -> None:
|
||||
"""明细的工作空间位于应用私有目录(与用户媒体库隔离)。"""
|
||||
# 数据:一个视频。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
db = _published_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
|
||||
# 测试过程
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
video = db.list_batch_videos(job_id)[0]
|
||||
|
||||
# 验证结果:work_dir 在私有 storage/batch 下,且不在视频目录内。
|
||||
work_dir = Path(video["work_dir"])
|
||||
assert (tmp_path / "storage" / "batch") in work_dir.parents
|
||||
assert folder not in work_dir.parents
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder_setup", "workflow_id", "message"),
|
||||
[
|
||||
("missing", "wf", "folder not found"),
|
||||
("empty", "wf", "no videos found"),
|
||||
("ok", "unknown", "published workflow not found"),
|
||||
],
|
||||
)
|
||||
def test_create_job_rejects_invalid_input(tmp_path: Path, folder_setup: str, workflow_id: str, message: str) -> None:
|
||||
"""校验失败时抛 ValueError(路由层转 422):目录缺失/无视频/工作流未发布。"""
|
||||
# 数据:按参数准备目录与工作流。
|
||||
folder = tmp_path / "videos"
|
||||
if folder_setup == "empty":
|
||||
folder.mkdir()
|
||||
elif folder_setup == "ok":
|
||||
_make_video(folder / "a.mp4")
|
||||
db = _published_db(tmp_path)
|
||||
|
||||
# 测试过程与验证结果
|
||||
with pytest.raises(ValueError, match=message):
|
||||
create_job(db, str(folder), workflow_id)
|
||||
|
||||
|
||||
def test_create_job_rejects_workflow_without_version(tmp_path: Path) -> None:
|
||||
"""已发布但无版本记录的工作流被拒绝(无法执行)。"""
|
||||
# 数据:有工作流记录但无版本。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_workflow({"id": "wf", "name": "无版本", "description": "", "published": 1})
|
||||
|
||||
# 测试过程与验证结果
|
||||
with pytest.raises(ValueError, match="no version"):
|
||||
create_job(db, str(folder), "wf")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 引擎:执行、产物放置、暂停
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_worker_processes_pending_video_end_to_end(tmp_path: Path, monkeypatch) -> None:
|
||||
"""引擎处理待处理视频:跑通流水线、产物放到视频旁、明细与任务标记完成。"""
|
||||
# 数据:一个视频 + echo 单节点工作流(产物为复制后的输入文件)。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
db = _published_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
registry.register_all()
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
|
||||
# 测试过程:直接驱动一轮处理(避免后台线程时序不确定)。
|
||||
worker = BatchWorker(db, interval_seconds=999)
|
||||
worker._process_job(db.get_batch_job(job_id))
|
||||
|
||||
# 验证结果:明细与任务完成,产物按约定名放到视频旁,工作空间被清理。
|
||||
item = db.list_batch_videos(job_id)[0]
|
||||
assert item["status"] == "COMPLETED"
|
||||
assert db.get_batch_job(job_id)["status"] == "COMPLETED"
|
||||
# echo 节点产物是 .txt,按约定保留原文件名放置在视频旁。
|
||||
assert list(folder.glob("movie.*")), "应在视频旁放置最终产物"
|
||||
|
||||
|
||||
def test_worker_skips_video_with_sidecar_subtitle(tmp_path: Path, monkeypatch) -> None:
|
||||
"""已有旁挂字幕的视频不触发流水线(SKIPPED 不产生 run)。"""
|
||||
# 数据:一个已带字幕的视频。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
(folder / "movie.CN.srt").write_text("1\n", encoding="utf-8")
|
||||
_make_video(folder / "other.mp4")
|
||||
db = _published_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
registry.register_all()
|
||||
|
||||
# 测试过程
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
|
||||
# 验证结果:SKIPPED 视频没有 run_id。
|
||||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job_id)}
|
||||
assert videos["movie.mp4"]["status"] == "SKIPPED"
|
||||
assert videos["movie.mp4"]["run_id"] is None
|
||||
assert videos["other.mp4"]["status"] == "PENDING"
|
||||
|
||||
|
||||
def test_worker_pause_sets_job_paused(tmp_path: Path, monkeypatch) -> None:
|
||||
"""暂停批量任务:任务状态置 PAUSED,待处理视频不被推进。"""
|
||||
# 数据:一个待处理视频的任务。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
db = _published_db(tmp_path)
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
job_id = create_job(db, str(folder), "wf")
|
||||
|
||||
# 测试过程
|
||||
db.update_batch_job(job_id, status="PAUSED", updated_at="2026-09-01T01:00:00+00:00")
|
||||
|
||||
# 验证结果:不会被 next_queued_batch_job 拾起(等待显式 resume)。
|
||||
assert db.next_queued_batch_job() is None
|
||||
assert db.get_batch_job(job_id)["status"] == "PAUSED"
|
||||
|
||||
|
||||
def test_worker_start_stop_idempotent(tmp_path: Path) -> None:
|
||||
"""引擎 start 重复调用不产生多余线程;stop 正常结束。"""
|
||||
# 数据:空库。
|
||||
db = Database(tmp_path / "wov.db")
|
||||
worker = BatchWorker(db, interval_seconds=999)
|
||||
|
||||
# 测试过程
|
||||
worker.start()
|
||||
first = worker._thread
|
||||
worker.start()
|
||||
second = worker._thread
|
||||
worker.stop()
|
||||
|
||||
# 验证结果
|
||||
assert first is second
|
||||
assert worker._thread is None
|
||||
@@ -0,0 +1,194 @@
|
||||
"""src/wov_app/config.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/config.py`(环境变量读取与路径推导),可独立调用。
|
||||
由于该模块在**导入时**锁定路径常量,隔离用例在子进程中运行(真实导入路径
|
||||
+ 干净环境),其余用例校验默认值与类型。
|
||||
|
||||
注意:config 的路径常量在进程内已固化,因此"环境变量覆盖"必须用子进程验证,
|
||||
否则测的是缓存值而非真实行为(这也是规则要求"可独立运行、不依赖外部配置"
|
||||
的具体体现)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wov_app import config
|
||||
|
||||
# 仓库根目录(config.WORKSPACE_ROOT 应该指向它)。
|
||||
WORKSPACE = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
# 与路径相关的环境变量:子进程验证前必须清除,否则会继承外层测试隔离值。
|
||||
_PATH_ENV_KEYS = (
|
||||
"WOV_DATA_DIR", "WOV_DB_PATH", "WOV_STORAGE_DIR",
|
||||
"WOV_SCHEDULER_INTERVAL_SECONDS", "WOV_BATCH_INTERVAL_SECONDS",
|
||||
"WOV_CLEANUP_INTERVAL_SECONDS", "WOV_CLEANUP_GRACE_SECONDS",
|
||||
"WOV_BATCH_ENABLED", "WOV_CLEANUP_ENABLED",
|
||||
)
|
||||
|
||||
|
||||
def _run_in_subprocess(code: str, env: dict[str, str] | None = None) -> dict:
|
||||
"""在干净子进程中导入 config 并返回指定变量(真实进程隔离)。
|
||||
|
||||
先清除全部相关环境变量,再用 env 注入本次用例的值,保证测到的是
|
||||
config 的真实解析逻辑而不是外层测试运行环境。
|
||||
"""
|
||||
script = (
|
||||
"import json\n"
|
||||
"from wov_app import config\n"
|
||||
f"{code}\n"
|
||||
"print(json.dumps(result))\n"
|
||||
)
|
||||
clean_env = {k: v for k, v in __import__("os").environ.items() if k not in _PATH_ENV_KEYS}
|
||||
clean_env.update(env or {})
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(WORKSPACE),
|
||||
env=clean_env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 默认值(当前进程内已导入的常量)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workspace_root_points_to_repo_root() -> None:
|
||||
"""WORKSPACE_ROOT 指向仓库根(用于推导其余路径)。"""
|
||||
# 数据:模块常量。
|
||||
# 测试过程与验证结果
|
||||
assert config.WORKSPACE_ROOT == WORKSPACE
|
||||
assert (config.WORKSPACE_ROOT / "pyproject.toml").is_file()
|
||||
|
||||
|
||||
def test_default_paths_are_under_data_dir() -> None:
|
||||
"""无环境变量时默认路径为 <仓库根>/data 下的推导值(子进程验证真实默认)。"""
|
||||
# 数据:清空全部相关环境变量。
|
||||
# 测试过程
|
||||
result = _run_in_subprocess(
|
||||
"result = {'root': str(config.WORKSPACE_ROOT), 'data': str(config.DATA_DIR), "
|
||||
"'db': str(config.DB_PATH), 'storage': str(config.STORAGE_DIR), "
|
||||
"'batch': config.BATCH_ENABLED, 'cleanup': config.CLEANUP_ENABLED}"
|
||||
)
|
||||
|
||||
# 验证结果:默认 data 目录在仓库根下,db/storage 由它推导,开关默认开。
|
||||
assert result["root"] == str(WORKSPACE)
|
||||
assert result["data"] == str(WORKSPACE / "data")
|
||||
assert result["db"] == str(WORKSPACE / "data" / "wov.db")
|
||||
assert result["storage"] == str(WORKSPACE / "data" / "storage")
|
||||
assert result["batch"] is True
|
||||
assert result["cleanup"] is True
|
||||
|
||||
|
||||
def test_numeric_settings_are_floats() -> None:
|
||||
"""数值型配置被解析为 float(避免字符串参与算术)。"""
|
||||
# 数据:模块常量。
|
||||
# 测试过程与验证结果
|
||||
for value in (
|
||||
config.SCHEDULER_INTERVAL_SECONDS,
|
||||
config.BATCH_INTERVAL_SECONDS,
|
||||
config.CLEANUP_INTERVAL_SECONDS,
|
||||
config.CLEANUP_GRACE_SECONDS,
|
||||
):
|
||||
assert isinstance(value, float)
|
||||
assert value > 0
|
||||
|
||||
|
||||
def test_boolean_settings_are_bool() -> None:
|
||||
"""布尔型开关被解析为 bool,默认全部开启(1)。"""
|
||||
# 数据:模块常量(测试运行环境由隔离层设为 0,故仅校验类型)。
|
||||
# 测试过程与验证结果
|
||||
for value in (config.BATCH_ENABLED, config.CLEANUP_ENABLED):
|
||||
assert isinstance(value, bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 环境变量覆盖(子进程真实导入)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_data_dir_env_override_changes_all_derived_paths(tmp_path: Path) -> None:
|
||||
"""WOV_DATA_DIR 覆盖后,DB_PATH 与 STORAGE_DIR 随之推导(路径联动)。"""
|
||||
# 数据:自定义数据目录。
|
||||
custom = tmp_path / "custom-data"
|
||||
|
||||
# 测试过程
|
||||
result = _run_in_subprocess(
|
||||
"result = {'data': str(config.DATA_DIR), 'db': str(config.DB_PATH), "
|
||||
"'storage': str(config.STORAGE_DIR)}",
|
||||
env={"WOV_DATA_DIR": str(custom)},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert result["data"] == str(custom)
|
||||
assert result["db"] == str(custom / "wov.db")
|
||||
assert result["storage"] == str(custom / "storage")
|
||||
|
||||
|
||||
def test_explicit_db_and_storage_env_take_precedence(tmp_path: Path) -> None:
|
||||
"""WOV_DB_PATH / WOV_STORAGE_DIR 可独立覆盖(不跟随 DATA_DIR)。"""
|
||||
# 数据:分别指定的数据库与存储路径。
|
||||
db_path = tmp_path / "x" / "custom.db"
|
||||
storage = tmp_path / "y" / "store"
|
||||
|
||||
# 测试过程
|
||||
result = _run_in_subprocess(
|
||||
"result = {'db': str(config.DB_PATH), 'storage': str(config.STORAGE_DIR)}",
|
||||
env={"WOV_DB_PATH": str(db_path), "WOV_STORAGE_DIR": str(storage)},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert result["db"] == str(db_path)
|
||||
assert result["storage"] == str(storage)
|
||||
|
||||
|
||||
def test_boolean_env_parsing() -> None:
|
||||
"""开关型环境变量:'1' 为 True,'0' 为 False。"""
|
||||
# 数据:批量与清理开关分别置 1 与 0。
|
||||
# 测试过程
|
||||
result = _run_in_subprocess(
|
||||
"result = {'batch': config.BATCH_ENABLED, 'cleanup': config.CLEANUP_ENABLED}",
|
||||
env={"WOV_BATCH_ENABLED": "1", "WOV_CLEANUP_ENABLED": "0"},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert result["batch"] is True
|
||||
assert result["cleanup"] is False
|
||||
|
||||
|
||||
def test_interval_env_parsing() -> None:
|
||||
"""轮询间隔环境变量被解析为对应浮点值。"""
|
||||
# 数据:指定的调度与批量间隔。
|
||||
# 测试过程
|
||||
result = _run_in_subprocess(
|
||||
"result = {'sched': config.SCHEDULER_INTERVAL_SECONDS, "
|
||||
"'batch': config.BATCH_INTERVAL_SECONDS}",
|
||||
env={"WOV_SCHEDULER_INTERVAL_SECONDS": "2.5", "WOV_BATCH_INTERVAL_SECONDS": "0.25"},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert result["sched"] == 2.5
|
||||
assert result["batch"] == 0.25
|
||||
|
||||
|
||||
def test_paths_are_pathlib_objects(tmp_path: Path) -> None:
|
||||
"""路径配置是 pathlib.Path(跨平台,不写死 Windows 盘符)。"""
|
||||
# 数据:自定义数据目录。
|
||||
# 测试过程
|
||||
result = _run_in_subprocess(
|
||||
"result = {'is_path': isinstance(config.DATA_DIR, __import__('pathlib').Path)}",
|
||||
env={"WOV_DATA_DIR": str(tmp_path / "d")},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert result["is_path"] is True
|
||||
@@ -0,0 +1,332 @@
|
||||
"""src/wov_app/db.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/db.py`(SQLite Repository:工作流/版本/任务/产物/
|
||||
批量任务),可独立调用。每个用例在临时目录创建独立数据库文件(真实 SQLite),
|
||||
不依赖全局 conftest。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wov_app.db import Database
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path: Path) -> Database:
|
||||
"""每个用例一个独立 SQLite 库(真实文件,非内存桩)。"""
|
||||
return Database(tmp_path / "wov.db")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_with_workflow(db: Database) -> Database:
|
||||
"""已建好工作流(含 v1 版本)的库:任务表对 workflow_id 有外键约束。"""
|
||||
db.upsert_workflow(_workflow("wf"))
|
||||
db.create_workflow_version("wf", 1, {"nodes": []})
|
||||
return db
|
||||
|
||||
|
||||
def _workflow(workflow_id: str = "wf", name: str = "流程") -> dict:
|
||||
"""构造真实工作流记录字段。"""
|
||||
return {"id": workflow_id, "name": name, "description": ""}
|
||||
|
||||
|
||||
def _run(run_id: str = "run-1", **overrides) -> dict:
|
||||
"""构造真实任务记录字段(默认 upload 来源、QUEUED 状态)。"""
|
||||
record = {
|
||||
"id": run_id,
|
||||
"workflow_id": "wf",
|
||||
"workflow_version": 1,
|
||||
"status": "QUEUED",
|
||||
"current_node_id": None,
|
||||
"progress": 0.0,
|
||||
"error": None,
|
||||
"input_uri": None,
|
||||
"param_overrides": None,
|
||||
"source": "upload",
|
||||
"created_at": "2026-09-01T00:00:00+00:00",
|
||||
"updated_at": "2026-09-01T00:00:00+00:00",
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工作流与版本
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upsert_and_get_workflow(db: Database) -> None:
|
||||
"""工作流写入后可读回,重复写入同 ID 覆盖而不报错。"""
|
||||
# 数据:一条工作流记录。
|
||||
db.upsert_workflow(_workflow("wf-1", "初版"))
|
||||
|
||||
# 测试过程
|
||||
stored = db.get_workflow("wf-1")
|
||||
db.upsert_workflow(_workflow("wf-1", "改名"))
|
||||
renamed = db.get_workflow("wf-1")
|
||||
|
||||
# 验证结果
|
||||
assert stored["name"] == "初版"
|
||||
assert renamed["name"] == "改名"
|
||||
|
||||
|
||||
def test_list_and_delete_workflow(db: Database) -> None:
|
||||
"""列出全部工作流;删除后不再出现在列表与查询中。"""
|
||||
# 数据:两条工作流。
|
||||
db.upsert_workflow(_workflow("wf-1"))
|
||||
db.upsert_workflow(_workflow("wf-2"))
|
||||
|
||||
# 测试过程
|
||||
before = {w["id"] for w in db.list_workflows()}
|
||||
db.delete_workflow("wf-1")
|
||||
after = {w["id"] for w in db.list_workflows()}
|
||||
|
||||
# 验证结果
|
||||
assert before == {"wf-1", "wf-2"}
|
||||
assert after == {"wf-2"}
|
||||
assert db.get_workflow("wf-1") is None
|
||||
|
||||
|
||||
def test_workflow_versions_and_latest(db: Database) -> None:
|
||||
"""版本按序保存,latest 返回最高版本,可按版本号精确读取。"""
|
||||
# 数据:同一工作流的 v1 与 v2 定义。
|
||||
db.upsert_workflow(_workflow("wf-1"))
|
||||
db.create_workflow_version("wf-1", 1, {"nodes": [{"id": "a"}]})
|
||||
db.create_workflow_version("wf-1", 2, {"nodes": [{"id": "a"}, {"id": "b"}]})
|
||||
|
||||
# 测试过程
|
||||
latest = db.get_latest_workflow_version("wf-1")
|
||||
first = db.get_workflow_version("wf-1", 1)
|
||||
versions = db.list_workflow_versions("wf-1")
|
||||
|
||||
# 验证结果
|
||||
assert latest["version"] == 2
|
||||
assert len(latest["definition"]["nodes"]) == 2
|
||||
assert first["definition"]["nodes"] == [{"id": "a"}]
|
||||
assert [v["version"] for v in versions] == [2, 1]
|
||||
|
||||
|
||||
def test_workflow_version_missing_returns_none(db: Database) -> None:
|
||||
"""不存在的工作流/版本返回 None(不做隐式创建)。"""
|
||||
# 数据:空库。
|
||||
# 测试过程与验证结果
|
||||
assert db.get_latest_workflow_version("nope") is None
|
||||
assert db.get_workflow_version("nope", 1) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 任务:创建、读写、param_overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_and_get_run_with_overrides(db_with_workflow: Database) -> None:
|
||||
"""任务创建后可读回,param_overrides 以 JSON 存储并解析回字典。"""
|
||||
# 数据:带参数覆盖的 upload 任务。
|
||||
db_with_workflow.create_run(_run("r1", param_overrides={"frame-extract": {"crop": [0, 0.75, 1, 0.25]}}))
|
||||
|
||||
# 测试过程
|
||||
stored = db_with_workflow.get_run("r1")
|
||||
|
||||
# 验证结果
|
||||
assert stored["status"] == "QUEUED"
|
||||
assert stored["source"] == "upload"
|
||||
assert stored["param_overrides"] == {"frame-extract": {"crop": [0, 0.75, 1, 0.25]}}
|
||||
|
||||
|
||||
def test_update_run_fields(db_with_workflow: Database) -> None:
|
||||
"""update_run 按字段更新(状态/进度/错误)。"""
|
||||
# 数据:一条任务。
|
||||
db_with_workflow.create_run(_run("r1"))
|
||||
|
||||
# 测试过程
|
||||
db_with_workflow.update_run("r1", status="RUNNING", progress=0.5, error=None, updated_at="t2")
|
||||
stored = db_with_workflow.get_run("r1")
|
||||
|
||||
# 验证结果
|
||||
assert stored["status"] == "RUNNING"
|
||||
assert stored["progress"] == 0.5
|
||||
|
||||
|
||||
def test_list_runs_orders_by_created_at_desc(db_with_workflow: Database) -> None:
|
||||
"""任务列表按创建时间倒序返回(新的在前)。"""
|
||||
# 数据:三条不同创建时间的任务。
|
||||
db_with_workflow.create_run(_run("old", created_at="2026-09-01T00:00:00+00:00"))
|
||||
db_with_workflow.create_run(_run("mid", created_at="2026-09-02T00:00:00+00:00"))
|
||||
db_with_workflow.create_run(_run("new", created_at="2026-09-03T00:00:00+00:00"))
|
||||
|
||||
# 测试过程
|
||||
ids = [r["id"] for r in db_with_workflow.list_runs()]
|
||||
|
||||
# 验证结果
|
||||
assert ids == ["new", "mid", "old"]
|
||||
|
||||
|
||||
def test_delete_run_removes_record_and_artifacts(db_with_workflow: Database) -> None:
|
||||
"""删除任务同时清理其产物记录。"""
|
||||
# 数据:任务 + 一条产物。
|
||||
db_with_workflow.create_run(_run("r1"))
|
||||
db_with_workflow.create_artifact({
|
||||
"run_id": "r1", "node_id": "a", "name": "a.data_uri", "uri": "/tmp/x", "kind": "file",
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
db_with_workflow.delete_run("r1")
|
||||
|
||||
# 验证结果
|
||||
assert db_with_workflow.get_run("r1") is None
|
||||
assert db_with_workflow.list_artifacts("r1") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 调度用查询:只有 QUEUED 会被拾起
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_next_queued_run_returns_oldest_queued(db_with_workflow: Database) -> None:
|
||||
"""按创建时间返回最早的 QUEUED 任务。"""
|
||||
# 数据:一条较早的 QUEUED。
|
||||
db_with_workflow.create_run(_run("a", created_at="2026-09-01T00:00:00+00:00"))
|
||||
|
||||
# 测试过程
|
||||
picked = db_with_workflow.next_queued_run()
|
||||
|
||||
# 验证结果
|
||||
assert picked["id"] == "a"
|
||||
|
||||
|
||||
def test_next_queued_run_skips_paused(db_with_workflow: Database) -> None:
|
||||
"""PAUSED 不被拾起(必须显式 resume;修复"点击暂停反而开始任务"回归)。"""
|
||||
# 数据:一条 PAUSED(更早)+ 一条 QUEUED(更晚)。
|
||||
db_with_workflow.create_run(_run("paused", status="PAUSED", created_at="2026-09-01T00:00:00+00:00"))
|
||||
db_with_workflow.create_run(_run("queued", created_at="2026-09-02T00:00:00+00:00"))
|
||||
|
||||
# 测试过程
|
||||
picked = db_with_workflow.next_queued_run()
|
||||
|
||||
# 验证结果:取的是 QUEUED 那条。
|
||||
assert picked["id"] == "queued"
|
||||
|
||||
|
||||
def test_next_queued_run_skips_batch_source(db_with_workflow: Database) -> None:
|
||||
"""source=batch 的任务由批量引擎执行,主调度器不拾起(存储目录不同)。"""
|
||||
# 数据:一条 batch 来源的 QUEUED。
|
||||
db_with_workflow.create_run(_run("batch-1", source="batch"))
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert db_with_workflow.next_queued_run() is None
|
||||
|
||||
|
||||
def test_pause_and_resume_run(db_with_workflow: Database) -> None:
|
||||
"""暂停置 PAUSED,继续置回 QUEUED(等待调度器断点续跑)。"""
|
||||
# 数据:一条 QUEUED。
|
||||
db_with_workflow.create_run(_run("r1"))
|
||||
|
||||
# 测试过程
|
||||
db_with_workflow.pause_run("r1", "t2")
|
||||
paused = db_with_workflow.get_run("r1")["status"]
|
||||
db_with_workflow.resume_run("r1", "t3")
|
||||
resumed = db_with_workflow.get_run("r1")["status"]
|
||||
|
||||
# 验证结果
|
||||
assert paused == "PAUSED"
|
||||
assert resumed == "QUEUED"
|
||||
|
||||
|
||||
def test_recover_interrupted_runs_requeues_running_only(db_with_workflow: Database) -> None:
|
||||
"""重启恢复:RUNNING → QUEUED,PAUSED 保持不变。"""
|
||||
# 数据:RUNNING 与 PAUSED 各一条。
|
||||
db_with_workflow.create_run(_run("running", status="RUNNING"))
|
||||
db_with_workflow.create_run(_run("paused", status="PAUSED"))
|
||||
|
||||
# 测试过程
|
||||
count = db_with_workflow.recover_interrupted_runs("t2")
|
||||
|
||||
# 验证结果:只恢复 1 条,PAUSED 不变。
|
||||
assert count == 1
|
||||
assert db_with_workflow.get_run("running")["status"] == "QUEUED"
|
||||
assert db_with_workflow.get_run("paused")["status"] == "PAUSED"
|
||||
|
||||
|
||||
def test_recover_interrupted_batch_jobs_requeues_running(db_with_workflow: Database) -> None:
|
||||
"""重启恢复:RUNNING 的批量任务 → QUEUED(否则永久无人拾起)。"""
|
||||
# 数据:一条 RUNNING 批量任务(批量明细表对 run 有外键,先建任务记录)。
|
||||
db_with_workflow.create_run(_run("bv-run-1"))
|
||||
db_with_workflow.create_batch_job({
|
||||
"id": "job-1", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||||
"status": "RUNNING", "created_at": "t1", "updated_at": "t1",
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
count = db_with_workflow.recover_interrupted_batch_jobs("t2")
|
||||
|
||||
# 验证结果
|
||||
assert count == 1
|
||||
assert db_with_workflow.get_batch_job("job-1")["status"] == "QUEUED"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 产物与断点恢复
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_and_get_artifact(db_with_workflow: Database) -> None:
|
||||
"""产物按 run + name 记录,可按名精确读取。"""
|
||||
# 数据:一条任务 + 一条产物(产物对 run 有外键)。
|
||||
db_with_workflow.create_run(_run("r1"))
|
||||
db_with_workflow.create_artifact({
|
||||
"run_id": "r1", "node_id": "ocr", "name": "ocr.srt_uri",
|
||||
"uri": "/tmp/out/subtitle.srt", "kind": "file",
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
stored = db_with_workflow.get_artifact("r1", "ocr.srt_uri")
|
||||
|
||||
# 验证结果
|
||||
assert stored["uri"] == "/tmp/out/subtitle.srt"
|
||||
assert db_with_workflow.get_artifact("r1", "missing") is None
|
||||
|
||||
|
||||
def test_restore_run_outputs_strips_node_prefix(db_with_workflow: Database) -> None:
|
||||
"""恢复产物时剥去"节点ID."前缀,还原为 {输出名: URI}(断点续跑依赖)。"""
|
||||
# 数据:两个节点的产物。
|
||||
db_with_workflow.create_run(_run("r1"))
|
||||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "extract", "name": "extract.audio_uri", "uri": "/a.wav", "kind": "file"})
|
||||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "asr", "name": "asr.srt_uri", "uri": "/a.srt", "kind": "file"})
|
||||
|
||||
# 测试过程
|
||||
outputs = db_with_workflow.restore_run_outputs("r1")
|
||||
|
||||
# 验证结果
|
||||
assert outputs == {
|
||||
"extract": {"audio_uri": "/a.wav"},
|
||||
"asr": {"srt_uri": "/a.srt"},
|
||||
}
|
||||
|
||||
|
||||
def test_reset_run_clears_state_and_artifacts(db_with_workflow: Database) -> None:
|
||||
"""reset_run 清空产物与错误、回到 QUEUED(供失败任务重跑)。"""
|
||||
# 数据:一条 FAILED 任务带产物与错误。
|
||||
db_with_workflow.create_run(_run("r1", status="FAILED", error="boom"))
|
||||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "a", "name": "a.x", "uri": "/x", "kind": "file"})
|
||||
|
||||
# 测试过程
|
||||
db_with_workflow.reset_run("r1", "t2")
|
||||
stored = db_with_workflow.get_run("r1")
|
||||
|
||||
# 验证结果
|
||||
assert stored["status"] == "QUEUED"
|
||||
assert stored["error"] is None
|
||||
assert db_with_workflow.list_artifacts("r1") == []
|
||||
|
||||
|
||||
def test_list_run_ids(db_with_workflow: Database) -> None:
|
||||
"""列出全部任务 ID(供孤儿清理比对文件系统)。"""
|
||||
# 数据:两条任务。
|
||||
db_with_workflow.create_run(_run("r1"))
|
||||
db_with_workflow.create_run(_run("r2"))
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert sorted(db_with_workflow.list_run_ids()) == ["r1", "r2"]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""src/wov_app/logging.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/logging.py`(控制台日志配置),可独立调用。
|
||||
用例使用真实 logging 模块与真实日志记录,验证命名、处理器去重与传播设置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from wov_app.logging import _APP_LOGGER_NAME, _ensure_console_handler, get_logger
|
||||
|
||||
|
||||
def _strip_handlers(logger: logging.Logger) -> None:
|
||||
"""清空日志器处理器,保证用例从干净状态开始(模块自建隔离)。"""
|
||||
for handler in list(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
|
||||
|
||||
def test_get_logger_uses_app_prefix() -> None:
|
||||
"""日志器名称带应用前缀,便于与其他库日志区分。"""
|
||||
# 数据:模块名 "scheduler"。
|
||||
# 测试过程
|
||||
logger = get_logger("scheduler")
|
||||
|
||||
# 验证结果
|
||||
assert logger.name == f"{_APP_LOGGER_NAME}.scheduler"
|
||||
_strip_handlers(logger)
|
||||
|
||||
|
||||
def test_get_logger_attaches_console_handler_once() -> None:
|
||||
"""重复获取同一日志器不会重复附加处理器(避免日志重复打印)。"""
|
||||
# 数据:先清空,再连续获取两次。
|
||||
logger = get_logger("dup")
|
||||
_strip_handlers(logger)
|
||||
|
||||
# 测试过程
|
||||
first = get_logger("dup")
|
||||
second = get_logger("dup")
|
||||
|
||||
# 验证结果:处理器只有 1 个。
|
||||
assert first is second
|
||||
assert len([h for h in second.handlers if isinstance(h, logging.StreamHandler)]) == 1
|
||||
_strip_handlers(second)
|
||||
|
||||
|
||||
def test_get_logger_sets_info_level_and_no_propagation() -> None:
|
||||
"""日志级别为 INFO 且不向根日志传播(防止 uvicorn 重复输出)。"""
|
||||
# 数据:新日志器。
|
||||
logger = get_logger("levels")
|
||||
_strip_handlers(logger)
|
||||
|
||||
# 测试过程
|
||||
logger = get_logger("levels")
|
||||
|
||||
# 验证结果
|
||||
assert logger.level == logging.INFO
|
||||
assert logger.propagate is False
|
||||
_strip_handlers(logger)
|
||||
|
||||
|
||||
def test_ensure_console_handler_is_idempotent() -> None:
|
||||
"""_ensure_console_handler 对已配置的日志器不再追加处理器。"""
|
||||
# 数据:手工配置过的日志器。
|
||||
logger = logging.getLogger("vrsub.idempotent")
|
||||
_strip_handlers(logger)
|
||||
|
||||
# 测试过程
|
||||
_ensure_console_handler(logger)
|
||||
_ensure_console_handler(logger)
|
||||
|
||||
# 验证结果
|
||||
assert len(logger.handlers) == 1
|
||||
_strip_handlers(logger)
|
||||
|
||||
|
||||
def test_log_record_is_actually_emitted(capsys) -> None:
|
||||
"""日志记录真实写出到控制台(handler 生效,而非仅配置)。"""
|
||||
# 数据:日志器 + 一条 INFO 记录。
|
||||
logger = get_logger("emit")
|
||||
|
||||
# 测试过程
|
||||
logger.info("测试日志 %s", "生效")
|
||||
|
||||
# 验证结果:输出内容包含格式化后的消息。
|
||||
captured = capsys.readouterr()
|
||||
assert "测试日志 生效" in (captured.err + captured.out)
|
||||
_strip_handlers(logger)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""src/wov_app/main.py 与 src/wov_app/schemas.py 的模块级测试。
|
||||
|
||||
被测模块:
|
||||
- `src/wov_app/main.py`:FastAPI 应用装配(生命周期、路由挂载、静态前端、
|
||||
健康检查、重启恢复);
|
||||
- `src/wov_app/schemas.py`:管理端请求模型(Pydantic)。
|
||||
|
||||
用例通过真实 TestClient 触发完整生命周期(启动/关闭),验证后台服务被正确
|
||||
创建与回收、恢复逻辑被调用、静态前端可访问。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wov_app.main import app as fastapi_app
|
||||
from wov_app.schemas import BatchJobCreate, WorkflowCreate
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""隔离数据库与存储的真实 TestClient(关闭后台线程便于断言状态)。"""
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "0")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
|
||||
with TestClient(fastapi_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 生命周期装配
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lifespan_registers_nodes_and_state(client: TestClient) -> None:
|
||||
"""启动后节点注册表就绪,且 app.state 上挂好了库/调度器/清理器/批量引擎。"""
|
||||
# 数据:真实应用生命周期。
|
||||
# 测试过程
|
||||
state = client.app.state
|
||||
nodes = {m.id for m in __import__("wov_app.registry", fromlist=["list_nodes"]).list_nodes()}
|
||||
|
||||
# 验证结果
|
||||
assert "echo" in nodes and "faster-whisper" in nodes
|
||||
assert state.db is not None
|
||||
assert state.scheduler is not None
|
||||
assert state.cleaner is not None
|
||||
assert state.batch is not None
|
||||
|
||||
|
||||
def test_lifespan_recovers_interrupted_runs(tmp_path: Path, monkeypatch) -> None:
|
||||
"""重启恢复:遗留 RUNNING 任务在启动时被恢复为 QUEUED。"""
|
||||
# 数据:预先在目标库里写入一条 RUNNING 任务(含工作流与版本)。
|
||||
from wov_app.db import Database
|
||||
|
||||
db_path = tmp_path / "wov.db"
|
||||
db = Database(db_path)
|
||||
db.upsert_workflow({"id": "wf", "name": "x", "description": "", "published": 1, "latest_version": 1})
|
||||
db.create_workflow_version("wf", 1, {"nodes": [], "edges": []})
|
||||
db.create_run({
|
||||
"id": "run-stale", "workflow_id": "wf", "workflow_version": 1, "status": "RUNNING",
|
||||
"current_node_id": None, "progress": 0.5, "error": None, "input_uri": None,
|
||||
"param_overrides": None, "source": "upload",
|
||||
"created_at": "2026-09-01T00:00:00+00:00", "updated_at": "2026-09-01T00:00:00+00:00",
|
||||
})
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", db_path)
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", db_path)
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "0")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
|
||||
|
||||
# 测试过程:进入生命周期触发恢复逻辑。
|
||||
with TestClient(fastapi_app):
|
||||
recovered = Database(db_path).get_run("run-stale")
|
||||
|
||||
# 验证结果
|
||||
assert recovered["status"] == "QUEUED"
|
||||
|
||||
|
||||
def test_lifespan_seeds_workflows_when_enabled(tmp_path: Path, monkeypatch) -> None:
|
||||
"""开启自动种子时启动创建内置工作流(数据驱动)。"""
|
||||
# 数据:目标库 + 开启 seed。
|
||||
db_path = tmp_path / "wov.db"
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", db_path)
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", db_path)
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "1")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
|
||||
|
||||
# 测试过程
|
||||
with TestClient(fastapi_app):
|
||||
from wov_app.db import Database as _Db
|
||||
|
||||
ids = {w["id"] for w in _Db(db_path).list_workflows()}
|
||||
|
||||
# 验证结果:内置工作流全部就位。
|
||||
assert {"zh-direct", "ocr-subtitle", "learn-translate"} <= ids
|
||||
|
||||
|
||||
def test_lifespan_starts_and_stops_background_services(tmp_path: Path, monkeypatch) -> None:
|
||||
"""开启后台服务时启动线程,退出时全部停止(无残留线程)。"""
|
||||
# 数据:全部后台服务开启。
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "0")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "1")
|
||||
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "1")
|
||||
monkeypatch.setenv("WOV_BATCH_ENABLED", "1")
|
||||
|
||||
# 测试过程
|
||||
with TestClient(fastapi_app) as test_client:
|
||||
scheduler_thread = test_client.app.state.scheduler._thread
|
||||
cleaner_thread = test_client.app.state.cleaner._thread
|
||||
batch_thread = test_client.app.state.batch._thread
|
||||
assert scheduler_thread is not None and scheduler_thread.is_alive()
|
||||
assert cleaner_thread is not None and cleaner_thread.is_alive()
|
||||
assert batch_thread is not None and batch_thread.is_alive()
|
||||
|
||||
# 验证结果:退出后线程引用被清空(stop 已执行)。
|
||||
assert test_client.app.state.scheduler._thread is None
|
||||
assert test_client.app.state.cleaner._thread is None
|
||||
assert test_client.app.state.batch._thread is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 路由与静态前端
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_health_endpoint(client: TestClient) -> None:
|
||||
"""健康检查返回 ok 与模式标识(部署探针依赖)。"""
|
||||
# 数据:无。
|
||||
# 测试过程
|
||||
response = client.get("/health")
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["mode"] == "monolith"
|
||||
|
||||
|
||||
def test_static_frontend_is_mounted(client: TestClient) -> None:
|
||||
"""静态前端挂载在根路径,首页可访问(真实 web/ 目录)。"""
|
||||
# 数据:真实前端文件。
|
||||
# 测试过程
|
||||
response = client.get("/")
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
def test_openapi_lists_routers(client: TestClient) -> None:
|
||||
"""OpenAPI 文档包含三组路由(管理端/用户端/批量)。"""
|
||||
# 数据:无。
|
||||
# 测试过程
|
||||
paths = client.get("/openapi.json").json()["paths"]
|
||||
|
||||
# 验证结果:每组至少一个端点。
|
||||
assert any(path.startswith("/api/admin/workflows") for path in paths)
|
||||
assert any(path.startswith("/api/apps") for path in paths)
|
||||
assert any(path.startswith("/api/batch") for path in paths)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 请求模型(schemas)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workflow_create_schema_defaults() -> None:
|
||||
"""WorkflowCreate:id/description 可省略,definition 必填。"""
|
||||
# 数据:最小合法载荷。
|
||||
model = WorkflowCreate(name="流程", definition={"nodes": [], "edges": []})
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert model.id is None
|
||||
assert model.description == ""
|
||||
assert model.definition == {"nodes": [], "edges": []}
|
||||
|
||||
|
||||
def test_batch_job_create_schema_defaults() -> None:
|
||||
"""BatchJobCreate:recursive 默认 True(批量页默认递归扫描)。"""
|
||||
# 数据:最小载荷。
|
||||
model = BatchJobCreate(folder="/videos", workflow_id="wf")
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert model.recursive is True
|
||||
assert model.folder == "/videos"
|
||||
|
||||
|
||||
def test_schemas_reject_missing_required_fields() -> None:
|
||||
"""缺少必填字段时 Pydantic 校验失败(由 FastAPI 转 422)。"""
|
||||
# 数据:缺少 name / folder。
|
||||
# 测试过程与验证结果
|
||||
with pytest.raises(Exception):
|
||||
WorkflowCreate(definition={})
|
||||
with pytest.raises(Exception):
|
||||
BatchJobCreate(workflow_id="wf")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,192 @@
|
||||
"""src/wov_app/registry.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/registry.py`(节点注册表:节点调用的唯一入口),
|
||||
可独立调用。注册表是进程内全局状态,用例通过保存/恢复快照隔离,不依赖
|
||||
全局 conftest(原 tests/conftest.py 的 autouse 夹具已按规则取消)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wov_app import registry
|
||||
from wov_sdk.models import InvokeRequest, InvokeResponse, NodeManifest
|
||||
|
||||
# 仓库根目录(用于定位真实 manifests/)。
|
||||
WORKSPACE = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_registry():
|
||||
"""用例前清空注册表、用例后恢复快照:保证用例看到的是干净基线,
|
||||
不受其他模块(如 main 生命周期 register_all)的注册结果影响。"""
|
||||
snapshot = dict(registry._registry)
|
||||
registry._registry.clear()
|
||||
yield
|
||||
registry._registry.clear()
|
||||
registry._registry.update(snapshot)
|
||||
|
||||
|
||||
def _manifest(node_id: str = "demo-node") -> NodeManifest:
|
||||
"""构造一个最小合法清单(真实字段结构)。"""
|
||||
return NodeManifest(
|
||||
id=node_id,
|
||||
name="演示节点",
|
||||
version="0.1.0",
|
||||
capability="demo",
|
||||
command=["python", "-m", "demo"],
|
||||
)
|
||||
|
||||
|
||||
def _request(tmp_path: Path) -> InvokeRequest:
|
||||
"""构造真实调用请求。"""
|
||||
return InvokeRequest(
|
||||
run_id="run-test",
|
||||
node_instance_id="n1",
|
||||
params={},
|
||||
inputs={"text": "hi"},
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 注册与查询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_register_and_get_node() -> None:
|
||||
"""注册后可查询到清单,未注册返回 None。"""
|
||||
# 数据:一个清单 + 一个真实处理器。
|
||||
registry.register(_manifest("echo-x"), lambda request: InvokeResponse(status="completed"))
|
||||
|
||||
# 测试过程
|
||||
found = registry.get_node("echo-x")
|
||||
missing = registry.get_node("not-registered")
|
||||
|
||||
# 验证结果
|
||||
assert found is not None and found.id == "echo-x"
|
||||
assert missing is None
|
||||
|
||||
|
||||
def test_register_overwrites_same_id() -> None:
|
||||
"""同一 ID 重复注册按后者覆盖(启动时 register_all 幂等)。"""
|
||||
# 数据:同一 ID 注册两次,名称不同。
|
||||
first = _manifest("node-x")
|
||||
second = NodeManifest(
|
||||
id="node-x", name="覆盖后", version="0.2.0", capability="demo",
|
||||
command=["python", "-m", "demo"],
|
||||
)
|
||||
registry.register(first, lambda request: InvokeResponse(status="completed"))
|
||||
registry.register(second, lambda request: InvokeResponse(status="completed"))
|
||||
|
||||
# 测试过程
|
||||
found = registry.get_node("node-x")
|
||||
|
||||
# 验证结果
|
||||
assert found.name == "覆盖后"
|
||||
assert found.version == "0.2.0"
|
||||
|
||||
|
||||
def test_register_rejects_invalid_manifest() -> None:
|
||||
"""非法清单(空 ID)注册时校验失败并抛错。"""
|
||||
# 数据:id 为空的清单。
|
||||
invalid = NodeManifest(id="", name="x", version="1", capability="c", command=["python"])
|
||||
|
||||
# 测试过程与验证结果
|
||||
with pytest.raises(ValueError):
|
||||
registry.register(invalid, lambda request: InvokeResponse(status="completed"))
|
||||
|
||||
|
||||
def test_register_all_registers_builtin_nodes() -> None:
|
||||
"""register_all 从真实 manifests/ 注册全部内置节点(含关键节点)。"""
|
||||
# 数据:真实仓库清单目录。
|
||||
assert (WORKSPACE / "manifests").is_dir()
|
||||
|
||||
# 测试过程
|
||||
registry.register_all()
|
||||
ids = {manifest.id for manifest in registry.list_nodes()}
|
||||
|
||||
# 验证结果:字幕流水线所需的节点全部在册(ID 取自 manifests/*.json)。
|
||||
for expected in (
|
||||
"echo", "ffmpeg-extract", "faster-whisper", "llm-translate",
|
||||
"vlm-ocr", "frame-extract", "subtitle-ocr", "llm-filter",
|
||||
"subtitle-correction", "srt-to-dual-eye-ass",
|
||||
):
|
||||
assert expected in ids, f"内置节点未注册:{expected}"
|
||||
|
||||
|
||||
def test_register_all_is_idempotent() -> None:
|
||||
"""重复调用 register_all 不产生重复条目(按 ID 覆盖)。"""
|
||||
# 数据:无。
|
||||
# 测试过程
|
||||
registry.register_all()
|
||||
first = len(registry.list_nodes())
|
||||
registry.register_all()
|
||||
second = len(registry.list_nodes())
|
||||
|
||||
# 验证结果
|
||||
assert first == second
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# invoke:唯一调用入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invoke_routes_to_registered_handler(tmp_path: Path) -> None:
|
||||
"""invoke 把请求路由到注册的处理器并原样返回其响应。"""
|
||||
# 数据:记录收到的请求的处理器。
|
||||
received: list[InvokeRequest] = []
|
||||
|
||||
def handler(request: InvokeRequest) -> InvokeResponse:
|
||||
received.append(request)
|
||||
return InvokeResponse(status="completed", outputs={"text": "ok"})
|
||||
|
||||
registry.register(_manifest("node-x"), handler)
|
||||
|
||||
# 测试过程
|
||||
response = registry.invoke("node-x", _request(tmp_path))
|
||||
|
||||
# 验证结果:响应来自处理器,且请求原样传递。
|
||||
assert response.status == "completed"
|
||||
assert response.outputs == {"text": "ok"}
|
||||
assert received[0].inputs == {"text": "hi"}
|
||||
|
||||
|
||||
def test_invoke_raises_for_unregistered_node(tmp_path: Path) -> None:
|
||||
"""未注册节点调用抛 ValueError(不静默返回空结果)。"""
|
||||
# 数据:未注册的 node_id。
|
||||
# 测试过程与验证结果
|
||||
with pytest.raises(ValueError, match="not registered"):
|
||||
registry.invoke("nope", _request(tmp_path))
|
||||
|
||||
|
||||
def test_invoke_returns_failed_response_unchanged(tmp_path: Path) -> None:
|
||||
"""处理器返回 failed 时原样透传(注册表不改变节点语义)。"""
|
||||
# 数据:总是失败的处理器。
|
||||
registry.register(
|
||||
_manifest("node-x"),
|
||||
lambda request: InvokeResponse(status="failed", error="boom"),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
response = registry.invoke("node-x", _request(tmp_path))
|
||||
|
||||
# 验证结果
|
||||
assert response.status == "failed"
|
||||
assert response.error == "boom"
|
||||
|
||||
|
||||
def test_list_nodes_returns_sorted_by_id() -> None:
|
||||
"""list_nodes 按节点 ID 排序返回(前端展示稳定)。"""
|
||||
# 数据:乱序注册的节点。
|
||||
for node_id in ("z-node", "a-node", "m-node"):
|
||||
registry.register(_manifest(node_id), lambda request: InvokeResponse(status="completed"))
|
||||
|
||||
# 测试过程
|
||||
ids = [m.id for m in registry.list_nodes()]
|
||||
|
||||
# 验证结果
|
||||
assert ids == ["a-node", "m-node", "z-node"]
|
||||
@@ -0,0 +1,319 @@
|
||||
"""src/wov_app/routers/apps.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/routers/apps.py`(用户端 API:应用列表、上传建任务、
|
||||
进度查询、产物下载、重试/暂停/继续/删除),可独立调用。用例通过真实
|
||||
FastAPI TestClient 走完整 HTTP 链路,数据库为临时 SQLite。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wov_app import registry
|
||||
from wov_app.db import Database
|
||||
from wov_app.main import app as fastapi_app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registry():
|
||||
"""保存/恢复注册表(模块自带隔离)。"""
|
||||
snapshot = dict(registry._registry)
|
||||
yield
|
||||
registry._registry.clear()
|
||||
registry._registry.update(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""构造隔离存储与库的真实 TestClient。
|
||||
|
||||
`wov_app.config` 在导入时锁定路径常量,因此这里同时替换 main 与 config
|
||||
的 STORAGE_DIR(路由在函数内 `from wov_app.config import STORAGE_DIR`),
|
||||
保证测试不写入真实 data/。
|
||||
"""
|
||||
storage = tmp_path / "storage"
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", storage)
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", storage)
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "0")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
|
||||
# 生命周期用 main.DB_PATH 建库;库文件路径与断言用的库保持一致。
|
||||
with TestClient(fastapi_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _publish_echo_app(client: TestClient, app_id: str = "echo-app") -> str:
|
||||
"""创建并发布一个单 echo 节点应用(echo 为内置节点)。"""
|
||||
definition = {
|
||||
"name": "echo-flow",
|
||||
"version": 1,
|
||||
"nodes": [{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}],
|
||||
"edges": [],
|
||||
"entry_inputs": {"video_uri": "file"},
|
||||
"final_outputs": {"result": "step.file_uri"},
|
||||
}
|
||||
client.post("/api/admin/workflows", json={
|
||||
"id": app_id, "name": "Echo App", "description": "upload a file", "definition": definition,
|
||||
})
|
||||
client.post(f"/api/admin/workflows/{app_id}/publish")
|
||||
return app_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 应用列表与创建任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_apps_returns_only_published(client: TestClient) -> None:
|
||||
"""应用列表只返回已发布工作流(用户看不到草稿)。"""
|
||||
# 数据:一个草稿 + 一个已发布。
|
||||
client.post("/api/admin/workflows", json={
|
||||
"id": "draft", "name": "草稿", "description": "",
|
||||
"definition": {"name": "d", "version": 1, "nodes": [], "edges": []},
|
||||
})
|
||||
_publish_echo_app(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.get("/api/apps")
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
ids = {item["id"] for item in response.json()}
|
||||
assert ids == {"echo-app"}
|
||||
|
||||
|
||||
def test_create_run_accepts_upload_and_persists_params(client: TestClient) -> None:
|
||||
"""上传视频创建任务:返回 run_id,参数覆盖被持久化。"""
|
||||
# 数据:已发布应用 + 真实上传文件 + 裁剪参数覆盖。
|
||||
app_id = _publish_echo_app(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post(
|
||||
f"/api/apps/{app_id}/runs",
|
||||
files={"file": ("movie.mp4", b"fake video bytes", "video/mp4")},
|
||||
data={"params": '{"step": {"interval_seconds": 3}}'},
|
||||
)
|
||||
|
||||
# 验证结果:任务创建成功且参数被保存。
|
||||
assert response.status_code == 200, response.text
|
||||
run_id = response.json()["id"]
|
||||
stored = client.get(f"/api/runs/{run_id}").json()
|
||||
assert stored["param_overrides"] == {"step": {"interval_seconds": 3}}
|
||||
assert stored["status"] in ("QUEUED", "RUNNING", "COMPLETED")
|
||||
|
||||
|
||||
def test_create_run_rejects_unpublished_app(client: TestClient) -> None:
|
||||
"""未发布应用不接单(404)。"""
|
||||
# 数据:只创建不发布。
|
||||
client.post("/api/admin/workflows", json={
|
||||
"id": "draft-app", "name": "草稿", "description": "",
|
||||
"definition": {"name": "d", "version": 1, "nodes": [], "edges": []},
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
response = client.post(
|
||||
"/api/apps/draft-app/runs",
|
||||
files={"file": ("a.mp4", b"x", "video/mp4")},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_create_run_rejects_unknown_app(client: TestClient) -> None:
|
||||
"""不存在的应用返回 404。"""
|
||||
# 数据:未注册的应用 ID。
|
||||
# 测试过程
|
||||
response = client.post(
|
||||
"/api/apps/nope/runs", files={"file": ("a.mp4", b"x", "video/mp4")},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_create_run_rejects_invalid_params_json(client: TestClient) -> None:
|
||||
"""params 不是合法 JSON 时返回 422(参数错误不产生任务)。"""
|
||||
# 数据:已发布应用 + 非法 params。
|
||||
app_id = _publish_echo_app(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post(
|
||||
f"/api/apps/{app_id}/runs",
|
||||
files={"file": ("a.mp4", b"x", "video/mp4")},
|
||||
data={"params": "{not json"},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 任务查询、暂停、继续、重试、删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_run(client: TestClient) -> str:
|
||||
"""创建一条任务并返回 run_id(测试辅助)。"""
|
||||
app_id = _publish_echo_app(client)
|
||||
response = client.post(
|
||||
f"/api/apps/{app_id}/runs", files={"file": ("m.mp4", b"data", "video/mp4")},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_list_and_get_runs(client: TestClient) -> None:
|
||||
"""任务列表与详情可读(详情含状态与进度)。"""
|
||||
# 数据:一条任务。
|
||||
run_id = _create_run(client)
|
||||
|
||||
# 测试过程
|
||||
listed = client.get("/api/runs").json()
|
||||
detail = client.get(f"/api/runs/{run_id}")
|
||||
|
||||
# 验证结果
|
||||
assert any(item["id"] == run_id for item in listed)
|
||||
assert detail.status_code == 200
|
||||
assert {"status", "progress", "id"} <= set(detail.json())
|
||||
|
||||
|
||||
def test_get_missing_run_returns_404(client: TestClient) -> None:
|
||||
"""不存在的任务返回 404。"""
|
||||
# 数据:未创建的任务 ID。
|
||||
# 测试过程与验证结果
|
||||
assert client.get("/api/runs/nope").status_code == 404
|
||||
|
||||
|
||||
def test_pause_and_resume_run(client: TestClient) -> None:
|
||||
"""暂停置 PAUSED、继续置 QUEUED,并写入/清除暂停信号文件。"""
|
||||
# 数据:一条任务。
|
||||
run_id = _create_run(client)
|
||||
|
||||
# 测试过程
|
||||
paused = client.post(f"/api/runs/{run_id}/pause")
|
||||
paused_status = client.get(f"/api/runs/{run_id}").json()["status"]
|
||||
resumed = client.post(f"/api/runs/{run_id}/resume")
|
||||
resumed_status = client.get(f"/api/runs/{run_id}").json()["status"]
|
||||
|
||||
# 验证结果
|
||||
assert paused.status_code == 200 and resumed.status_code == 200
|
||||
assert paused_status == "PAUSED"
|
||||
assert resumed_status == "QUEUED"
|
||||
|
||||
|
||||
def test_resume_rejects_non_paused_run(client: TestClient) -> None:
|
||||
"""非 PAUSED 状态不能继续(409/422 语义,避免误触发执行)。"""
|
||||
# 数据:一条刚创建的任务(QUEUED)。
|
||||
run_id = _create_run(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post(f"/api/runs/{run_id}/resume")
|
||||
|
||||
# 验证结果:不是 200 成功。
|
||||
assert response.status_code != 200
|
||||
|
||||
|
||||
def test_retry_requeues_failed_run(client: TestClient) -> None:
|
||||
"""FAILED 任务可重试:状态回到 QUEUED 并清空错误。"""
|
||||
# 数据:手动把任务置为 FAILED。
|
||||
run_id = _create_run(client)
|
||||
db: Database = client.app.state.db
|
||||
db.update_run(run_id, status="FAILED", error="boom", updated_at="2026-09-01T00:00:00+00:00")
|
||||
|
||||
# 测试过程
|
||||
response = client.post(f"/api/runs/{run_id}/retry")
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
stored = client.get(f"/api/runs/{run_id}").json()
|
||||
assert stored["status"] == "QUEUED"
|
||||
assert stored["error"] is None
|
||||
|
||||
|
||||
def test_delete_run_removes_record_and_files(client: TestClient) -> None:
|
||||
"""删除任务清理记录与私有上传/产物目录(不动外部路径)。"""
|
||||
# 数据:一条任务。
|
||||
run_id = _create_run(client)
|
||||
db: Database = client.app.state.db
|
||||
|
||||
# 测试过程
|
||||
response = client.delete(f"/api/runs/{run_id}")
|
||||
|
||||
# 验证结果:记录消失,再次查询 404。
|
||||
assert response.status_code == 200
|
||||
assert db.get_run(run_id) is None
|
||||
assert client.get(f"/api/runs/{run_id}").status_code == 404
|
||||
|
||||
|
||||
def test_delete_rejects_batch_source_run(client: TestClient) -> None:
|
||||
"""source=batch 的任务拒绝普通删除(R01:避免误删用户视频目录)。"""
|
||||
# 数据:一条 batch 来源任务(source 不在 update_run 白名单,直连库改)。
|
||||
run_id = _create_run(client)
|
||||
db: Database = client.app.state.db
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE workflow_runs SET source='batch' WHERE id = ?", (run_id,))
|
||||
|
||||
# 测试过程
|
||||
response = client.delete(f"/api/runs/{run_id}")
|
||||
|
||||
# 验证结果:被拒绝(422),记录仍存在。
|
||||
assert response.status_code == 422
|
||||
assert db.get_run(run_id) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 产物查询与下载
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_artifacts_and_download(client: TestClient, tmp_path: Path) -> None:
|
||||
"""产物列表可查,产物内容可按名下载。"""
|
||||
# 数据:任务 + 真实产物文件 + 产物记录。
|
||||
run_id = _create_run(client)
|
||||
db: Database = client.app.state.db
|
||||
payload = tmp_path / "out.srt"
|
||||
payload.write_text("1\n00:00:01,000 --> 00:00:02,000\n你好\n", encoding="utf-8")
|
||||
db.create_artifact({
|
||||
"run_id": run_id, "node_id": "step", "name": "result",
|
||||
"uri": str(payload), "mime_type": "application/x-subrip", "size": payload.stat().st_size,
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
listed = client.get(f"/api/runs/{run_id}/artifacts")
|
||||
downloaded = client.get(f"/api/runs/{run_id}/artifacts/result")
|
||||
|
||||
# 验证结果
|
||||
assert listed.status_code == 200
|
||||
assert any(item["name"] == "result" for item in listed.json())
|
||||
assert downloaded.status_code == 200
|
||||
assert "你好" in downloaded.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_download_missing_artifact_returns_404(client: TestClient) -> None:
|
||||
"""下载不存在的产物返回 404。"""
|
||||
# 数据:一条任务,无该产物。
|
||||
run_id = _create_run(client)
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert client.get(f"/api/runs/{run_id}/artifacts/nope").status_code == 404
|
||||
|
||||
|
||||
def test_download_missing_file_returns_404(client: TestClient) -> None:
|
||||
"""产物记录存在但文件已被删除时返回 404(不返回残缺内容)。"""
|
||||
# 数据:产物记录指向不存在的文件。
|
||||
run_id = _create_run(client)
|
||||
db: Database = client.app.state.db
|
||||
db.create_artifact({
|
||||
"run_id": run_id, "node_id": "step", "name": "gone",
|
||||
"uri": "/nonexistent/file.srt", "mime_type": "application/x-subrip", "size": 0,
|
||||
})
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert client.get(f"/api/runs/{run_id}/artifacts/gone").status_code == 404
|
||||
@@ -0,0 +1,310 @@
|
||||
"""src/wov_app/routers/batch.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/routers/batch.py`(批量处理 API:目录浏览、任务创建
|
||||
与列表、暂停/继续、删除、产物下载),可独立调用。用例通过真实 TestClient,
|
||||
文件夹为临时目录下的真实视频文件。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wov_app import registry
|
||||
from wov_app.main import app as fastapi_app
|
||||
from wov_sdk.models import WorkflowDefinition
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registry():
|
||||
"""保存/恢复注册表(模块自带隔离)。"""
|
||||
snapshot = dict(registry._registry)
|
||||
yield
|
||||
registry._registry.clear()
|
||||
registry._registry.update(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""构造隔离数据库与存储的真实 TestClient。"""
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage")
|
||||
# 批量工作空间也指向临时目录(模块常量在导入时绑定)。
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "0")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
|
||||
with TestClient(fastapi_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _make_video(path: Path) -> Path:
|
||||
"""创建真实可读的视频文件。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 32)
|
||||
return path
|
||||
|
||||
|
||||
def _publish_workflow(client: TestClient, workflow_id: str = "wf") -> None:
|
||||
"""创建并发布单 echo 节点工作流。"""
|
||||
definition = WorkflowDefinition.from_dict({
|
||||
"name": "批量流程", "version": 1,
|
||||
"nodes": [{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}],
|
||||
"edges": [], "entry_inputs": {"video_uri": "file"},
|
||||
"final_outputs": {"result": "step.file_uri"},
|
||||
}).to_dict()
|
||||
client.post("/api/admin/workflows", json={
|
||||
"id": workflow_id, "name": "批量流程", "description": "", "definition": definition,
|
||||
})
|
||||
client.post(f"/api/admin/workflows/{workflow_id}/publish")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 目录浏览(本地后端提供,浏览器拿不到绝对路径)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_roots_returns_browsable_roots(client: TestClient) -> None:
|
||||
"""根目录列表返回可浏览位置(POSIX 返回 / 与家目录)。"""
|
||||
# 数据:无(真实文件系统)。
|
||||
# 测试过程
|
||||
response = client.get("/api/batch/roots")
|
||||
|
||||
# 验证结果:非空且每项含 path/name。
|
||||
assert response.status_code == 200
|
||||
roots = response.json()
|
||||
assert roots, "至少应返回一个可浏览根目录"
|
||||
assert all({"path", "name"} <= set(item) for item in roots)
|
||||
|
||||
|
||||
def test_list_dirs_returns_only_directories(client: TestClient, tmp_path: Path) -> None:
|
||||
"""列目录只返回直接子目录,且过滤隐藏目录。"""
|
||||
# 数据:两个子目录 + 一个隐藏目录 + 一个文件。
|
||||
folder = tmp_path / "root"
|
||||
(folder / "sub1").mkdir(parents=True)
|
||||
(folder / "sub2").mkdir()
|
||||
(folder / ".hidden").mkdir()
|
||||
(folder / "file.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
body = client.get("/api/batch/dirs", params={"path": str(folder)}).json()
|
||||
|
||||
# 验证结果
|
||||
names = {item["name"] for item in body["dirs"]}
|
||||
assert names == {"sub1", "sub2"}
|
||||
|
||||
|
||||
def test_list_dirs_returns_empty_for_missing_path(client: TestClient, tmp_path: Path) -> None:
|
||||
"""目录不存在时返回空列表而非 500(前端树保持可用)。"""
|
||||
# 数据:不存在的路径。
|
||||
# 测试过程
|
||||
response = client.get("/api/batch/dirs", params={"path": str(tmp_path / "nope")})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dirs"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 创建与查询批量任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_job_returns_job_with_videos(client: TestClient, tmp_path: Path) -> None:
|
||||
"""创建批量任务:返回任务 ID、总数与明细列表(含跳过项)。"""
|
||||
# 数据:两个视频,其中一个已有旁挂字幕。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
_make_video(folder / "b.mp4")
|
||||
(folder / "b.CN.srt").write_text("1\n", encoding="utf-8")
|
||||
_publish_workflow(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["total"] == 1
|
||||
assert len(body["videos"]) == 2
|
||||
|
||||
|
||||
def test_create_job_rejects_missing_folder(client: TestClient, tmp_path: Path) -> None:
|
||||
"""文件夹不存在返回 422。"""
|
||||
# 数据:未发布的目录路径。
|
||||
_publish_workflow(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/batch/jobs", json={
|
||||
"folder": str(tmp_path / "nope"), "workflow_id": "wf", "recursive": False,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_job_rejects_unpublished_workflow(client: TestClient, tmp_path: Path) -> None:
|
||||
"""工作流未发布返回 422。"""
|
||||
# 数据:有视频但工作流未发布。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_job_rejects_folder_without_videos(client: TestClient, tmp_path: Path) -> None:
|
||||
"""文件夹内无视频返回 422。"""
|
||||
# 数据:空文件夹 + 已发布工作流。
|
||||
folder = tmp_path / "videos"
|
||||
folder.mkdir()
|
||||
_publish_workflow(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_list_and_get_job(client: TestClient, tmp_path: Path) -> None:
|
||||
"""任务列表与详情可读,详情含视频明细。"""
|
||||
# 数据:一条批量任务。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
_publish_workflow(client)
|
||||
job_id = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()["id"]
|
||||
|
||||
# 测试过程
|
||||
listed = client.get("/api/batch/jobs").json()
|
||||
detail = client.get(f"/api/batch/jobs/{job_id}")
|
||||
|
||||
# 验证结果
|
||||
assert any(item["id"] == job_id for item in listed)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["videos"][0]["video_path"].endswith("a.mp4")
|
||||
|
||||
|
||||
def test_get_missing_job_returns_404(client: TestClient) -> None:
|
||||
"""不存在的批量任务返回 404。"""
|
||||
# 数据:未创建。
|
||||
# 测试过程与验证结果
|
||||
assert client.get("/api/batch/jobs/nope").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 暂停 / 继续 / 删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pause_and_resume_job(client: TestClient, tmp_path: Path) -> None:
|
||||
"""暂停置 PAUSED、继续置 QUEUED。"""
|
||||
# 数据:一条待处理批量任务。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
_publish_workflow(client)
|
||||
job_id = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()["id"]
|
||||
|
||||
# 测试过程
|
||||
paused = client.post(f"/api/batch/jobs/{job_id}/pause")
|
||||
after_pause = client.get(f"/api/batch/jobs/{job_id}").json()["status"]
|
||||
resumed = client.post(f"/api/batch/jobs/{job_id}/resume")
|
||||
after_resume = client.get(f"/api/batch/jobs/{job_id}").json()["status"]
|
||||
|
||||
# 验证结果
|
||||
assert paused.status_code == 200 and resumed.status_code == 200
|
||||
assert after_pause == "PAUSED"
|
||||
assert after_resume == "QUEUED"
|
||||
|
||||
|
||||
def test_delete_job_removes_records_and_keeps_media(client: TestClient, tmp_path: Path) -> None:
|
||||
"""删除任务清理记录与私有工作空间,保留用户视频。"""
|
||||
# 数据:一条批量任务。
|
||||
folder = tmp_path / "videos"
|
||||
video = _make_video(folder / "a.mp4")
|
||||
_publish_workflow(client)
|
||||
job_id = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()["id"]
|
||||
|
||||
# 测试过程
|
||||
response = client.delete(f"/api/batch/jobs/{job_id}")
|
||||
|
||||
# 验证结果:任务消失但视频保留。
|
||||
assert response.status_code == 200
|
||||
assert client.get(f"/api/batch/jobs/{job_id}").status_code == 404
|
||||
assert video.is_file()
|
||||
|
||||
|
||||
def test_delete_missing_job_returns_404(client: TestClient) -> None:
|
||||
"""删除不存在的任务返回 404。"""
|
||||
# 数据:未创建。
|
||||
# 测试过程与验证结果
|
||||
assert client.delete("/api/batch/jobs/nope").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 产物下载
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_download_sidecar_product(client: TestClient, tmp_path: Path) -> None:
|
||||
"""下载接口返回视频旁的产物文件内容。"""
|
||||
# 数据:一条批量任务 + 视频旁的成品字幕。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
_publish_workflow(client)
|
||||
body = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()
|
||||
job_id = body["id"]
|
||||
video_id = body["videos"][0]["id"]
|
||||
(folder / "movie.CN.srt").write_text("1\n00:00:01,000 --> 00:00:02,000\n你好\n", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
response = client.get(
|
||||
f"/api/batch/jobs/{job_id}/videos/{video_id}/download",
|
||||
params={"alias": "movie.CN.srt"},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert "你好" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_download_missing_product_returns_404(client: TestClient, tmp_path: Path) -> None:
|
||||
"""产物不存在时返回 404。"""
|
||||
# 数据:一条批量任务(无产物)。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
_publish_workflow(client)
|
||||
body = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()
|
||||
|
||||
# 测试过程
|
||||
response = client.get(
|
||||
f"/api/batch/jobs/{body['id']}/videos/{body['videos'][0]['id']}/download",
|
||||
params={"alias": "movie.CN.srt"},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,311 @@
|
||||
"""src/wov_app/routers/workflows.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/routers/workflows.py`(管理端 API:工作流 CRUD、
|
||||
校验、发布、版本历史),可独立调用。用例通过真实 TestClient 走 HTTP 链路,
|
||||
数据库为临时 SQLite。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wov_app.main import app as fastapi_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""构造隔离数据库与存储的真实 TestClient。"""
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "0")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
|
||||
with TestClient(fastapi_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _definition(name: str = "echo-flow", nodes: list[dict] | None = None, edges: list[dict] | None = None) -> dict:
|
||||
"""构造合法 DAG 定义(默认单 echo 节点)。"""
|
||||
return {
|
||||
"name": name,
|
||||
"version": 1,
|
||||
"nodes": nodes or [
|
||||
{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
|
||||
],
|
||||
"edges": edges or [],
|
||||
"entry_inputs": {"video_uri": "file"},
|
||||
"final_outputs": {"result": "step.file_uri"},
|
||||
}
|
||||
|
||||
|
||||
def _create(client: TestClient, workflow_id: str = "wf", definition: dict | None = None) -> dict:
|
||||
"""创建一个工作流并返回响应体。"""
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": workflow_id, "name": "测试流程", "description": "说明",
|
||||
"definition": definition or _definition(),
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 创建与读取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_workflow_saves_first_version(client: TestClient) -> None:
|
||||
"""创建工作流保存为 v1,初始未发布。"""
|
||||
# 数据:合法定义。
|
||||
# 测试过程
|
||||
created = _create(client, "wf-1")
|
||||
|
||||
# 验证结果
|
||||
assert created["id"] == "wf-1"
|
||||
assert created["latest_version"] == 1
|
||||
assert created["published"] is False
|
||||
|
||||
|
||||
def test_create_workflow_appends_new_version(client: TestClient) -> None:
|
||||
"""对已存在工作流再次创建 → 追加新版本(保存即新版本)。"""
|
||||
# 数据:同一 ID 两次创建。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
second = _create(client, "wf-1")
|
||||
|
||||
# 验证结果:版本递增。
|
||||
assert second["latest_version"] == 2
|
||||
|
||||
|
||||
def test_get_workflow_returns_latest_definition(client: TestClient) -> None:
|
||||
"""详情返回最新版本定义(供编排页加载编辑)。"""
|
||||
# 数据:已创建工作流。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
detail = client.get("/api/admin/workflows/wf-1")
|
||||
|
||||
# 验证结果
|
||||
assert detail.status_code == 200
|
||||
body = detail.json()
|
||||
assert body["latest_version_data"]["version"] == 1
|
||||
assert body["latest_version_data"]["definition"]["nodes"][0]["id"] == "step"
|
||||
|
||||
|
||||
def test_get_missing_workflow_returns_404(client: TestClient) -> None:
|
||||
"""不存在的工作流返回 404。"""
|
||||
# 数据:未创建。
|
||||
# 测试过程与验证结果
|
||||
assert client.get("/api/admin/workflows/nope").status_code == 404
|
||||
|
||||
|
||||
def test_list_workflows(client: TestClient) -> None:
|
||||
"""列表返回全部工作流概要。"""
|
||||
# 数据:两个工作流。
|
||||
_create(client, "wf-1")
|
||||
_create(client, "wf-2")
|
||||
|
||||
# 测试过程
|
||||
listed = client.get("/api/admin/workflows").json()
|
||||
|
||||
# 验证结果
|
||||
assert {item["id"] for item in listed} == {"wf-1", "wf-2"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验:拒绝非法 DAG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_rejects_cycle(client: TestClient) -> None:
|
||||
"""环形 DAG 保存被拒(422),不产生工作流记录(R04)。"""
|
||||
# 数据:A→B→A 的环形定义。
|
||||
cyclic = _definition(nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}])
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": "wf-cycle", "name": "环形", "description": "", "definition": cyclic,
|
||||
})
|
||||
|
||||
# 验证结果:422,且未写库。
|
||||
assert response.status_code == 422
|
||||
assert client.get("/api/admin/workflows/wf-cycle").status_code == 404
|
||||
|
||||
|
||||
def test_create_rejects_self_loop(client: TestClient) -> None:
|
||||
"""自环(节点指向自己)同样被拒绝。"""
|
||||
# 数据:单节点自环。
|
||||
loop = _definition(
|
||||
nodes=[{"id": "a", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}}],
|
||||
edges=[{"from": "a", "to": "a"}],
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": "wf-loop", "name": "自环", "description": "", "definition": loop,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_rejects_edge_to_unknown_node(client: TestClient) -> None:
|
||||
"""边引用不存在的节点被拒绝。"""
|
||||
# 数据:边指向 ghost 节点。
|
||||
bad = _definition(edges=[{"from": "step", "to": "ghost"}])
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": "wf-bad", "name": "坏边", "description": "", "definition": bad,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_rejects_duplicate_node_ids(client: TestClient) -> None:
|
||||
"""节点 ID 重复被拒绝。"""
|
||||
# 数据:两个同 ID 节点。
|
||||
dup = _definition(nodes=[
|
||||
{"id": "step", "node_type": "echo", "inputs": {}},
|
||||
{"id": "step", "node_type": "echo", "inputs": {}},
|
||||
])
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": "wf-dup", "name": "重复", "description": "", "definition": dup,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_validate_endpoint_returns_node_ids(client: TestClient) -> None:
|
||||
"""校验接口对合法定义返回 valid 与节点 ID 列表(不保存)。"""
|
||||
# 数据:已创建工作流 + 待校验定义。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows/wf-1/validate", json=_definition())
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"valid": True, "node_ids": ["step"]}
|
||||
|
||||
|
||||
def test_validate_endpoint_rejects_cycle(client: TestClient) -> None:
|
||||
"""校验接口对环形定义返回 422。"""
|
||||
# 数据:环形定义。
|
||||
_create(client, "wf-1")
|
||||
cyclic = _definition(nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}])
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert client.post("/api/admin/workflows/wf-1/validate", json=cyclic).status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 发布与版本
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_publish_marks_workflow_published(client: TestClient) -> None:
|
||||
"""发布后工作流标记为已发布(出现在用户应用中心)。"""
|
||||
# 数据:已创建工作流。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows/wf-1/publish")
|
||||
detail = client.get("/api/admin/workflows/wf-1").json()
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert detail["published"] == 1
|
||||
|
||||
|
||||
def test_publish_rejects_legacy_cycle_version(client: TestClient) -> None:
|
||||
"""历史遗留的环形版本无法发布(发布前重新校验,R04)。"""
|
||||
# 数据:绕过创建校验,直接写入环形版本(模拟历史数据)。
|
||||
_create(client, "wf-1")
|
||||
db = client.app.state.db
|
||||
cyclic = _definition(nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}])
|
||||
db.create_workflow_version("wf-1", 9, cyclic)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows/wf-1/publish")
|
||||
|
||||
# 验证结果:被拒绝。
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_publish_rejects_workflow_without_version(client: TestClient) -> None:
|
||||
"""无版本的工作流不能发布。"""
|
||||
# 数据:只有工作流记录,无版本(绕过创建接口)。
|
||||
db = client.app.state.db
|
||||
db.upsert_workflow({"id": "empty", "name": "空", "description": "", "latest_version": 0})
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows/empty/publish")
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_list_versions_returns_history_desc(client: TestClient) -> None:
|
||||
"""版本历史按版本号倒序返回,供对比与回滚。"""
|
||||
# 数据:两个版本。
|
||||
_create(client, "wf-1")
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
versions = client.get("/api/admin/workflows/wf-1/versions").json()
|
||||
|
||||
# 验证结果
|
||||
assert [item["version"] for item in versions] == [2, 1]
|
||||
|
||||
|
||||
def test_delete_workflow_removes_it(client: TestClient) -> None:
|
||||
"""删除工作流后查询 404。"""
|
||||
# 数据:已创建工作流。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
response = client.delete("/api/admin/workflows/wf-1")
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert client.get("/api/admin/workflows/wf-1").status_code == 404
|
||||
|
||||
|
||||
def test_delete_missing_workflow_returns_404(client: TestClient) -> None:
|
||||
"""删除不存在的工作流返回 404。"""
|
||||
# 数据:未创建。
|
||||
# 测试过程与验证结果
|
||||
assert client.delete("/api/admin/workflows/nope").status_code == 404
|
||||
|
||||
|
||||
def test_created_workflow_id_slugified_from_name_when_absent(client: TestClient) -> None:
|
||||
"""未给 ID 时由名称生成 slug 形式 ID(管理端便利行为)。"""
|
||||
# 数据:只给名称。
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"name": "My Cool Flow", "description": "", "definition": _definition(),
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == "my-cool-flow"
|
||||
@@ -0,0 +1,494 @@
|
||||
"""src/wov_app/scheduler.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/scheduler.py`(DAG 拓扑调度、断点续跑、暂停语义),
|
||||
可独立调用。用例使用真实 SQLite、真实存储目录与真实节点(echo/echo 派生),
|
||||
仅对需要外部服务的节点不做测试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wov_app import registry
|
||||
from wov_app.db import Database
|
||||
from wov_app.scheduler import WorkflowScheduler, topological_sort
|
||||
from wov_sdk.models import WorkflowDefinition
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registry():
|
||||
"""用例前清空注册表、用例后恢复快照:保证用例看到的是干净基线,
|
||||
不受其他模块(如 main 生命周期 register_all)的注册结果影响。"""
|
||||
snapshot = dict(registry._registry)
|
||||
registry._registry.clear()
|
||||
yield
|
||||
registry._registry.clear()
|
||||
registry._registry.update(snapshot)
|
||||
|
||||
|
||||
def _definition(nodes: list[dict], edges: list[dict], **extra) -> WorkflowDefinition:
|
||||
"""构造真实 WorkflowDefinition(节点 ID/类型/输入与边)。"""
|
||||
payload = {
|
||||
"name": "测试流程",
|
||||
"version": 1,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"entry_inputs": {"video_uri": "file"},
|
||||
"final_outputs": extra.pop("final_outputs", {}),
|
||||
**extra,
|
||||
}
|
||||
return WorkflowDefinition.from_dict(payload)
|
||||
|
||||
|
||||
def _db_with_workflow(tmp_path: Path, definition: WorkflowDefinition, workflow_id: str = "wf") -> Database:
|
||||
"""建好工作流 + 版本记录的临时库(任务表有外键约束)。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_workflow({"id": workflow_id, "name": "流程", "description": ""})
|
||||
db.create_workflow_version(workflow_id, 1, definition.to_dict())
|
||||
return db
|
||||
|
||||
|
||||
def _run(run_id: str, input_uri: str, **overrides) -> dict:
|
||||
"""构造真实任务记录。"""
|
||||
record = {
|
||||
"id": run_id, "workflow_id": "wf", "workflow_version": 1, "status": "QUEUED",
|
||||
"current_node_id": None, "progress": 0.0, "error": None, "input_uri": input_uri,
|
||||
"param_overrides": None, "source": "upload",
|
||||
"created_at": "2026-09-01T00:00:00+00:00", "updated_at": "2026-09-01T00:00:00+00:00",
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
def _scheduler(db: Database, storage: Path) -> WorkflowScheduler:
|
||||
"""构造调度器(不启动后台线程,直接调用 execute_run)。"""
|
||||
return WorkflowScheduler(db, storage, interval_seconds=999)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 拓扑排序
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_topological_sort_linear_chain() -> None:
|
||||
"""线性链按依赖顺序返回。"""
|
||||
# 数据:a → b → c。
|
||||
definition = _definition(
|
||||
nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
{"id": "c", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
],
|
||||
edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "c"}],
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
order = topological_sort(definition)
|
||||
|
||||
# 验证结果
|
||||
assert order == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_topological_sort_diamond() -> None:
|
||||
"""菱形依赖中,汇合节点排在其全部前驱之后。"""
|
||||
# 数据:a → (b, c) → d。
|
||||
definition = _definition(
|
||||
nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
{"id": "c", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
{"id": "d", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
],
|
||||
edges=[
|
||||
{"from": "a", "to": "b"}, {"from": "a", "to": "c"},
|
||||
{"from": "b", "to": "d"}, {"from": "c", "to": "d"},
|
||||
],
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
order = topological_sort(definition)
|
||||
|
||||
# 验证结果:a 最先、d 最后,b/c 在中间。
|
||||
assert order[0] == "a"
|
||||
assert order[-1] == "d"
|
||||
assert set(order[1:3]) == {"b", "c"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 执行:成功路径
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_execute_run_completes_and_records_artifacts(tmp_path: Path) -> None:
|
||||
"""单节点任务执行成功:状态 COMPLETED、产物登记、进度到位。"""
|
||||
# 数据:echo 单节点工作流 + 真实输入文件。
|
||||
definition = _definition(
|
||||
nodes=[{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}],
|
||||
edges=[],
|
||||
final_outputs={"result": "step.file_uri"},
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
source = tmp_path / "input.txt"
|
||||
source.write_text("输入内容", encoding="utf-8")
|
||||
db.create_run(_run("run-1", str(source)))
|
||||
registry.register_all()
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-1")
|
||||
|
||||
# 验证结果:任务完成、进度 1.0、节点产物与最终别名都已登记。
|
||||
stored = db.get_run("run-1")
|
||||
assert stored["status"] == "COMPLETED"
|
||||
assert stored["progress"] == 1.0
|
||||
names = {a["name"] for a in db.list_artifacts("run-1")}
|
||||
assert "step.file_uri" in names
|
||||
assert "result" in names
|
||||
|
||||
|
||||
def test_execute_run_multi_node_chain_passes_artifacts(tmp_path: Path) -> None:
|
||||
"""多节点链:后序节点通过 URI 拿到前序产物(节点间只经产物交换数据)。"""
|
||||
# 数据:step1 → step2 两节点链。
|
||||
definition = _definition(
|
||||
nodes=[
|
||||
{"id": "step1", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
|
||||
{"id": "step2", "node_type": "echo", "inputs": {"file_uri": "step1.file_uri"}},
|
||||
],
|
||||
edges=[{"from": "step1", "to": "step2"}],
|
||||
final_outputs={"out": "step2.file_uri"},
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
source = tmp_path / "input.txt"
|
||||
source.write_text("链式内容", encoding="utf-8")
|
||||
db.create_run(_run("run-2", str(source)))
|
||||
registry.register_all()
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-2")
|
||||
|
||||
# 验证结果:两节点产物都存在,且 step2 的产物内容来自 step1(传递一致)。
|
||||
assert db.get_run("run-2")["status"] == "COMPLETED"
|
||||
artifacts = {a["name"]: a["uri"] for a in db.list_artifacts("run-2")}
|
||||
assert "step1.file_uri" in artifacts and "step2.file_uri" in artifacts
|
||||
assert Path(artifacts["step2.file_uri"]).read_text(encoding="utf-8") == "链式内容"
|
||||
assert Path(artifacts["step2.file_uri"]).parent != Path(artifacts["step1.file_uri"]).parent
|
||||
|
||||
|
||||
def test_execute_run_creates_final_alias_with_stable_name(tmp_path: Path) -> None:
|
||||
"""最终产物按 上传文件名.标识.时间戳 生成别名,并保留节点原始文件。"""
|
||||
# 数据:单节点 + target_language 参数(决定别名标识)。
|
||||
definition = _definition(
|
||||
nodes=[{
|
||||
"id": "step", "node_type": "echo",
|
||||
"inputs": {"file_uri": "input.video_uri"},
|
||||
"params": {"target_language": "zh-CN"},
|
||||
}],
|
||||
edges=[],
|
||||
final_outputs={"cn_srt_uri": "step.file_uri"},
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
source = tmp_path / "test01.mp4"
|
||||
source.write_text("数据", encoding="utf-8")
|
||||
db.create_run(_run("run-3", str(source)))
|
||||
registry.register_all()
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-3")
|
||||
|
||||
# 验证结果:别名指向 finals 下的稳定路径,含 zh-CN 标识;节点原文件仍在。
|
||||
artifacts = {a["name"]: a["uri"] for a in db.list_artifacts("run-3")}
|
||||
final = Path(artifacts["cn_srt_uri"])
|
||||
assert final.is_file()
|
||||
assert "zh-CN" in final.name
|
||||
assert "finals" in final.parts
|
||||
assert Path(artifacts["step.file_uri"]).is_file()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 失败与无效 DAG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_execute_run_fails_when_workflow_version_missing(tmp_path: Path) -> None:
|
||||
"""工作流版本记录丢失时任务失败(不留 QUEUED 堵塞队列)。"""
|
||||
# 数据:工作流存在但没有 v1 版本记录(外键仍满足)。
|
||||
db = Database(tmp_path / "wov.db")
|
||||
storage = tmp_path / "storage"
|
||||
db.upsert_workflow({"id": "wf-no-version", "name": "流程", "description": ""})
|
||||
db.create_run(_run("run-x", "input", workflow_id="wf-no-version"))
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-x")
|
||||
|
||||
# 验证结果
|
||||
stored = db.get_run("run-x")
|
||||
assert stored["status"] == "FAILED"
|
||||
assert "workflow version not found" in stored["error"]
|
||||
|
||||
|
||||
def test_execute_run_marks_failed_on_cycle_and_does_not_block_queue(tmp_path: Path) -> None:
|
||||
"""环形 DAG(历史无效版本)立即失败且不堵塞后续任务(R04 回归)。"""
|
||||
# 数据:A→B→A 的环形定义。
|
||||
definition = _definition(
|
||||
nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
],
|
||||
edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}],
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
db.create_run(_run("run-cycle", "input"))
|
||||
registry.register_all()
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-cycle")
|
||||
|
||||
# 验证结果:任务 FAILED,且队首前移(不再返回该任务)。
|
||||
assert db.get_run("run-cycle")["status"] == "FAILED"
|
||||
assert db.next_queued_run() is None
|
||||
|
||||
|
||||
def test_execute_run_fails_when_input_reference_missing(tmp_path: Path) -> None:
|
||||
"""输入引用无法解析(前序产物缺失)时任务失败并记录原因。"""
|
||||
# 数据:节点引用不存在的产物。
|
||||
definition = _definition(
|
||||
nodes=[{"id": "step", "node_type": "echo", "inputs": {"file_uri": "ghost.file_uri"}}],
|
||||
edges=[],
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
db.create_run(_run("run-4", "input"))
|
||||
registry.register_all()
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-4")
|
||||
|
||||
# 验证结果
|
||||
stored = db.get_run("run-4")
|
||||
assert stored["status"] == "FAILED"
|
||||
assert "missing input" in stored["error"]
|
||||
|
||||
|
||||
def test_execute_run_fails_when_node_returns_failed(tmp_path: Path) -> None:
|
||||
"""节点返回 failed 时任务失败并保留错误信息。"""
|
||||
# 数据:注册一个总是失败的节点。
|
||||
from wov_sdk.models import InvokeResponse, NodeManifest
|
||||
|
||||
definition = _definition(
|
||||
nodes=[{"id": "step", "node_type": "boom", "inputs": {}}],
|
||||
edges=[],
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
db.create_run(_run("run-5", "input"))
|
||||
registry.register(
|
||||
NodeManifest(id="boom", name="失败节点", version="1", capability="c", command=["python"]),
|
||||
lambda request: InvokeResponse(status="failed", error="节点内部错误"),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-5")
|
||||
|
||||
# 验证结果
|
||||
stored = db.get_run("run-5")
|
||||
assert stored["status"] == "FAILED"
|
||||
assert "节点内部错误" in stored["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 暂停语义
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_execute_run_leaves_paused_run_untouched(tmp_path: Path) -> None:
|
||||
"""以 PAUSED 进入时直接返回保持暂停(修复"点击暂停反而开始任务")。"""
|
||||
# 数据:PAUSED 状态的任务。
|
||||
definition = _definition(
|
||||
nodes=[{"id": "step", "node_type": "echo", "inputs": {}}],
|
||||
edges=[],
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
db.create_run(_run("run-6", "input", status="PAUSED"))
|
||||
registry.register_all()
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-6")
|
||||
|
||||
# 验证结果:仍为 PAUSED,且未产生任何产物。
|
||||
assert db.get_run("run-6")["status"] == "PAUSED"
|
||||
assert db.list_artifacts("run-6") == []
|
||||
|
||||
|
||||
def test_execute_run_stops_at_node_boundary_when_paused(tmp_path: Path) -> None:
|
||||
"""运行中被暂停:在当前节点边界停下保持 PAUSED,不标 FAILED。"""
|
||||
# 数据:两节点链;第一个节点执行时把任务置 PAUSED。
|
||||
from wov_sdk.models import InvokeResponse, NodeManifest
|
||||
|
||||
definition = _definition(
|
||||
nodes=[
|
||||
{"id": "step1", "node_type": "pause-me", "inputs": {}},
|
||||
{"id": "step2", "node_type": "echo", "inputs": {}},
|
||||
],
|
||||
edges=[{"from": "step1", "to": "step2"}],
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
db.create_run(_run("run-7", "input"))
|
||||
registry.register_all()
|
||||
|
||||
def pause_handler(request):
|
||||
"""模拟用户在该节点执行期间点击暂停。"""
|
||||
db.pause_run("run-7", "t2")
|
||||
return InvokeResponse(status="completed", outputs={"file_uri": "/tmp/x"})
|
||||
|
||||
registry.register(
|
||||
NodeManifest(id="pause-me", name="暂停节点", version="1", capability="c", command=["python"]),
|
||||
pause_handler,
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-7")
|
||||
|
||||
# 验证结果:保持 PAUSED,step2 未执行。
|
||||
assert db.get_run("run-7")["status"] == "PAUSED"
|
||||
names = {a["name"] for a in db.list_artifacts("run-7")}
|
||||
assert not any(name.startswith("step2") for name in names)
|
||||
|
||||
|
||||
def test_execute_run_clears_stale_pause_flag(tmp_path: Path) -> None:
|
||||
"""执行前清理残留的 paused.flag(避免误触发节点内暂停)。"""
|
||||
# 数据:单节点任务 + 已存在的 paused.flag。
|
||||
definition = _definition(
|
||||
nodes=[{"id": "step", "node_type": "echo", "inputs": {}}],
|
||||
edges=[],
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
db.create_run(_run("run-8", "input"))
|
||||
registry.register_all()
|
||||
run_root = storage / "runs" / "run-8"
|
||||
run_root.mkdir(parents=True)
|
||||
(run_root / "paused.flag").write_text("", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-8")
|
||||
|
||||
# 验证结果:标志被清除,任务正常完成。
|
||||
assert not (run_root / "paused.flag").exists()
|
||||
assert db.get_run("run-8")["status"] == "COMPLETED"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 断点续跑与参数覆盖
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_execute_run_resumes_from_existing_artifacts(tmp_path: Path) -> None:
|
||||
"""断点续跑:已有产物的节点被跳过,只执行剩余节点。"""
|
||||
# 数据:两节点链,step1 产物已登记。
|
||||
calls: list[str] = []
|
||||
|
||||
def tracking_handler(node_id: str):
|
||||
def handler(request):
|
||||
from wov_sdk.models import InvokeResponse
|
||||
|
||||
calls.append(node_id)
|
||||
return InvokeResponse(status="completed", outputs={"file_uri": f"/tmp/{node_id}"})
|
||||
|
||||
return handler
|
||||
|
||||
definition = _definition(
|
||||
nodes=[
|
||||
{"id": "step1", "node_type": "track1", "inputs": {}},
|
||||
{"id": "step2", "node_type": "track2", "inputs": {}},
|
||||
],
|
||||
edges=[{"from": "step1", "to": "step2"}],
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
db.create_run(_run("run-9", "input"))
|
||||
db.create_artifact({
|
||||
"run_id": "run-9", "node_id": "step1", "name": "step1.file_uri",
|
||||
"uri": "/tmp/step1", "kind": "file",
|
||||
})
|
||||
from wov_sdk.models import NodeManifest
|
||||
|
||||
registry.register(
|
||||
NodeManifest(id="track1", name="t1", version="1", capability="c", command=["python"]),
|
||||
tracking_handler("step1"),
|
||||
)
|
||||
registry.register(
|
||||
NodeManifest(id="track2", name="t2", version="1", capability="c", command=["python"]),
|
||||
tracking_handler("step2"),
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-9")
|
||||
|
||||
# 验证结果:只调用了 step2。
|
||||
assert calls == ["step2"]
|
||||
assert db.get_run("run-9")["status"] == "COMPLETED"
|
||||
|
||||
|
||||
def test_execute_run_applies_param_overrides(tmp_path: Path) -> None:
|
||||
"""param_overrides 按节点 ID 合并进节点参数(前端框选 crop 的通道)。"""
|
||||
# 数据:节点参数与覆盖值同时存在。
|
||||
received: list[dict] = []
|
||||
|
||||
def handler(request):
|
||||
from wov_sdk.models import InvokeResponse
|
||||
|
||||
received.append(dict(request.params))
|
||||
return InvokeResponse(status="completed", outputs={"file_uri": "/tmp/x"})
|
||||
|
||||
definition = _definition(
|
||||
nodes=[{
|
||||
"id": "step", "node_type": "param-node",
|
||||
"inputs": {}, "params": {"interval_seconds": 0.5, "crop": [0, 0, 1, 1]},
|
||||
}],
|
||||
edges=[],
|
||||
)
|
||||
storage = tmp_path / "storage"
|
||||
db = _db_with_workflow(tmp_path, definition)
|
||||
db.create_run(_run("run-10", "input", param_overrides={"step": {"crop": [0, 0.75, 1, 0.25]}}))
|
||||
from wov_sdk.models import NodeManifest
|
||||
|
||||
registry.register(
|
||||
NodeManifest(id="param-node", name="p", version="1", capability="c", command=["python"]),
|
||||
handler,
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
_scheduler(db, storage).execute_run("run-10")
|
||||
|
||||
# 验证结果:覆盖值生效,未覆盖的参数保持原样。
|
||||
assert received == [{"interval_seconds": 0.5, "crop": [0, 0.75, 1, 0.25]}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 线程生命周期
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_start_and_stop_are_idempotent(tmp_path: Path) -> None:
|
||||
"""start 重复调用不产生多余线程;stop 正常结束。"""
|
||||
# 数据:空库 + 调度器。
|
||||
db = Database(tmp_path / "wov.db")
|
||||
scheduler = WorkflowScheduler(db, tmp_path / "storage", interval_seconds=999)
|
||||
|
||||
# 测试过程
|
||||
scheduler.start()
|
||||
first = scheduler._thread
|
||||
scheduler.start()
|
||||
second = scheduler._thread
|
||||
scheduler.stop()
|
||||
|
||||
# 验证结果
|
||||
assert first is second
|
||||
assert scheduler._thread is None
|
||||
@@ -0,0 +1,127 @@
|
||||
"""src/wov_app/seed.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/seed.py`(从 workflows/*.json 载入内置工作流,幂等),
|
||||
可独立调用。用例使用真实仓库工作流 JSON 与临时 SQLite 库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from wov_app.db import Database
|
||||
from wov_app.seed import seed_default_workflows
|
||||
|
||||
# 仓库根与真实内置工作流目录。
|
||||
WORKSPACE = Path(__file__).resolve().parents[3]
|
||||
WORKFLOWS_DIR = WORKSPACE / "workflows"
|
||||
|
||||
|
||||
def _db(tmp_path: Path) -> Database:
|
||||
"""每个用例一个临时 SQLite 库。"""
|
||||
return Database(tmp_path / "wov.db")
|
||||
|
||||
|
||||
def test_seed_loads_all_builtin_workflows(tmp_path: Path) -> None:
|
||||
"""真实 workflows/*.json 全部写入库,且数量与文件数一致。"""
|
||||
# 数据:仓库真实工作流数据文件。
|
||||
db = _db(tmp_path)
|
||||
expected = {p.stem for p in WORKFLOWS_DIR.glob("*.json")}
|
||||
assert expected, "仓库应至少有一个内置工作流数据文件"
|
||||
|
||||
# 测试过程
|
||||
created = seed_default_workflows(db, WORKFLOWS_DIR)
|
||||
stored = {w["id"] for w in db.list_workflows()}
|
||||
|
||||
# 验证结果
|
||||
assert created == len(expected)
|
||||
assert stored == expected
|
||||
|
||||
|
||||
def test_seed_creates_version_record_with_valid_dag(tmp_path: Path) -> None:
|
||||
"""每个工作流写入 v1 版本记录,DAG 通过结构校验(节点/边合法)。"""
|
||||
# 数据:真实工作流目录。
|
||||
db = _db(tmp_path)
|
||||
|
||||
# 测试过程
|
||||
seed_default_workflows(db, WORKFLOWS_DIR)
|
||||
|
||||
# 验证结果:以真实 OCR 工作流为例,节点与版本齐全。
|
||||
version = db.get_latest_workflow_version("ocr-subtitle")
|
||||
assert version is not None
|
||||
definition = version["definition"]
|
||||
node_ids = {n["id"] for n in definition["nodes"]}
|
||||
assert {"extract", "ocr", "filter"} <= node_ids
|
||||
assert definition["edges"]
|
||||
|
||||
|
||||
def test_seed_is_idempotent(tmp_path: Path) -> None:
|
||||
"""重复 seed 不覆盖已有工作流,第二次返回创建数 0。"""
|
||||
# 数据:先 seed 一次。
|
||||
db = _db(tmp_path)
|
||||
first = seed_default_workflows(db, WORKFLOWS_DIR)
|
||||
|
||||
# 测试过程:再次 seed。
|
||||
second = seed_default_workflows(db, WORKFLOWS_DIR)
|
||||
|
||||
# 验证结果
|
||||
assert first > 0
|
||||
assert second == 0
|
||||
assert len(db.list_workflows()) == first
|
||||
|
||||
|
||||
def test_seed_does_not_overwrite_user_modification(tmp_path: Path) -> None:
|
||||
"""已存在的工作流不被 seed 覆盖(保护用户改过的数据)。"""
|
||||
# 数据:先写入一个与内置同 ID 的自定义工作流。
|
||||
db = _db(tmp_path)
|
||||
db.upsert_workflow({
|
||||
"id": "zh-direct", "name": "用户改过的名字", "description": "", "published": 0,
|
||||
"latest_version": 1,
|
||||
})
|
||||
|
||||
# 测试过程
|
||||
seed_default_workflows(db, WORKFLOWS_DIR)
|
||||
|
||||
# 验证结果:用户版本保留。
|
||||
assert db.get_workflow("zh-direct")["name"] == "用户改过的名字"
|
||||
|
||||
|
||||
def test_seed_from_empty_directory_creates_nothing(tmp_path: Path) -> None:
|
||||
"""空目录不创建任何工作流(数据驱动,无硬编码兜底)。"""
|
||||
# 数据:空目录。
|
||||
empty = tmp_path / "workflows"
|
||||
empty.mkdir()
|
||||
db = _db(tmp_path)
|
||||
|
||||
# 测试过程
|
||||
created = seed_default_workflows(db, empty)
|
||||
|
||||
# 验证结果
|
||||
assert created == 0
|
||||
assert db.list_workflows() == []
|
||||
|
||||
|
||||
def test_seed_marks_workflows_published(tmp_path: Path) -> None:
|
||||
"""内置工作流默认已发布(用户端可直接选择执行)。"""
|
||||
# 数据:真实工作流目录。
|
||||
db = _db(tmp_path)
|
||||
|
||||
# 测试过程
|
||||
seed_default_workflows(db, WORKFLOWS_DIR)
|
||||
|
||||
# 验证结果
|
||||
for workflow in db.list_workflows():
|
||||
assert workflow["published"] == 1
|
||||
|
||||
|
||||
def test_seed_honors_version_from_data_file(tmp_path: Path) -> None:
|
||||
"""版本号取自数据文件(不同工作流可有不同当前版本)。"""
|
||||
# 数据:真实工作流目录(含 v7 的 ocr-subtitle)。
|
||||
db = _db(tmp_path)
|
||||
|
||||
# 测试过程
|
||||
seed_default_workflows(db, WORKFLOWS_DIR)
|
||||
|
||||
# 验证结果:与数据文件声明一致。
|
||||
payload = json.loads((WORKFLOWS_DIR / "ocr-subtitle.json").read_text(encoding="utf-8"))
|
||||
assert db.get_workflow("ocr-subtitle")["latest_version"] == int(payload.get("version", 1))
|
||||
@@ -0,0 +1,131 @@
|
||||
"""src/wov_app/storage.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/storage.py`(产物文件原子复制),被 scheduler 与
|
||||
batch 复用,也可独立调用。用例使用真实文件系统(tmp_path)验证复制完整性与
|
||||
失败时对原目标的保护。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wov_app.storage import atomic_copy
|
||||
|
||||
|
||||
def test_atomic_copy_creates_target_with_same_content(tmp_path: Path) -> None:
|
||||
"""复制后目标存在且内容与源一致(源保留)。"""
|
||||
# 数据:真实的源文件(含中文内容)。
|
||||
source = tmp_path / "src.srt"
|
||||
source.write_text("1\n00:00:01,000 --> 00:00:02,000\n你好\n", encoding="utf-8")
|
||||
target = tmp_path / "out" / "movie.CN.srt"
|
||||
|
||||
# 测试过程
|
||||
atomic_copy(source, target)
|
||||
|
||||
# 验证结果:源保留、目标内容一致。
|
||||
assert source.is_file()
|
||||
assert target.read_text(encoding="utf-8") == source.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_atomic_copy_creates_parent_directories(tmp_path: Path) -> None:
|
||||
"""目标父目录不存在时自动创建(产物目录可能尚未建立)。"""
|
||||
# 数据:深层不存在的目录。
|
||||
source = tmp_path / "a.txt"
|
||||
source.write_text("x", encoding="utf-8")
|
||||
target = tmp_path / "deep" / "nested" / "dir" / "a.txt"
|
||||
|
||||
# 测试过程
|
||||
atomic_copy(source, target)
|
||||
|
||||
# 验证结果
|
||||
assert target.is_file()
|
||||
assert target.parent.is_dir()
|
||||
|
||||
|
||||
def test_atomic_copy_overwrites_existing_target(tmp_path: Path) -> None:
|
||||
"""目标已存在时被完整替换(旧内容不残留)。"""
|
||||
# 数据:已存在的旧目标(内容更长,用于检测残留)。
|
||||
source = tmp_path / "new.srt"
|
||||
source.write_text("新", encoding="utf-8")
|
||||
target = tmp_path / "movie.CN.srt"
|
||||
target.write_text("旧的非常长的内容" * 100, encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
atomic_copy(source, target)
|
||||
|
||||
# 验证结果:内容被完全替换,无旧内容残留。
|
||||
assert target.read_text(encoding="utf-8") == "新"
|
||||
|
||||
|
||||
def test_atomic_copy_preserves_binary_content(tmp_path: Path) -> None:
|
||||
"""二进制内容(如 ASS 的 BOM/CRLF)逐字节一致。"""
|
||||
# 数据:带 BOM 与 CRLF 的字节序列。
|
||||
payload = "\ufeff[Script Info]\r\nTitle: 测试\r\n".encode("utf-8")
|
||||
source = tmp_path / "src.ass"
|
||||
source.write_bytes(payload)
|
||||
target = tmp_path / "dst.ass"
|
||||
|
||||
# 测试过程
|
||||
atomic_copy(source, target)
|
||||
|
||||
# 验证结果:逐字节一致。
|
||||
assert target.read_bytes() == payload
|
||||
|
||||
|
||||
def test_atomic_copy_cleans_temp_file_on_success(tmp_path: Path) -> None:
|
||||
"""成功后不留下临时文件(目录里只有源与目标)。"""
|
||||
# 数据:源文件。
|
||||
source = tmp_path / "src.txt"
|
||||
source.write_text("x", encoding="utf-8")
|
||||
target = tmp_path / "dst.txt"
|
||||
|
||||
# 测试过程
|
||||
atomic_copy(source, target)
|
||||
|
||||
# 验证结果:无 *.tmp 残留。
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_atomic_copy_keeps_target_and_cleans_temp_on_failure(tmp_path: Path) -> None:
|
||||
"""复制失败时保留原目标、清理临时文件(R03:不暴露写了一半的成品)。"""
|
||||
# 数据:源文件不存在(触发 copy2 失败),目标已存在有效内容。
|
||||
missing = tmp_path / "missing.txt"
|
||||
target = tmp_path / "movie.CN.srt"
|
||||
target.write_text("原有有效成品", encoding="utf-8")
|
||||
|
||||
# 测试过程与验证结果:抛错,原目标未被破坏,无临时文件残留。
|
||||
with pytest.raises(FileNotFoundError):
|
||||
atomic_copy(missing, target)
|
||||
assert target.read_text(encoding="utf-8") == "原有有效成品"
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_atomic_copy_exists_with_missing_target_on_failure(tmp_path: Path) -> None:
|
||||
"""复制失败且目标原本不存在时,不产生残缺目标文件。"""
|
||||
# 数据:源不存在、目标不存在。
|
||||
missing = tmp_path / "missing.txt"
|
||||
target = tmp_path / "new.CN.srt"
|
||||
|
||||
# 测试过程与验证结果
|
||||
with pytest.raises(FileNotFoundError):
|
||||
atomic_copy(missing, target)
|
||||
assert not target.exists()
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_atomic_copy_preserves_source_permissions_semantics(tmp_path: Path) -> None:
|
||||
"""目标可读(copy2 保留元数据,权限不会导致后续读取失败)。"""
|
||||
# 数据:普通源文件。
|
||||
source = tmp_path / "src.txt"
|
||||
source.write_text("内容", encoding="utf-8")
|
||||
target = tmp_path / "dst.txt"
|
||||
|
||||
# 测试过程
|
||||
atomic_copy(source, target)
|
||||
|
||||
# 验证结果:目标可读且非空。
|
||||
assert target.read_text(encoding="utf-8") == "内容"
|
||||
assert target.stat().st_size > 0
|
||||
Reference in New Issue
Block a user