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:
2026-09-13 15:40:56 +08:00
parent 966f3e6b4b
commit 8a715a8064
139 changed files with 20810 additions and 10733 deletions
View File
View File
+414
View File
@@ -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
View File
+194
View File
@@ -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
View File
+332
View File
@@ -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 → QUEUEDPAUSED 保持不变。"""
# 数据: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"]
View File
+88
View File
@@ -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)
View File
+216
View File
@@ -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:
"""WorkflowCreateid/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:
"""BatchJobCreaterecursive 默认 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")
+257
View File
@@ -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()
View File
+192
View File
@@ -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"]
View File
+319
View File
@@ -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
+310
View File
@@ -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"
+494
View File
@@ -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
View File
+127
View File
@@ -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))
View File
+131
View File
@@ -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