170 lines
7.9 KiB
Python
170 lines
7.9 KiB
Python
"""产物收尾回归:真实 SQLite、字幕文件和 API 下载验证暂停及故障恢复。"""
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from wov_app.db import Database
|
|
from wov_app.main import app
|
|
from wov_app.scheduler import WorkflowScheduler
|
|
from wov_sdk.models import WorkflowDefinition
|
|
|
|
|
|
@pytest.fixture
|
|
def subtitle(tmp_path):
|
|
"""复用真实字幕资产,不在测试中生成模型输出或占位媒体。"""
|
|
source = Path(__file__).resolve().parent.parent / "testdata/ocr_srt_run_ac7f480a3ccb.srt"
|
|
if not source.is_file():
|
|
pytest.skip("缺少真实字幕资产")
|
|
target = tmp_path / "original.srt"
|
|
shutil.copy2(source, target)
|
|
return target
|
|
|
|
|
|
def _seed(db, source, finals):
|
|
"""登记已完成节点的真实产物,模拟节点执行结束后的断点。"""
|
|
definition = {"name": "finalization", "version": 1,
|
|
"nodes": [{"id": "step", "node_type": "echo"}],
|
|
"final_outputs": finals}
|
|
db.upsert_workflow({"id": "finalization", "name": "收尾回归"})
|
|
db.create_workflow_version("finalization", 1, definition)
|
|
db.create_run({"id": "run_finalization", "workflow_id": "finalization",
|
|
"workflow_version": 1, "status": "QUEUED", "input_uri": str(source),
|
|
"created_at": "2026-01-01T00:00:00+00:00", "updated_at": "2026-01-01T00:00:00+00:00"})
|
|
db.create_artifact({"run_id": "run_finalization", "node_id": "step",
|
|
"name": "step.file_uri", "uri": str(source)})
|
|
return WorkflowDefinition.from_dict(definition)
|
|
|
|
|
|
def test_pause_during_final_registration_keeps_downloads(subtitle, tmp_path, monkeypatch):
|
|
"""在第一个最终别名登记后暂停,再继续,两别名均可下载且 URI 不漂移。"""
|
|
db = Database(tmp_path / "test.db")
|
|
_seed(db, subtitle, {"result": "step.file_uri", "copy": "step.file_uri"})
|
|
original = subtitle.read_bytes()
|
|
create = db.create_artifact
|
|
|
|
def pause_once(artifact):
|
|
create(artifact)
|
|
if artifact["name"] == "result":
|
|
db.pause_run("run_finalization", "2026-01-01T00:00:00+00:00")
|
|
|
|
scheduler = WorkflowScheduler(db, tmp_path / "storage")
|
|
with monkeypatch.context() as patch:
|
|
patch.setattr(db, "create_artifact", pause_once)
|
|
scheduler.execute_run("run_finalization")
|
|
first_uri = db.get_artifact("run_finalization", "result")["uri"]
|
|
assert db.get_run("run_finalization")["status"] == "PAUSED"
|
|
db.resume_run("run_finalization", "2026-01-01T00:00:00+00:00")
|
|
scheduler.execute_run("run_finalization")
|
|
assert db.get_run("run_finalization")["status"] == "COMPLETED"
|
|
assert db.get_artifact("run_finalization", "result")["uri"] == first_uri
|
|
with TestClient(app) as client:
|
|
monkeypatch.setattr(app.state, "db", db)
|
|
for alias in ("result", "copy", "step.file_uri"):
|
|
response = client.get(f"/api/runs/run_finalization/artifacts/{alias}")
|
|
assert response.status_code == 200
|
|
assert response.content == original
|
|
|
|
|
|
def test_legacy_renamed_final_survives_resume(subtitle, tmp_path):
|
|
"""兼容旧版已改名但最终记录有效的断点,恢复不能覆盖成不存在的旧路径。"""
|
|
db = Database(tmp_path / "test.db")
|
|
_seed(db, subtitle, {"result": "step.file_uri"})
|
|
renamed = subtitle.with_name("legacy.srt")
|
|
subtitle.rename(renamed)
|
|
db.create_artifact({"run_id": "run_finalization", "node_id": "step",
|
|
"name": "result", "uri": str(renamed)})
|
|
WorkflowScheduler(db, tmp_path / "storage").execute_run("run_finalization")
|
|
assert db.get_run("run_finalization")["status"] == "COMPLETED"
|
|
assert Path(db.get_artifact("run_finalization", "result")["uri"]).read_bytes() == renamed.read_bytes()
|
|
|
|
|
|
def test_failed_final_copy_preserves_source_and_can_retry(subtitle, tmp_path, monkeypatch):
|
|
"""复制中途写入失败不登记残缺成品,源字幕可用,恢复后可成功收尾。"""
|
|
db = Database(tmp_path / "test.db")
|
|
_seed(db, subtitle, {"result": "step.file_uri"})
|
|
expected = subtitle.read_bytes()
|
|
scheduler = WorkflowScheduler(db, tmp_path / "storage")
|
|
|
|
def fail_copy(source, destination, **kwargs):
|
|
# 文件 I/O 边界模拟磁盘写满:真实写出截断内容后报错。
|
|
Path(destination).write_bytes(Path(source).read_bytes()[:64])
|
|
raise OSError("disk full")
|
|
|
|
with monkeypatch.context() as patch:
|
|
patch.setattr(shutil, "copy2", fail_copy)
|
|
scheduler.execute_run("run_finalization")
|
|
assert db.get_run("run_finalization")["status"] == "FAILED"
|
|
assert db.get_artifact("run_finalization", "result") is None
|
|
assert subtitle.read_bytes() == expected
|
|
assert not list((tmp_path / "storage").rglob("*.tmp"))
|
|
db.update_run("run_finalization", status="QUEUED", updated_at="2026-01-01T00:00:00+00:00")
|
|
scheduler.execute_run("run_finalization")
|
|
assert db.get_run("run_finalization")["status"] == "COMPLETED"
|
|
assert Path(db.get_artifact("run_finalization", "result")["uri"]).read_bytes() == expected
|
|
|
|
|
|
def test_batch_copy_failure_preserves_old_product_and_workspace(subtitle, tmp_path, monkeypatch):
|
|
"""批量成品写到一半失败:旧字幕不变、run/工作空间保留,重试可收尾。"""
|
|
from wov_app.batch import BatchWorker, create_job
|
|
|
|
video_asset = Path(__file__).resolve().parent.parent / "testdata/subtitle_10s.mp4"
|
|
if not video_asset.is_file():
|
|
pytest.skip("缺少真实视频资产")
|
|
library = tmp_path / "library"
|
|
library.mkdir()
|
|
video = library / "movie.mp4"
|
|
shutil.copy2(video_asset, video)
|
|
db = Database(tmp_path / "test.db")
|
|
_seed(db, subtitle, {"result": "step.file_uri"})
|
|
db.upsert_workflow({"id": "finalization", "name": "收尾回归", "published": 1, "latest_version": 1})
|
|
job_id = create_job(db, str(library), "finalization")
|
|
item = db.list_batch_videos(job_id)[0]
|
|
work_dir = Path(item["work_dir"])
|
|
work_dir.mkdir(parents=True)
|
|
source = work_dir / "result.srt"
|
|
shutil.copy2(subtitle, source)
|
|
now = "2026-01-01T00:00:00+00:00"
|
|
db.update_batch_video(item["id"], run_id="run_finalization", updated_at=now)
|
|
db.update_run("run_finalization", status="COMPLETED", updated_at=now)
|
|
db.create_artifact({"run_id": "run_finalization", "node_id": "step",
|
|
"name": "result", "uri": str(source)})
|
|
target = library / "movie.CN.srt"
|
|
shutil.copy2(subtitle, target)
|
|
original = target.read_bytes()
|
|
worker = BatchWorker(db)
|
|
|
|
def fail_copy(source, destination, **kwargs):
|
|
Path(destination).write_bytes(Path(source).read_bytes()[:64])
|
|
raise OSError("disk full")
|
|
|
|
with monkeypatch.context() as patch:
|
|
patch.setattr(shutil, "copy2", fail_copy)
|
|
worker._process_job(db.get_batch_job(job_id))
|
|
assert db.get_batch_video(item["id"])["status"] == "FAILED"
|
|
assert target.read_bytes() == original
|
|
assert db.get_run("run_finalization") is not None
|
|
assert source.read_bytes() == original
|
|
assert not list(library.glob("*.tmp"))
|
|
worker._process_job(db.get_batch_job(job_id))
|
|
assert db.get_batch_video(item["id"])["status"] == "COMPLETED"
|
|
assert db.get_run("run_finalization") is None
|
|
assert not work_dir.exists()
|
|
assert target.read_bytes() == original
|
|
assert video.read_bytes() == video_asset.read_bytes()
|
|
|
|
|
|
@pytest.mark.parametrize("missing_ref", [False, True])
|
|
def test_missing_final_fails_run(subtitle, tmp_path, missing_ref):
|
|
"""必需最终输出无引用或文件丢失都应 FAILED,不能成功登记失效链接。"""
|
|
db = Database(tmp_path / "test.db")
|
|
_seed(db, subtitle, {"result": "step.missing" if missing_ref else "step.file_uri"})
|
|
if not missing_ref:
|
|
subtitle.unlink()
|
|
WorkflowScheduler(db, tmp_path / "storage").execute_run("run_finalization")
|
|
run = db.get_run("run_finalization")
|
|
assert run["status"] == "FAILED"
|
|
assert "result" in run["error"]
|