"""M8: the two fields the streamlined setup flow added, and what they must not do. `BROWSER-UX-SPEC.md` §41 replaced "pick a scenario, then fill in its placeholders" with a form. Two things had to reach the API for that to work, and both are narrow by design (`BUILD-MILESTONES.md` M8, §39 of the brief): opening the campaign's first scene, so a new campaign does not open on a blank page. It builds the same `start` node a scenario's prompt does, by the same code path. canon_rules a read/write view onto the `rules` list inside the existing `campaign_canon` document, which has had no API at all since the column was added in migration 82. Neither adds a column. `test_knowledge_migration.py` and the M8 report's migration proof cover the schema claim; these cover the behaviour. """ import pytest from fastapi import Depends from fastapi.testclient import TestClient from app import auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app OPENING = "You sit at a shared table in the Crooked Lantern Tavern." @pytest.fixture() def client(monkeypatch): """The suite's convention: create the schema, own a user, drop it after. The first version of this fixture just wrapped `TestClient(app)`. It passed in isolation and failed ten ways in the full suite, because the tests share one database and every other module creates and drops the schema around itself — so this file inherited whatever the previous module had left, and had no user of its own for `auth.get_current_user` to find. """ Base.metadata.create_all(bind=engine) setup = SessionLocal() user = models.User(is_guest=False, email="m8setup@example.com") setup.add(user) setup.flush() setup.add(models.Settings(user_id=user.id, model="test-model")) setup.commit() user_id = user.id setup.close() monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None) def _current_user(db=Depends(get_db)): return db.get(models.User, user_id) app.dependency_overrides[auth.get_current_user] = _current_user c = TestClient(app) try: yield c finally: app.dependency_overrides.clear() Base.metadata.drop_all(bind=engine) def _campaign(client, **body): r = client.post("/api/adventures", json=body) assert r.status_code == 201, r.text return r.json() # ---------------------------------------------------------------- opening --- def test_the_opening_becomes_the_campaigns_first_scene(client): adv = _campaign(client, title="With opening", opening=OPENING) assert [(a["type"], a["text"]) for a in adv["actions"]] == [("start", OPENING)] # `action_count` is computed on read, so the create response reports 0 — # for a scenario-made campaign too, and it has always done so. The browser # navigates to the campaign and re-reads, which is the surface asserted # here and the one a reader actually sees. fetched = client.get(f"/api/adventures/{adv['id']}").json() assert fetched["action_count"] == 1 assert [(a["type"], a["text"]) for a in fetched["actions"]] == [("start", OPENING)] def test_the_opening_is_not_duplicated(client): adv = _campaign(client, title="Once", opening=OPENING) again = client.get(f"/api/adventures/{adv['id']}").json() assert [a["text"] for a in again["actions"]].count(OPENING) == 1 assert again["action_count"] == 1 def test_the_opening_is_placed_on_the_tree_like_any_other_node(client): """It must not bypass head/history semantics. A `start` node that was not placed on the tree, or carried no state snapshot, would break Undo and retry at the first turn — which is exactly where a new reader meets them. """ adv = _campaign(client, title="Placed", opening=OPENING) db = SessionLocal() try: row = (db.query(models.Action) .filter(models.Action.adventure_id == adv["id"]).one()) assert row.depth == 0 assert row.branch_id is not None assert row.parent_id is None finally: db.close() # And the head is at it: there is nothing before the opening to undo to. assert adv["can_undo"] is False assert adv["can_redo"] is False assert client.post(f"/api/adventures/{adv['id']}/undo").status_code >= 400 def test_a_campaign_without_an_opening_still_starts_empty(client): adv = _campaign(client, title="Blank") assert adv["actions"] == [] def test_a_scenario_prompt_takes_precedence_and_is_never_doubled(client): """Both routes build the same node, so only one of them may fire.""" sc = client.post("/api/scenarios", json={"title": "S", "prompt": "A scenario opening."}).json() adv = _campaign(client, scenario_id=sc["id"], opening=OPENING) assert [a["text"] for a in adv["actions"]] == ["A scenario opening."] legacy = _campaign(client, scenario_id=sc["id"]) assert [a["text"] for a in legacy["actions"]] == ["A scenario opening."] def test_the_opening_survives_export_and_import(client): adv = _campaign(client, title="Round trip", opening=OPENING) bundle = client.get(f"/api/adventures/{adv['id']}/export").json() restored = client.post("/api/adventures/import", json=bundle).json() assert [a["text"] for a in restored["actions"]] == [OPENING] # ------------------------------------------------------------ canon_rules --- def test_canon_rules_round_trip_and_blank_lines_are_dropped(client): adv = _campaign(client, title="Canon", canon_rules=["Resurrection is impossible.", " ", "Magic exists."]) assert adv["canon_rules"] == ["Resurrection is impossible.", "Magic exists."] patched = client.patch(f"/api/adventures/{adv['id']}", json={"canon_rules": ["Only one rule now."]}).json() assert patched["canon_rules"] == ["Only one rule now."] cleared = client.patch(f"/api/adventures/{adv['id']}", json={"canon_rules": []}).json() assert cleared["canon_rules"] == [] def test_editing_canon_preserves_the_structured_half_it_has_no_editor_for(client): """`campaign_canon` also holds `forbidden_status_changes`. The browser edits sentences and has no editor for the structured shape, so writing the sentences must not discard it — otherwise importing a bundle that carries one and then touching canon in the UI would silently drop a rule the validator enforces. """ adv = _campaign(client, title="Structured") db = SessionLocal() try: row = db.get(models.Adventure, adv["id"]) row.campaign_canon = { "rules": ["R1"], "forbidden_status_changes": [{"from": "dead", "to": "alive"}], } db.commit() finally: db.close() assert client.get(f"/api/adventures/{adv['id']}").json()["canon_rules"] == ["R1"] client.patch(f"/api/adventures/{adv['id']}", json={"canon_rules": ["R2", "R3"]}) db = SessionLocal() try: stored = db.get(models.Adventure, adv["id"]).campaign_canon assert stored["rules"] == ["R2", "R3"] assert stored["forbidden_status_changes"] == [{"from": "dead", "to": "alive"}] finally: db.close() def test_canon_is_untouched_by_an_unrelated_patch(client): adv = _campaign(client, title="Untouched", canon_rules=["A rule."]) renamed = client.patch(f"/api/adventures/{adv['id']}", json={"title": "Renamed"}).json() assert renamed["canon_rules"] == ["A rule."] assert renamed["title"] == "Renamed" def test_canon_reaches_the_prompt_as_the_campaigns_own_rules(client): """The point of exposing it: what is written here is what the narrator is told.""" adv = _campaign(client, title="Prompted", canon_rules=["Resurrection is impossible."]) report = client.get(f"/api/adventures/{adv['id']}/context").json() canon = next((s["text"] for s in report["sections"] if s["label"] == "campaign_canon"), "") assert "Resurrection is impossible." in canon