Files
writer-work-flow/apps/api/tests/test_outline.py
Yaojia Wang 5fb7bfb1de 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
2026-06-18 14:21:17 +02:00

217 lines
8.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.

"""T3.5 端点测试:大纲生成 + 持久化 + 伏笔看板(内存替身,无 DB/无网络)。
覆盖:
- POST /outlinemock 网关产 `OutlineResult` → 逐章 upsert含 foreshadow_windows→ 端点 commit
- 幂等:同 (project_id, chapter_no) 再生成覆盖;
- 项目不存在 → 404无凭据 → LLM_UNAVAILABLE503
- 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
# 逐章 upsert2 章)+ 端点末尾一次 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_UNAVAILABLE503在网关 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"