feat(b0): 注入透明可控版后端 — PUT override(pin/排除/recent_n) + 选择函数加参 + draft 同读
- selection.select_relevant_entities 加 pinned/excluded frozenset 入参;新理由 author_pin(末位) - assemble 加 override 关键字参(recent_n 覆盖回看章数 + pin/排除);GET/PUT/draft 同读同一覆盖(不变量 #6 作者兜底) - 新 domain/injection_repo.py(InjectionOverride/EntityRef/SqlInjectionOverrideRepo,upsert 只 flush) - 新表 chapter_injection(迁移 ad2c4c663daf,唯一 project_id+chapter_no;复用 outline 行已否决) - PUT/GET injection 端点 + InjectionOverrideRequest/回显 pinned/excluded;recent_n 1..20 校验 422 - 单测:selection pin/排除/exclude>pin + assemble override + 端点 PUT/404/422;regen TS 客户端 - 门禁绿:ruff/format · mypy 163 · alembic 无漂移 · pytest 476 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import httpx
|
||||
import pytest
|
||||
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,
|
||||
@@ -81,6 +82,22 @@ class _SpecRepo:
|
||||
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,
|
||||
@@ -103,16 +120,25 @@ def _make_client(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
memory_repos: MemoryRepos,
|
||||
injection_repo: _FakeInjectionRepo | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
import os
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from fakes_projects import FakeSession
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_memory_repos, get_project_repo
|
||||
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")
|
||||
|
||||
@@ -167,3 +193,68 @@ async def test_injection_no_outline_returns_empty() -> None:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user