Files
writer-work-flow/apps/api/tests/test_injection.py
Yaojia Wang 8058cfb11a feat(ux): B0 注入透明读端点 + F1 写作页右栏真面板
兑现「看到的=写章用的」信任牌(不变量 #6),替换写作页右栏过时假占位
(「M1 暂未接 / M2 开放」)。

后端:
- GET /projects/{id}/chapters/{no}/injection → InjectionResponse(selected
  实体 + 入选理由 + recent_n);调既有 assemble() 回放确定性 SelectionTrace,
  无 LLM / 无 commit / 无 DDL;项目不存在→404,无大纲→selected:[]。
- 新 schemas/injection.py;tests/test_injection.py(3 测)。

前端:
- gen:api 纳入端点;纯逻辑 lib/workbench/injection.ts(理由/类型→中文徽标
  + 4 vitest)+ useInjection 读 hook。
- ChapterAssistant 改 client 组件:列出选中实体 + 理由徽标 + 空/载/错三态,
  四审段指向审稿页;Workbench 传 projectId/chapterNo。

门禁:后端 ruff/format/mypy 159/alembic 无漂移/pytest 454;
前端 lint/typecheck/vitest 167/build。

B0 可控版(PUT override pin/排除/recent_n + selection 加参 + draft 同读
override + 持久化)+ F1 可控控件待后续。
2026-06-20 10:57:03 +02:00

170 lines
5.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.

"""B0 注入透明读端点测试GET /projects/:id/chapters/:no/injection。
读取 assemble 的确定性 SelectionTrace无 LLM、无 commit
- 命中实体 + 入选理由徽标;项目不存在 → 404无大纲 → selected: [](不报错)。
"""
from __future__ import annotations
import uuid
import httpx
import pytest
from fakes_projects import FakeProjectRepo
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import (
CharacterView,
DigestView,
ForeshadowView,
MemoryRepos,
OutlineView,
ProjectSpecView,
RuleView,
StyleView,
WorldEntityView,
)
from ww_shared import ErrorCode
# ---- 内存 fake memory repos无 DB只够断言 SelectionTrace 形状)----
class _OutlineRepo:
def __init__(self, rows: dict[int, OutlineView]) -> None:
self._rows = rows
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self._rows.get(chapter_no)
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
return [self._rows[k] for k in sorted(self._rows)]
class _CharRepo:
def __init__(self, rows: list[CharacterView]) -> None:
self._rows = rows
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
return list(self._rows)
class _WorldRepo:
def __init__(self, rows: list[WorldEntityView]) -> None:
self._rows = rows
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
return list(self._rows)
class _DigestRepo:
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
return []
class _ForeshadowRepo:
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
return []
class _StyleRepo:
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
return None
class _RulesRepo:
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
return []
class _SpecRepo:
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
return ProjectSpecView(title="测试作品", premise="测试前提")
def _memory_repos(
*,
outline: dict[int, OutlineView] | None = None,
characters: list[CharacterView] | None = None,
world: list[WorldEntityView] | None = None,
) -> MemoryRepos:
return MemoryRepos(
outline=_OutlineRepo(outline or {}),
character=_CharRepo(characters or []),
world_entity=_WorldRepo(world or []),
digest=_DigestRepo(),
foreshadow=_ForeshadowRepo(),
style=_StyleRepo(),
rules=_RulesRepo(),
project=_SpecRepo(),
)
def _make_client(
*,
project_repo: FakeProjectRepo,
memory_repos: MemoryRepos,
) -> httpx.AsyncClient:
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_memory_repos, get_project_repo
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_memory_repos] = lambda: memory_repos
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def _create_project(repo: FakeProjectRepo, title: str = "注入") -> uuid.UUID:
view = await repo.create(STUB_OWNER_ID, ProjectCreate(title=title))
return uuid.UUID(str(view.id))
@pytest.mark.asyncio
async def test_injection_lists_selected_entities_with_reasons() -> None:
repo = FakeProjectRepo()
pid = await _create_project(repo)
memory = _memory_repos(
outline={3: OutlineView(volume=1, chapter_no=3, beats={"entities": ["青檀"]})},
characters=[
CharacterView(name="林动", role="主角"),
CharacterView(name="青檀", role="配角"),
CharacterView(name="路人甲", role="龙套"),
],
)
client = _make_client(project_repo=repo, memory_repos=memory)
async with client:
resp = await client.get(f"/projects/{pid}/chapters/3/injection")
assert resp.status_code == 200
body = resp.json()
assert body["project_id"] == str(pid)
assert body["chapter_no"] == 3
by_name = {e["name"]: e for e in body["selected"]}
assert "林动" in by_name and "main_character" in by_name["林动"]["reasons"]
assert "青檀" in by_name and "explicit_beat" in by_name["青檀"]["reasons"]
assert "路人甲" not in by_name # 未命中任何理由 → 不注入
assert body["recent_n"] >= 1
@pytest.mark.asyncio
async def test_injection_unknown_project_404() -> None:
repo = FakeProjectRepo()
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters/1/injection")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_injection_no_outline_returns_empty() -> None:
repo = FakeProjectRepo()
pid = await _create_project(repo)
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
async with client:
resp = await client.get(f"/projects/{pid}/chapters/9/injection")
assert resp.status_code == 200
assert resp.json()["selected"] == []