"""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_list_runs_excludes_batch_runs_by_default(db_with_workflow: Database) -> None: """任务列表默认不含批量 run:它们属于批量页的任务明细,会把 20 条窗口占满。""" # 数据:一条上传任务 + 两条批量 run。 db_with_workflow.create_run(_run("upload", created_at="2026-09-01T00:00:00+00:00")) db_with_workflow.create_run(_run("batch-1", source="batch", created_at="2026-09-02T00:00:00+00:00")) db_with_workflow.create_run(_run("batch-2", source="batch", created_at="2026-09-03T00:00:00+00:00")) # 测试过程 default_ids = [r["id"] for r in db_with_workflow.list_runs()] all_ids = [r["id"] for r in db_with_workflow.list_runs(include_batch=True)] # 验证结果:默认只列上传任务,显式要求时才包含批量 run。 assert default_ids == ["upload"] assert all_ids == ["batch-2", "batch-1", "upload"] def test_delete_run_removes_record_and_artifacts(db_with_workflow: Database) -> None: """删除任务同时清理其产物记录。""" # 数据:任务 + 一条产物。 db_with_workflow.create_run(_run("r1")) db_with_workflow.create_artifact({ "run_id": "r1", "node_id": "a", "name": "a.data_uri", "uri": "/tmp/x", "kind": "file", }) # 测试过程 db_with_workflow.delete_run("r1") # 验证结果 assert db_with_workflow.get_run("r1") is None assert db_with_workflow.list_artifacts("r1") == [] # --------------------------------------------------------------------------- # 调度用查询:只有 QUEUED 会被拾起 # --------------------------------------------------------------------------- def test_next_queued_run_returns_oldest_queued(db_with_workflow: Database) -> None: """按创建时间返回最早的 QUEUED 任务。""" # 数据:一条较早的 QUEUED。 db_with_workflow.create_run(_run("a", created_at="2026-09-01T00:00:00+00:00")) # 测试过程 picked = db_with_workflow.next_queued_run() # 验证结果 assert picked["id"] == "a" def test_next_queued_run_skips_paused(db_with_workflow: Database) -> None: """PAUSED 不被拾起(必须显式 resume;修复"点击暂停反而开始任务"回归)。""" # 数据:一条 PAUSED(更早)+ 一条 QUEUED(更晚)。 db_with_workflow.create_run(_run("paused", status="PAUSED", created_at="2026-09-01T00:00:00+00:00")) db_with_workflow.create_run(_run("queued", created_at="2026-09-02T00:00:00+00:00")) # 测试过程 picked = db_with_workflow.next_queued_run() # 验证结果:取的是 QUEUED 那条。 assert picked["id"] == "queued" def test_next_queued_run_skips_batch_source(db_with_workflow: Database) -> None: """source=batch 的任务由批量引擎执行,主调度器不拾起(存储目录不同)。""" # 数据:一条 batch 来源的 QUEUED。 db_with_workflow.create_run(_run("batch-1", source="batch")) # 测试过程与验证结果 assert db_with_workflow.next_queued_run() is None def test_pause_and_resume_run(db_with_workflow: Database) -> None: """暂停置 PAUSED,继续置回 QUEUED(等待调度器断点续跑)。""" # 数据:一条 QUEUED。 db_with_workflow.create_run(_run("r1")) # 测试过程 db_with_workflow.pause_run("r1", "t2") paused = db_with_workflow.get_run("r1")["status"] db_with_workflow.resume_run("r1", "t3") resumed = db_with_workflow.get_run("r1")["status"] # 验证结果 assert paused == "PAUSED" assert resumed == "QUEUED" def test_recover_interrupted_runs_requeues_running_only(db_with_workflow: Database) -> None: """重启恢复:RUNNING → QUEUED,PAUSED 保持不变。""" # 数据:RUNNING 与 PAUSED 各一条。 db_with_workflow.create_run(_run("running", status="RUNNING")) db_with_workflow.create_run(_run("paused", status="PAUSED")) # 测试过程 count = db_with_workflow.recover_interrupted_runs("t2") # 验证结果:只恢复 1 条,PAUSED 不变。 assert count == 1 assert db_with_workflow.get_run("running")["status"] == "QUEUED" assert db_with_workflow.get_run("paused")["status"] == "PAUSED" def test_recover_interrupted_batch_jobs_requeues_running(db_with_workflow: Database) -> None: """重启恢复:RUNNING 的批量任务 → QUEUED(否则永久无人拾起)。""" # 数据:一条 RUNNING 批量任务(批量明细表对 run 有外键,先建任务记录)。 db_with_workflow.create_run(_run("bv-run-1")) db_with_workflow.create_batch_job({ "id": "job-1", "folder_path": "/videos", "workflow_id": "wf", "recursive": False, "status": "RUNNING", "created_at": "t1", "updated_at": "t1", }) # 测试过程 count = db_with_workflow.recover_interrupted_batch_jobs("t2") # 验证结果 assert count == 1 assert db_with_workflow.get_batch_job("job-1")["status"] == "QUEUED" # --------------------------------------------------------------------------- # 产物与断点恢复 # --------------------------------------------------------------------------- def test_create_and_get_artifact(db_with_workflow: Database) -> None: """产物按 run + name 记录,可按名精确读取。""" # 数据:一条任务 + 一条产物(产物对 run 有外键)。 db_with_workflow.create_run(_run("r1")) db_with_workflow.create_artifact({ "run_id": "r1", "node_id": "ocr", "name": "ocr.srt_uri", "uri": "/tmp/out/subtitle.srt", "kind": "file", }) # 测试过程 stored = db_with_workflow.get_artifact("r1", "ocr.srt_uri") # 验证结果 assert stored["uri"] == "/tmp/out/subtitle.srt" assert db_with_workflow.get_artifact("r1", "missing") is None def test_restore_run_outputs_strips_node_prefix(db_with_workflow: Database) -> None: """恢复产物时剥去"节点ID."前缀,还原为 {输出名: URI}(断点续跑依赖)。""" # 数据:两个节点的产物。 db_with_workflow.create_run(_run("r1")) db_with_workflow.create_artifact({"run_id": "r1", "node_id": "extract", "name": "extract.audio_uri", "uri": "/a.wav", "kind": "file"}) db_with_workflow.create_artifact({"run_id": "r1", "node_id": "asr", "name": "asr.srt_uri", "uri": "/a.srt", "kind": "file"}) # 测试过程 outputs = db_with_workflow.restore_run_outputs("r1") # 验证结果 assert outputs == { "extract": {"audio_uri": "/a.wav"}, "asr": {"srt_uri": "/a.srt"}, } def test_reset_run_clears_state_and_artifacts(db_with_workflow: Database) -> None: """reset_run 清空产物与错误、回到 QUEUED(供失败任务重跑)。""" # 数据:一条 FAILED 任务带产物与错误。 db_with_workflow.create_run(_run("r1", status="FAILED", error="boom")) db_with_workflow.create_artifact({"run_id": "r1", "node_id": "a", "name": "a.x", "uri": "/x", "kind": "file"}) # 测试过程 db_with_workflow.reset_run("r1", "t2") stored = db_with_workflow.get_run("r1") # 验证结果 assert stored["status"] == "QUEUED" assert stored["error"] is None assert db_with_workflow.list_artifacts("r1") == [] def test_list_run_ids(db_with_workflow: Database) -> None: """列出全部任务 ID(供孤儿清理比对文件系统)。""" # 数据:两条任务。 db_with_workflow.create_run(_run("r1")) db_with_workflow.create_run(_run("r2")) # 测试过程与验证结果 assert sorted(db_with_workflow.list_run_ids()) == ["r1", "r2"] def test_recover_interrupted_batch_jobs_requeues_zombie_completed(db_with_workflow: Database) -> None: """重启恢复:COMPLETED 但仍有未结束视频的僵尸任务也要置回 QUEUED。 曾出现「任务已完成、视频仍未处理」的僵尸状态:完成标记先于视频收尾写出, 而引擎只拾取 QUEUED,剩余视频永久无人处理。 """ # 数据:一个仍含 PENDING 视频的 COMPLETED 任务 + 一个全部结束的 COMPLETED 任务。 db_with_workflow.create_batch_job({ "id": "zombie", "folder_path": "/videos", "workflow_id": "wf", "recursive": False, "status": "COMPLETED", "created_at": "t1", "updated_at": "t1", }) db_with_workflow.create_batch_video({ "id": "zombie-v1", "job_id": "zombie", "video_path": "/videos/a.mp4", "work_dir": "/tmp/zombie", "status": "PENDING", "created_at": "t1", "updated_at": "t1", }) db_with_workflow.create_batch_job({ "id": "done", "folder_path": "/videos", "workflow_id": "wf", "recursive": False, "status": "COMPLETED", "created_at": "t1", "updated_at": "t1", }) db_with_workflow.create_batch_video({ "id": "done-v1", "job_id": "done", "video_path": "/videos/b.mp4", "work_dir": "/tmp/done", "status": "COMPLETED", "created_at": "t1", "updated_at": "t1", }) # 测试过程 count = db_with_workflow.recover_interrupted_batch_jobs("t2") # 验证结果:只有僵尸任务被置回 QUEUED,真正完成的任务不受影响。 assert count == 1 assert db_with_workflow.get_batch_job("zombie")["status"] == "QUEUED" assert db_with_workflow.get_batch_job("done")["status"] == "COMPLETED" def test_wal_mode_enabled_for_concurrent_pipeline_writes(tmp_path: Path) -> None: """数据:临时库文件。 过程:打开一次连接并读取日志模式。 验证:处于 WAL 模式——批量流水线多线程并发写产物/进度时,读写可并行, 不会互相阻塞成 database is locked。 """ db = Database(tmp_path / "wov.db") with db._connect() as conn: mode = conn.execute("PRAGMA journal_mode").fetchone()[0] assert mode.lower() == "wal" def test_concurrent_artifact_writes_survive(tmp_path: Path) -> None: """数据:四个线程各自往同一任务写入 25 条产物记录。 过程:并发调用 create_artifact(批量流水线里多个视频同时落产物的场景)。 验证:全部写入成功且记录数正确,没有因写锁竞争丢数据或抛异常。 """ import threading db = Database(tmp_path / "wov.db") db.upsert_workflow({"id": "wf", "name": "流程", "description": "", "published": 1, "latest_version": 1}) db.create_workflow_version("wf", 1, {"nodes": []}) db.create_run({ "id": "run-concurrent", "workflow_id": "wf", "workflow_version": 1, "status": "RUNNING", "source": "batch", "created_at": "t1", "updated_at": "t1", }) errors: list[Exception] = [] def writer(worker: int) -> None: try: for index in range(25): db.create_artifact({ "run_id": "run-concurrent", "node_id": "asr", "name": f"w{worker}-{index}", "uri": f"/tmp/w{worker}-{index}.srt", "mime_type": "text/plain", "size": index, }) except Exception as exc: # noqa: BLE001 - 用例要报告任意写失败 errors.append(exc) threads = [threading.Thread(target=writer, args=(n,)) for n in range(4)] for thread in threads: thread.start() for thread in threads: thread.join() assert errors == [] with db._connect() as conn: count = conn.execute( "SELECT COUNT(*) FROM artifacts WHERE run_id = ?", ("run-concurrent",), ).fetchone()[0] assert count == 100