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>
154 lines
4.4 KiB
Python
154 lines
4.4 KiB
Python
"""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
|