"""B0 注入透明读端点测试:GET /projects/:id/chapters/:no/injection。 读取 assemble 的确定性 SelectionTrace(无 LLM、无 commit): - 命中实体 + 入选理由徽标;项目不存在 → 404;无大纲 → selected: [](不报错)。 """ from __future__ import annotations import uuid import httpx import pytest from cryptography.fernet import Fernet from fakes_projects import FakeProjectRepo from ww_api.services.credentials import STUB_OWNER_ID from ww_core.domain.injection_repo import InjectionOverride 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, *, volume: int | None = None ) -> list[OutlineView]: rows = [self._rows[k] for k in sorted(self._rows)] return [v for v in rows if volume is None or v.volume == volume] 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="测试前提") class _FakeInjectionRepo: """内存注入覆盖 repo(按 (project_id, chapter_no) 存最后一次 upsert)。""" def __init__(self) -> None: self._store: dict[tuple[str, int], InjectionOverride] = {} async def get(self, project_id: uuid.UUID, chapter_no: int) -> InjectionOverride | None: return self._store.get((str(project_id), chapter_no)) async def upsert( self, project_id: uuid.UUID, chapter_no: int, override: InjectionOverride ) -> InjectionOverride: self._store[(str(project_id), chapter_no)] = override return override 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, injection_repo: _FakeInjectionRepo | None = None, ) -> httpx.AsyncClient: import os os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) from fakes_projects import FakeSession from ww_api.main import create_app from ww_api.services.project_deps import ( get_injection_repo, get_memory_repos, get_project_repo, ) from ww_db import get_session app = create_app() app.dependency_overrides[get_project_repo] = lambda: project_repo app.dependency_overrides[get_memory_repos] = lambda: memory_repos app.dependency_overrides[get_injection_repo] = lambda: injection_repo or _FakeInjectionRepo() app.dependency_overrides[get_session] = lambda: FakeSession() 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"] == [] @pytest.mark.asyncio async def test_injection_put_pin_excludes_and_reflects() -> None: repo = FakeProjectRepo() pid = await _create_project(repo) memory = _memory_repos( outline={3: OutlineView(volume=1, chapter_no=3, beats={})}, characters=[ CharacterView(name="林动", role="主角"), CharacterView(name="路人乙", role="龙套"), ], ) injection = _FakeInjectionRepo() client = _make_client(project_repo=repo, memory_repos=memory, injection_repo=injection) async with client: # pin 龙套路人乙(本无理由)、排除主角林动、近况回看设为 2。 put = await client.put( f"/projects/{pid}/chapters/3/injection", json={ "pinned": [{"kind": "character", "name": "路人乙"}], "excluded": [{"kind": "character", "name": "林动"}], "recent_n": 2, }, ) assert put.status_code == 200 body = put.json() by_name = {e["name"]: e for e in body["selected"]} assert "路人乙" in by_name and "author_pin" in by_name["路人乙"]["reasons"] assert "林动" not in by_name # 排除生效 assert body["recent_n"] == 2 assert body["pinned"] == [{"kind": "character", "name": "路人乙"}] assert body["excluded"] == [{"kind": "character", "name": "林动"}] # 覆盖已落 fake repo → GET 回放同一结果(看到的=写章用的)。 got = await client.get(f"/projects/{pid}/chapters/3/injection") assert got.status_code == 200 got_names = {e["name"] for e in got.json()["selected"]} assert "路人乙" in got_names and "林动" not in got_names @pytest.mark.asyncio async def test_injection_put_unknown_project_404() -> None: repo = FakeProjectRepo() client = _make_client(project_repo=repo, memory_repos=_memory_repos()) async with client: resp = await client.put( f"/projects/{uuid.uuid4()}/chapters/1/injection", json={"pinned": [], "excluded": [], "recent_n": None}, ) assert resp.status_code == 404 assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND @pytest.mark.asyncio async def test_injection_put_rejects_out_of_range_recent_n() -> None: repo = FakeProjectRepo() pid = await _create_project(repo) client = _make_client(project_repo=repo, memory_repos=_memory_repos()) async with client: resp = await client.put( f"/projects/{pid}/chapters/1/injection", json={"pinned": [], "excluded": [], "recent_n": 999}, ) assert resp.status_code == 422