Files
writer-work-flow/apps/api/tests/test_job_runner.py
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

154 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""T4.1 通用长任务 runner 单测ARCH §7.4)。
直接 `await run_job(...)`(不起后台线程、不连真 DB注入 fake session 工厂 + fake job
repo + fake work断言
- 成功路set_running → work → complete(result) → commit
- 失败路work 抛 → 回滚 → 新 session 里 fail(error) → commit且异常不冒泡。
独立 session 纪律 = `run_job` 自建 session经 session_factory与 `run_overdue_scan`
先例一致。这里的 fake session 工厂记录开了几次 session + 各次 commit。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from ww_api.services.job_runner import run_job
from ww_core.domain.job_repo import (
PROGRESS_COMPLETE,
STATUS_DONE,
STATUS_FAILED,
STATUS_RUNNING,
JobView,
)
JOB_ID = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
class _FakeSession:
"""最小 fake记录 commit 次数(提交边界断言)。"""
def __init__(self) -> None:
self.commits = 0
async def commit(self) -> None:
self.commits += 1
class _FakeSessionFactory:
"""独立 session 工厂替身:`()` → async-CM 产新 `_FakeSession`,记录开了几次。"""
def __init__(self) -> None:
self.sessions: list[_FakeSession] = []
def __call__(self) -> Any:
session = _FakeSession()
self.sessions.append(session)
@asynccontextmanager
async def _cm() -> AsyncIterator[_FakeSession]:
yield session
return _cm()
@dataclass
class _FakeJobRepo:
"""内存 job repo记录状态流转 + result/error不触 DB"""
status: str = "queued"
progress: int = 0
result: dict[str, Any] | None = None
error: str | None = None
calls: list[str] = field(default_factory=list)
def _view(self, job_id: uuid.UUID) -> JobView:
return JobView(
id=job_id,
kind="style_learn",
status=self.status,
progress=self.progress,
result=self.result,
error=self.error,
)
async def set_running(self, job_id: uuid.UUID) -> JobView:
self.calls.append("set_running")
self.status = STATUS_RUNNING
return self._view(job_id)
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
self.calls.append("complete")
self.status = STATUS_DONE
self.progress = PROGRESS_COMPLETE
self.result = dict(result)
return self._view(job_id)
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
self.calls.append("fail")
self.status = STATUS_FAILED
self.error = error
return self._view(job_id)
# ---- success path ----
async def test_run_job_success_sets_done_and_commits() -> None:
factory = _FakeSessionFactory()
repo = _FakeJobRepo()
received: list[AsyncSession] = []
async def work(session: AsyncSession) -> dict[str, Any]:
received.append(session)
return {"version": 1, "dims_count": 16}
await run_job(
factory,
JOB_ID,
work,
repo_factory=lambda _s: repo,
)
assert repo.calls == ["set_running", "complete"]
assert repo.status == STATUS_DONE
assert repo.progress == PROGRESS_COMPLETE
assert repo.result == {"version": 1, "dims_count": 16}
# 自建了一个独立 session 且提交了一次
assert len(factory.sessions) == 1
assert factory.sessions[0].commits == 1
# work 拿到的就是 run_job 自建的 session
assert len(received) == 1
# ---- failure path ----
async def test_run_job_failure_sets_failed_and_does_not_raise() -> None:
factory = _FakeSessionFactory()
repo = _FakeJobRepo()
async def work(_session: AsyncSession) -> dict[str, Any]:
raise RuntimeError("extraction blew up")
# 异常被吞(后台任务边界),不冒泡
await run_job(
factory,
JOB_ID,
work,
repo_factory=lambda _s: repo,
)
assert "complete" not in repo.calls
assert "fail" in repo.calls
assert repo.status == STATUS_FAILED
assert repo.error == "extraction blew up"
# 失败置态在一个**全新** session 里完成并 commit原事务作废
assert len(factory.sessions) == 2
assert factory.sessions[1].commits == 1