feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点 - 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件 - 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板 - 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE - bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试 - M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug - 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
This commit is contained in:
@@ -8,10 +8,19 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView
|
||||
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
ForeshadowStatus,
|
||||
apply_overdue_scan,
|
||||
)
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
transition as apply_transition,
|
||||
)
|
||||
from ww_core.domain.project_repo import ProjectCreate, ProjectView
|
||||
from ww_core.domain.repositories import DigestView
|
||||
from ww_core.domain.review_repo import ReviewView
|
||||
@@ -172,14 +181,156 @@ class FakeDigestAppendRepo:
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""最小 fake:记录 commit 次数(验收事务边界断言)。"""
|
||||
"""最小 fake:记录 commit/rollback 次数(验收事务 + 端点提交边界断言)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.rollbacks += 1
|
||||
|
||||
|
||||
class FakeForeshadowRepo:
|
||||
"""实现 `ForeshadowLedgerRepo` Protocol(内存;状态机/进展/扫描复用纯函数)。
|
||||
|
||||
`raise_integrity_on` 模拟重复 code 的 DB 唯一约束冲突(register 抛 IntegrityError)。
|
||||
"""
|
||||
|
||||
def __init__(self, *, raise_integrity_on: set[str] | None = None) -> None:
|
||||
self.rows: dict[tuple[uuid.UUID, str], ForeshadowLedgerView] = {}
|
||||
self._raise_integrity_on = raise_integrity_on or set()
|
||||
|
||||
async def register(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
code: str,
|
||||
title: str,
|
||||
planted_at: int | None = None,
|
||||
content: str | None = None,
|
||||
expected_close_from: int | None = None,
|
||||
expected_close_to: int | None = None,
|
||||
importance: str | None = None,
|
||||
) -> ForeshadowLedgerView:
|
||||
if code in self._raise_integrity_on or (project_id, code) in self.rows:
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
raise IntegrityError("duplicate code", None, Exception("unique"))
|
||||
view = ForeshadowLedgerView(
|
||||
code=code,
|
||||
title=title,
|
||||
status=ForeshadowStatus.OPEN.value,
|
||||
planted_at=planted_at,
|
||||
content=content,
|
||||
expected_close_from=expected_close_from,
|
||||
expected_close_to=expected_close_to,
|
||||
importance=importance,
|
||||
)
|
||||
self.rows[(project_id, code)] = view
|
||||
return view
|
||||
|
||||
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
|
||||
return self.rows.get((project_id, code))
|
||||
|
||||
async def list_by_status(
|
||||
self, project_id: uuid.UUID, status: str | None = None
|
||||
) -> list[ForeshadowLedgerView]:
|
||||
rows = [v for (p, _), v in self.rows.items() if p == project_id]
|
||||
if status is not None:
|
||||
rows = [v for v in rows if v.status == status]
|
||||
return sorted(rows, key=lambda v: v.code)
|
||||
|
||||
async def transition(
|
||||
self, project_id: uuid.UUID, code: str, *, to_status: str
|
||||
) -> ForeshadowLedgerView:
|
||||
row = self.rows.get((project_id, code))
|
||||
if row is None:
|
||||
raise LookupError(f"foreshadow not found: {code}")
|
||||
new_status = apply_transition(
|
||||
ForeshadowStatus(row.status), ForeshadowStatus(to_status)
|
||||
).value
|
||||
updated = row.model_copy(update={"status": new_status})
|
||||
self.rows[(project_id, code)] = updated
|
||||
return updated
|
||||
|
||||
async def record_progress(
|
||||
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
|
||||
) -> ForeshadowLedgerView:
|
||||
row = self.rows.get((project_id, code))
|
||||
if row is None:
|
||||
raise LookupError(f"foreshadow not found: {code}")
|
||||
updated = row.model_copy(update={"progress": [*row.progress, dict(entry)]})
|
||||
self.rows[(project_id, code)] = updated
|
||||
return updated
|
||||
|
||||
async def scan_overdue(
|
||||
self, project_id: uuid.UUID, *, current_chapter: int
|
||||
) -> list[ForeshadowLedgerView]:
|
||||
changed: list[ForeshadowLedgerView] = []
|
||||
for key, row in list(self.rows.items()):
|
||||
new_status = apply_overdue_scan(
|
||||
ForeshadowStatus(row.status),
|
||||
current_chapter=current_chapter,
|
||||
expected_close_to=row.expected_close_to,
|
||||
).value
|
||||
if new_status != row.status:
|
||||
updated = row.model_copy(update={"status": new_status})
|
||||
self.rows[key] = updated
|
||||
changed.append(updated)
|
||||
return changed
|
||||
|
||||
|
||||
class FakeOutlineWriteRepo:
|
||||
"""实现 `OutlineWriteRepo` Protocol(内存;幂等覆盖同 (project_id, chapter_no))。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# (project_id, chapter_no) -> (volume, beats, foreshadow_windows)
|
||||
self.rows: dict[tuple[uuid.UUID, int], tuple[int, list[str], list[dict[str, Any]]]] = {}
|
||||
self.upsert_calls = 0
|
||||
|
||||
async def upsert_chapter(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
volume: int,
|
||||
chapter_no: int,
|
||||
beats: list[str],
|
||||
foreshadow_windows: list[dict[str, Any]],
|
||||
) -> OutlineWriteView:
|
||||
self.upsert_calls += 1
|
||||
windows = [dict(w) for w in foreshadow_windows]
|
||||
self.rows[(project_id, chapter_no)] = (volume, list(beats), windows)
|
||||
return OutlineWriteView(
|
||||
chapter_no=chapter_no,
|
||||
volume=volume,
|
||||
beats=list(beats),
|
||||
foreshadow_windows=windows,
|
||||
)
|
||||
|
||||
|
||||
class FakeSessionFactory:
|
||||
"""到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。
|
||||
|
||||
记录开了几次 session + 各次 commit,供「扫描自建 session 并提交」断言。
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class FakeReviewGateway:
|
||||
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""
|
||||
|
||||
203
apps/api/tests/test_foreshadow.py
Normal file
203
apps/api/tests/test_foreshadow.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""T3.2 伏笔登记/状态变更端点 + 验收后到期扫描(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- 登记 201 + 重复 code → 422 VALIDATION 信封;
|
||||
- 状态机转移 200 / 非法转移 → 422 VALIDATION / 不存在 → 404;
|
||||
- 进展 append;空更新 → 422;
|
||||
- 到期扫描纯函数 `run_overdue_scan` 直接 await:埋 expected_close_to < 验收章号的伏笔
|
||||
→ 置 OVERDUE、自建 session 提交(**绝不真起后台线程**)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import (
|
||||
FakeForeshadowRepo,
|
||||
FakeSession,
|
||||
FakeSessionFactory,
|
||||
)
|
||||
from ww_api.services.foreshadow_scan import run_overdue_scan
|
||||
from ww_core.domain.foreshadow_state import ForeshadowStatus
|
||||
from ww_shared import ErrorCode
|
||||
|
||||
|
||||
def _make_client(
|
||||
*, repo: FakeForeshadowRepo | None = None, session: FakeSession | None = None
|
||||
) -> tuple[httpx.AsyncClient, FakeForeshadowRepo, FakeSession]:
|
||||
import os
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_foreshadow_repo
|
||||
from ww_db import get_session
|
||||
|
||||
repo = repo or FakeForeshadowRepo()
|
||||
session = session or FakeSession()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, repo, session
|
||||
|
||||
|
||||
# ---- 登记 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_foreshadow_returns_201_open() -> None:
|
||||
client, repo, session = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/foreshadow",
|
||||
json={"code": "F1", "title": "神秘信物", "planted_at": 3, "expected_close_to": 10},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["code"] == "F1"
|
||||
assert body["status"] == ForeshadowStatus.OPEN.value
|
||||
assert body["expected_close_to"] == 10
|
||||
assert session.commits == 1
|
||||
assert (pid, "F1") in repo.rows
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_code_maps_to_422_validation() -> None:
|
||||
repo = FakeForeshadowRepo(raise_integrity_on={"F1"})
|
||||
client, _, session = _make_client(repo=repo)
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/foreshadow", json={"code": "F1", "title": "重复"}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == ErrorCode.VALIDATION
|
||||
assert err["details"]["reason"] == "duplicate"
|
||||
# 冲突后回滚、不提交。
|
||||
assert session.commits == 0
|
||||
assert session.rollbacks == 1
|
||||
|
||||
|
||||
# ---- 状态变更 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transition_open_to_partial_200() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="t")
|
||||
client, _, session = _make_client(repo=repo)
|
||||
async with client:
|
||||
resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={"to_status": "PARTIAL"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "PARTIAL"
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_transition_maps_to_422_validation() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="t")
|
||||
await repo.transition(pid, "F1", to_status="CLOSED") # 先到终态
|
||||
client, _, session = _make_client(repo=repo)
|
||||
async with client:
|
||||
resp = await client.patch(
|
||||
f"/projects/{pid}/foreshadow/F1",
|
||||
json={"to_status": "PARTIAL"}, # CLOSED→PARTIAL 非法
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == ErrorCode.VALIDATION
|
||||
assert err["details"]["reason"] == "invalid_transition"
|
||||
assert session.rollbacks == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transition_unknown_code_404() -> None:
|
||||
client, _, _ = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.patch(f"/projects/{pid}/foreshadow/NOPE", json={"to_status": "PARTIAL"})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_progress_appends() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="t")
|
||||
client, _, session = _make_client(repo=repo)
|
||||
async with client:
|
||||
resp = await client.patch(
|
||||
f"/projects/{pid}/foreshadow/F1",
|
||||
json={"progress_entry": {"chapter_no": 5, "note": "首次照应"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["progress"][0]["note"] == "首次照应"
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_update_maps_to_422() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="t")
|
||||
client, _, _ = _make_client(repo=repo)
|
||||
async with client:
|
||||
resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["error"]["details"]["reason"] == "empty_update"
|
||||
|
||||
|
||||
# ---- 验收后到期扫描(纯函数直接 await,不起后台线程)----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_overdue_scan_marks_overdue_and_commits() -> None:
|
||||
# Arrange:一条 expected_close_to=5 的伏笔,验收章号 7 > 5 → 应置 OVERDUE。
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="逾期", expected_close_to=5)
|
||||
await repo.register(pid, code="F2", title="窗口内", expected_close_to=9)
|
||||
factory = FakeSessionFactory()
|
||||
|
||||
# Act:经 repo_factory 缝注入 fake 账本 repo(不联网/不真起后台线程)。
|
||||
count = await run_overdue_scan(
|
||||
factory,
|
||||
project_id=pid,
|
||||
chapter_no=7,
|
||||
request_id="req-1",
|
||||
repo_factory=lambda _session: repo,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert count == 1
|
||||
assert repo.rows[(pid, "F1")].status == ForeshadowStatus.OVERDUE.value
|
||||
assert repo.rows[(pid, "F2")].status == ForeshadowStatus.OPEN.value
|
||||
# 自建 session 且有变更 → 提交一次。
|
||||
assert len(factory.sessions) == 1
|
||||
assert factory.sessions[0].commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_overdue_scan_no_change_skips_commit() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="窗口内", expected_close_to=9)
|
||||
factory = FakeSessionFactory()
|
||||
|
||||
count = await run_overdue_scan(
|
||||
factory, project_id=pid, chapter_no=3, repo_factory=lambda _session: repo
|
||||
)
|
||||
|
||||
assert count == 0
|
||||
# 空扫描不提交。
|
||||
assert factory.sessions[0].commits == 0
|
||||
216
apps/api/tests/test_outline.py
Normal file
216
apps/api/tests/test_outline.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""T3.5 端点测试:大纲生成 + 持久化 + 伏笔看板(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST /outline:mock 网关产 `OutlineResult` → 逐章 upsert(含 foreshadow_windows)→ 端点 commit;
|
||||
- 幂等:同 (project_id, chapter_no) 再生成覆盖;
|
||||
- 项目不存在 → 404;无凭据 → LLM_UNAVAILABLE(503);
|
||||
- GET /foreshadow?status=:按状态过滤;非法 status → 422 VALIDATION。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import (
|
||||
FakeForeshadowRepo,
|
||||
FakeOutlineWriteRepo,
|
||||
FakeProjectRepo,
|
||||
FakeReviewGateway,
|
||||
FakeSession,
|
||||
)
|
||||
from test_projects import _empty_memory_repos
|
||||
from ww_agents import ForeshadowWindow, OutlineChapter, OutlineResult
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
def _outline_result() -> OutlineResult:
|
||||
return OutlineResult(
|
||||
chapters=[
|
||||
OutlineChapter(
|
||||
no=1,
|
||||
beats=["开篇引入主角", "埋下信物伏笔"],
|
||||
foreshadow_windows=[
|
||||
ForeshadowWindow(code="F1", plant_chapter=1, expected_close_to=10)
|
||||
],
|
||||
),
|
||||
OutlineChapter(no=2, beats=["冲突升级"], foreshadow_windows=[]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
project_repo: FakeProjectRepo | None = None,
|
||||
foreshadow_repo: FakeForeshadowRepo | None = None,
|
||||
outline_repo: FakeOutlineWriteRepo | None = None,
|
||||
gateway: FakeReviewGateway | None = None,
|
||||
session: FakeSession | None = None,
|
||||
) -> tuple[httpx.AsyncClient, FakeOutlineWriteRepo, FakeSession]:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_foreshadow_repo,
|
||||
get_memory_repos,
|
||||
get_outline_gateway,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
project_repo = project_repo or FakeProjectRepo()
|
||||
foreshadow_repo = foreshadow_repo or FakeForeshadowRepo()
|
||||
outline_repo = outline_repo or FakeOutlineWriteRepo()
|
||||
gateway = gateway or FakeReviewGateway(parsed=_outline_result())
|
||||
session = session or FakeSession()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_foreshadow_repo] = lambda: foreshadow_repo
|
||||
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
|
||||
app.dependency_overrides[get_outline_write_repo] = lambda: outline_repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
app.dependency_overrides[get_outline_gateway] = lambda: gateway
|
||||
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, outline_repo, session
|
||||
|
||||
|
||||
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
|
||||
view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
|
||||
return uuid.UUID(str(view.id))
|
||||
|
||||
|
||||
# ---- 大纲生成 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_upserts_chapters_and_commits() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
client, outline_repo, session = _make_client(project_repo=project_repo)
|
||||
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/outline", json={"volume": 1})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert [c["no"] for c in body["chapters"]] == [1, 2]
|
||||
ch1 = body["chapters"][0]
|
||||
assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
|
||||
assert ch1["foreshadow_windows"][0]["code"] == "F1"
|
||||
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
|
||||
# 逐章 upsert(2 章)+ 端点末尾一次 commit。
|
||||
assert outline_repo.upsert_calls == 2
|
||||
assert (pid, 1) in outline_repo.rows
|
||||
assert outline_repo.rows[(pid, 1)][1] == ["开篇引入主角", "埋下信物伏笔"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_is_idempotent_overwrites_same_chapter() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
outline_repo = FakeOutlineWriteRepo()
|
||||
client, _, _ = _make_client(project_repo=project_repo, outline_repo=outline_repo)
|
||||
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
|
||||
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
|
||||
|
||||
# 同 (project_id, chapter_no) 覆盖,不堆叠 → 仍只 2 行。
|
||||
assert len(outline_repo.rows) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_unknown_project_404() -> None:
|
||||
client, _, _ = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{uuid.uuid4()}/outline", json={})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_without_credentials_maps_to_llm_unavailable() -> None:
|
||||
# 无凭据时 `build_gateway_for_tier` 抛 LLM_UNAVAILABLE(503),在网关 dep 解析阶段发生。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_foreshadow_repo,
|
||||
get_memory_repos,
|
||||
get_outline_gateway,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
async def _no_creds() -> object:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_foreshadow_repo] = lambda: FakeForeshadowRepo()
|
||||
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
|
||||
app.dependency_overrides[get_outline_write_repo] = lambda: FakeOutlineWriteRepo()
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
app.dependency_overrides[get_outline_gateway] = _no_creds
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(f"/projects/{pid}/outline", json={})
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
# 网关阶段失败 → 未生成、未提交。
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_upper_error_propagates() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
gateway = FakeReviewGateway(error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
|
||||
client, _, session = _make_client(project_repo=project_repo, gateway=gateway)
|
||||
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/outline", json={})
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- 伏笔看板 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_foreshadow_board_filters_by_status() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="开放伏笔")
|
||||
await repo.register(pid, code="F2", title="部分回收")
|
||||
await repo.transition(pid, "F2", to_status="PARTIAL")
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_foreshadow_repo
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
all_resp = await client.get(f"/projects/{pid}/foreshadow")
|
||||
open_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "OPEN"})
|
||||
bad_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "NOPE"})
|
||||
|
||||
assert {f["code"] for f in all_resp.json()["foreshadow"]} == {"F1", "F2"}
|
||||
open_codes = [f["code"] for f in open_resp.json()["foreshadow"]]
|
||||
assert open_codes == ["F1"]
|
||||
assert bad_resp.status_code == 422
|
||||
assert bad_resp.json()["error"]["code"] == ErrorCode.VALIDATION
|
||||
assert bad_resp.json()["error"]["details"]["reason"] == "invalid_status"
|
||||
63
apps/api/tests/test_outline_context.py
Normal file
63
apps/api/tests/test_outline_context.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""T3.5 大纲注入上下文组装单测(确定性纯函数;ARCH §5.4)。
|
||||
|
||||
断言:四源(设定/伏笔/人物/世界观)都进文本、排序确定(同输入同输出)、空源不炸。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_api.services.outline_context import build_outline_context
|
||||
from ww_core.domain import ForeshadowLedgerView
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
from ww_core.domain.repositories import CharacterView, WorldEntityView
|
||||
|
||||
|
||||
def _project() -> ProjectView:
|
||||
import uuid
|
||||
|
||||
return ProjectView(
|
||||
id=uuid.UUID(int=1),
|
||||
title="逆天剑帝",
|
||||
genre="玄幻",
|
||||
logline="少年逆袭",
|
||||
selling_points=["爽点密集", "升级流"],
|
||||
)
|
||||
|
||||
|
||||
def test_build_context_includes_all_four_sources() -> None:
|
||||
text = build_outline_context(
|
||||
project=_project(),
|
||||
foreshadow=[
|
||||
ForeshadowLedgerView(
|
||||
code="F1", title="神秘信物", status="OPEN", planted_at=3, expected_close_to=10
|
||||
)
|
||||
],
|
||||
characters=[CharacterView(name="主角", role="protagonist", motive="复仇")],
|
||||
world_entities=[WorldEntityView(type="location", name="剑冢")],
|
||||
)
|
||||
assert "逆天剑帝" in text
|
||||
assert "F1" in text and "神秘信物" in text
|
||||
assert "回收窗口 ?-10" in text
|
||||
assert "主角" in text
|
||||
assert "剑冢" in text
|
||||
|
||||
|
||||
def test_build_context_is_deterministic_and_sorted() -> None:
|
||||
project = _project()
|
||||
fs = [
|
||||
ForeshadowLedgerView(code="F2", title="b", status="OPEN"),
|
||||
ForeshadowLedgerView(code="F1", title="a", status="OPEN"),
|
||||
]
|
||||
a = build_outline_context(project=project, foreshadow=fs, characters=[], world_entities=[])
|
||||
b = build_outline_context(
|
||||
project=project, foreshadow=list(reversed(fs)), characters=[], world_entities=[]
|
||||
)
|
||||
assert a == b
|
||||
# F1 排在 F2 前(按 code 升序)。
|
||||
assert a.index("[F1]") < a.index("[F2]")
|
||||
|
||||
|
||||
def test_build_context_handles_empty_sources() -> None:
|
||||
text = build_outline_context(
|
||||
project=_project(), foreshadow=[], characters=[], world_entities=[]
|
||||
)
|
||||
assert "(无)" in text
|
||||
@@ -17,6 +17,7 @@ from fakes_projects import (
|
||||
FakeReviewGateway,
|
||||
FakeReviewRepo,
|
||||
FakeSession,
|
||||
FakeSessionFactory,
|
||||
)
|
||||
from test_projects import _empty_memory_repos
|
||||
from ww_agents import ContinuityReview
|
||||
@@ -50,6 +51,7 @@ def _make_client(
|
||||
get_memory_repos,
|
||||
get_review_gateway,
|
||||
get_review_repo,
|
||||
get_session_factory,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
@@ -68,6 +70,7 @@ def _make_client(
|
||||
app.dependency_overrides[get_review_gateway] = lambda: review_gateway
|
||||
app.dependency_overrides[get_digest_gateway] = lambda: digest_gateway
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, chapter_repo, review_repo, digest_repo, session
|
||||
|
||||
Reference in New Issue
Block a user