Files
writer-work-flow/apps/api/tests/test_foreshadow.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

204 lines
6.8 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.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