"""M3: the active head, and what moving it costs. This file is the acceptance contract for the milestone that stopped Undo from deleting. Its subject is one invariant and the behaviour that follows from it: Undo deletes zero accepted turns. Everything else here is a consequence. Redo exists because the turns are still there. Divergence retires a future rather than removing it. Memory and summary coverage narrow and widen again as the head moves, without anything being re-embedded. Export carries where the reader stopped, because that is now a decision rather than a fact about the newest row. The tests are named for the acceptance items they discharge — D01-D10, E01-E04, I01-I03, I07, L01-L02 in `planning/V1-ACCEPTANCE-TESTS.md` — so a reader can go from a failing test to the requirement it belongs to without a map. The world state is instrumentation here, not the subject. Each scripted reply banks ten gold, which makes "the state at this position" a number a test can assert instead of a paragraph it has to interpret. M5 replaces that machinery with genre-neutral narrative state; these tests then need the *instrumentation* moved, not the assertions removed, because what they measure is where the story is being read. python -m pytest tests/test_head_cursor.py -v """ import pytest from fastapi import Depends from fastapi.testclient import TestClient from app import limits, models from app.providers import ProviderError from app.context import cursors, lineage from app.database import Base, SessionLocal, engine, get_db from app.main import app from app import auth, tree from app.routers import adventures from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) setup = SessionLocal() user = models.User(is_guest=False, email="head@example.com") setup.add(user) setup.flush() setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model")) scenario = models.Scenario(user_id=user.id, title="S", stat_schema=GOLD_SCHEMA) setup.add(scenario) setup.flush() adv = models.Adventure( user_id=user.id, title="Tavern", scenario_id=scenario.id, ) setup.add(adv) setup.flush() setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter the tavern.")) setup.commit() adv_id, user_id = adv.id, user.id setup.close() ScriptedProvider.replies = gold_replies() monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) 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) c.adv_id = adv_id try: yield c finally: app.dependency_overrides.clear() adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) # ------------------------------------------------------------------ helpers def _play(client, text="look around", type="do", after_id=None): payload = {"type": type, "text": text} if after_id is not None: payload["after_id"] = after_id r = client.post(f"/api/adventures/{client.adv_id}/actions", json=payload) assert r.status_code == 200, r.text return r def _turns(client, count): for n in range(count): _play(client, f"turn {n}") def _undo(client): return client.post(f"/api/adventures/{client.adv_id}/undo") def _redo(client): return client.post(f"/api/adventures/{client.adv_id}/redo") def _adventure(client) -> dict: r = client.get(f"/api/adventures/{client.adv_id}") assert r.status_code == 200, r.text return r.json() def _texts(client) -> list[str]: return [a["text"] for a in _adventure(client)["actions"]] def _rows(adv_id) -> list[models.Action]: """Every action row, story or not, live or not, head or no head.""" db = SessionLocal() try: return ( db.query(models.Action) .filter(models.Action.adventure_id == adv_id) .order_by(models.Action.branch_id, models.Action.depth, models.Action.id) .all() ) finally: db.close() def _gold(adv_id) -> int: db = SessionLocal() try: adv = db.get(models.Adventure, adv_id) return tally_of(adv.narrative_state) finally: db.close() def _head(adv_id) -> tuple[int, int]: db = SessionLocal() try: adv = db.get(models.Adventure, adv_id) return adv.head_branch_id, adv.head_depth finally: db.close() # ------------------------------------------------------- D01, D02, D03: undo def test_d01_one_undo_returns_the_transcript_and_the_state(client): _turns(client, 2) assert _gold(client.adv_id) == 2 * GOLD_PER_TURN told = _texts(client) page = _undo(client) assert page.status_code == 200, page.text # Both halves of the turn step back together: a player's action and the # reply to it are one accepted story step. assert [a["text"] for a in page.json()["actions"]] == told[:-2] assert _gold(client.adv_id) == 1 * GOLD_PER_TURN def test_d02_five_consecutive_undos_each_land_where_they_should(client): _turns(client, 7) assert _gold(client.adv_id) == 7 * GOLD_PER_TURN for step in range(1, 6): assert _undo(client).status_code == 200 assert _gold(client.adv_id) == (7 - step) * GOLD_PER_TURN # Two rows per turn, plus the opening. assert len(_texts(client)) == 1 + 2 * (7 - step) def test_d03_undo_walks_back_to_the_campaign_opening_and_stops(client): _turns(client, 7) for _ in range(7): assert _undo(client).status_code == 200 assert _texts(client) == ["You enter the tavern."] assert _gold(client.adv_id) == 0 # The opening is the floor. There is no pre-campaign position to reach. refused = _undo(client) assert refused.status_code == 400 assert "Nothing to undo" in refused.json()["detail"] assert _adventure(client)["can_undo"] is False def test_the_m3_invariant_undo_deletes_zero_accepted_turns(client): """The row count is the milestone. Everything else in this file is a consequence of it, so it is asserted on its own, over the whole retained tree rather than over the story being told.""" _turns(client, 5) before = [a.id for a in _rows(client.adv_id)] for _ in range(5): assert _undo(client).status_code == 200 after = [a.id for a in _rows(client.adv_id)] assert after == before assert len(_texts(client)) == 1 # and yet the story is back at its opening # -------------------------------------------------------------- D04, L02: redo def test_d04_redo_restores_the_continuation_and_its_state(client): _turns(client, 4) whole = _texts(client) _undo(client) _undo(client) assert _gold(client.adv_id) == 2 * GOLD_PER_TURN assert _redo(client).status_code == 200 assert _gold(client.adv_id) == 3 * GOLD_PER_TURN assert _redo(client).status_code == 200 assert _gold(client.adv_id) == 4 * GOLD_PER_TURN assert _texts(client) == whole # Nowhere further forward to go, and the control says so. assert _adventure(client)["can_redo"] is False assert _redo(client).status_code == 400 def test_l02_state_matches_the_position_in_both_directions(client): """Each position's state is the snapshot the node left behind, so arriving from in front of it and arriving from behind it must agree.""" _turns(client, 5) going_back = [] for _ in range(5): _undo(client) going_back.append(_gold(client.adv_id)) coming_forward = [] for _ in range(5): _redo(client) coming_forward.append(_gold(client.adv_id)) assert going_back == [40, 30, 20, 10, 0] assert coming_forward == [10, 20, 30, 40, 50] # ------------------------------------------------- D05, E01, E04: divergence def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client): _turns(client, 4) abandoned = {a.id for a in _rows(client.adv_id)} _undo(client) _undo(client) assert _adventure(client)["can_redo"] is True ScriptedProvider.replies = [tally_reply("A different road.", 1)] _play(client, "go the other way") # Ordinary Redo cannot walk into the old future any more... assert _adventure(client)["can_redo"] is False assert _redo(client).status_code == 400 # ...and not one row of it was deleted to achieve that. kept = {a.id for a in _rows(client.adv_id)} assert abandoned <= kept # The story now tells the new continuation. assert _texts(client)[-1].startswith("A different road.") def test_the_departed_branch_records_where_the_story_left_it(client): """`DATA-MODEL.md` §5 gives a branch a disposition. It is stored as the fact that produced it — the depth the story left at — and nothing reads it to decide behaviour, so this test is what makes a divergence observable.""" _turns(client, 3) _undo(client) left_at = _head(client.adv_id)[1] _play(client, "elsewhere") db = SessionLocal() try: superseded = ( db.query(models.Branch) .filter( models.Branch.adventure_id == client.adv_id, models.Branch.superseded_at.isnot(None), ) .all() ) assert len(superseded) == 1 assert superseded[0].superseded_depth == left_at finally: db.close() def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client): """E01 and E04 are the same mechanism measured twice: the state that is current is the one belonging to the position the story is read at, so a number only the abandoned future ever reached cannot survive a divergence.""" ScriptedProvider.replies = [ tally_reply("You find a purse.", 10), tally_reply("You find the hoard.", 510), ] _play(client, "search") _play(client, "keep searching") assert _gold(client.adv_id) == 510 _undo(client) assert _gold(client.adv_id) == 10 ScriptedProvider.replies = [tally_reply("You leave empty-handed.", 11)] _play(client, "go home") assert _gold(client.adv_id) == 11, "the hoard belonged to a story this one is not" # ------------------------------------------------------ E02, E03: derived work def _attach_memory(adv_id, text, at_depth): """Writes a memory ending on the live node at `at_depth`, the way the summarizer would, and anchors the memory cursor there.""" db = SessionLocal() try: adventure = db.get(models.Adventure, adv_id) node = ( db.query(models.Action) .filter( models.Action.adventure_id == adv_id, models.Action.depth == at_depth, models.Action.live.is_(True), ) .first() ) memory = models.Memory( adventure_id=adv_id, text=text, source_start=0, source_end=node.depth, ) tree.attach_memory(memory, node) db.add(memory) cursors.MEMORY.anchor_at(adventure, node) cursors.SUMMARY.anchor_at(adventure, node) db.commit() finally: db.close() def _retrievable(adv_id) -> set[str]: """The memories the story can reach, through the clause `memorybank` retrieves with.""" db = SessionLocal() try: adventure = db.get(models.Adventure, adv_id) rows = ( db.query(models.Memory) .filter( models.Memory.adventure_id == adv_id, lineage.path_of(db, adventure).clause(models.Memory), ) .all() ) return {m.text for m in rows} finally: db.close() def test_e02_an_abandoned_memory_is_unreachable_and_still_on_disk(client): _turns(client, 3) tip_depth = _head(client.adv_id)[1] _attach_memory(client.adv_id, "Mara reveals she is a spy.", tip_depth) assert _retrievable(client.adv_id) == {"Mara reveals she is a spy."} _undo(client) # Negative control: undone, the revelation is not retrievable... assert _retrievable(client.adv_id) == set() _play(client, "talk about the weather") # ...and after diverging it stays unreachable, on a line the story left. assert _retrievable(client.adv_id) == set() db = SessionLocal() try: assert db.query(models.Memory).count() == 1, "unreachable, not deleted" finally: db.close() def test_e02_positive_control_the_memory_returns_on_the_line_it_belongs_to(client): """The negative control alone would pass if memories were simply broken. Redo puts the head back on the lineage the memory was written for, and it must be retrievable again — without having been re-embedded.""" _turns(client, 3) _attach_memory(client.adv_id, "Mara reveals she is a spy.", _head(client.adv_id)[1]) _undo(client) assert _retrievable(client.adv_id) == set() _redo(client) assert _retrievable(client.adv_id) == {"Mara reveals she is a spy."} def test_e03_a_summary_anchor_cannot_claim_coverage_past_the_head(client): """E03 without redesigning the summarizer. The anchor says how far the derived work has read; resolved against a head-capped path it can never report a stretch in the abandoned future as already covered, so that content is re-derived for the new line rather than carried into it.""" _turns(client, 3) tip_depth = _head(client.adv_id)[1] _attach_memory(client.adv_id, "Everything up to the reveal.", tip_depth) db = SessionLocal() try: adventure = db.get(models.Adventure, client.adv_id) assert cursors.SUMMARY.depth(db, adventure) == tip_depth finally: db.close() _undo(client) _play(client, "a different question") db = SessionLocal() try: adventure = db.get(models.Adventure, client.adv_id) covered = cursors.SUMMARY.depth(db, adventure) assert covered < tip_depth, "coverage from the abandoned line was claimed" finally: db.close() # ------------------------------------------------ D06-D08: retry and takes def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client): ScriptedProvider.replies = gold_replies("Take") _play(client, "knock") first = [a for a in _rows(client.adv_id) if a.type == "ai"][0] ScriptedProvider.replies = [tally_reply("Another knock.", GOLD_PER_TURN)] r = client.post(f"/api/adventures/{client.adv_id}/retry") assert r.status_code == 200, r.text ai_rows = [a for a in _rows(client.adv_id) if a.type == "ai"] assert len(ai_rows) == 2, "the earlier take is retained" assert first.id in {a.id for a in ai_rows} # Both takes sit at the same coordinate, which is what makes them takes # rather than turns, and the state is one turn's worth either way — the # retry replaced the take rather than stacking on top of it. assert {a.depth for a in ai_rows} == {first.depth} assert _gold(client.adv_id) == GOLD_PER_TURN def test_d07_a_retry_from_behind_the_tip_branches_instead_of_amending(client): """A turn with an accepted future is not a leaf, whatever the capped read says. Retrying it has to leave that future on the line it was written for, which is a branch — the same operation `add_take` performs for a turn the story has already moved past.""" _turns(client, 3) _undo(client) before = {a.id for a in _rows(client.adv_id)} r = client.post(f"/api/adventures/{client.adv_id}/retry") assert r.status_code == 200, r.text after = {a.id for a in _rows(client.adv_id)} assert before <= after, "the retained future kept every row" db = SessionLocal() try: assert db.query(models.Branch).filter_by(adventure_id=client.adv_id).count() == 2 finally: db.close() def test_switching_a_take_is_refused_while_a_kept_future_hangs_off_it(client): """The one operation that cannot be made safe by branching, because it changes which take is live in place. It reports rather than guesses.""" ScriptedProvider.replies = gold_replies() _play(client, "knock") client.post(f"/api/adventures/{client.adv_id}/retry") _play(client, "go in") _undo(client) newest = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0] r = client.post( f"/api/adventures/{client.adv_id}/actions/{newest.id}/variant", json={"index": 0} ) assert r.status_code == 400 assert "undone but kept" in r.json()["detail"] # ---------------------------------------------------------- D09, D10: editing def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client): """D09 and D10 through the operation the product actually offers. Editing an earlier turn and replaying it is one endpoint — `takes` — and it is already the "return to the parent state, then continue differently" shape the semantics ask for. The plain text edit beside it is a correction to prose that creates no continuation, and M3 does not change it. """ ScriptedProvider.replies = [ tally_reply("You accuse her.", 10), tally_reply("She draws a knife.", 30), ] _play(client, "I accuse Mara of stealing the key.") _play(client, "wait") accusation = [a for a in _rows(client.adv_id) if a.type == "do"][0] old_future = {a.id for a in _rows(client.adv_id)} ScriptedProvider.replies = [tally_reply("She shakes her head.", 1)] r = client.post( f"/api/adventures/{client.adv_id}/actions/{accusation.id}/takes", json={"text": "I quietly ask Mara whether she has seen the key."}, ) assert r.status_code == 200, r.text # The old future is retained whole... assert old_future <= {a.id for a in _rows(client.adv_id)} # ...the edited input is what the story now tells... told = _texts(client) assert any("quietly ask Mara" in t for t in told) assert not any("accuse her" in t for t in told) # ...and no state from the abandoned line leaked into it. assert _gold(client.adv_id) == 1 # ------------------------------------------------------------------ L01 def test_l01_a_failed_turn_accepts_no_narration_and_strands_no_state(client): """A provider that fails must not leave the story half-advanced. The head does move, by exactly one node, and that is deliberate rather than a gap in the guarantee: A05 keeps the player's submitted text so it can be tried again, and the head sits on it. What L01 forbids is an *accepted* narration with half-written state behind it, a head that has moved past a turn that did not happen, or earlier story becoming unreachable — so those are what this asserts. """ _turns(client, 2) branch, depth = _head(client.adv_id) told = _texts(client) banked = _gold(client.adv_id) ai_before = [a.id for a in _rows(client.adv_id) if a.type == "ai"] ScriptedProvider.replies = [ProviderError("the model refused")] client.post(f"/api/adventures/{client.adv_id}/actions", json={"type": "do", "text": "the turn that fails"}) # One step, onto the retained player action, on the same branch. assert _head(client.adv_id) == (branch, depth + 1) # No narration was accepted, and no state moved with a turn that did not # finish. assert [a.id for a in _rows(client.adv_id) if a.type == "ai"] == ai_before assert _gold(client.adv_id) == banked # Every earlier turn is still readable, in order, unchanged. assert _texts(client)[:len(told)] == told # And the story still moves: one Undo steps back over the stranded input # and the position is a complete turn again. ScriptedProvider.replies = gold_replies() assert _undo(client).status_code == 200 assert _texts(client) == told assert _gold(client.adv_id) == banked # ------------------------------------------------- I01, I02, I03, I07: bundles def _export(client) -> dict: r = client.get(f"/api/adventures/{client.adv_id}/export") assert r.status_code == 200, r.text return r.json() def _import(client, bundle) -> dict: """Imports a bundle and returns the new adventure. "A fresh data directory" is an adventure this file has never touched: the import allocates its own branch rows and its own nodes, and resolves the file's local branch numbers against them, which is the whole of what the round trip has to get right. Sharing a database with the original does not weaken that — the two adventures share no row. """ r = client.post("/api/adventures/import", json=bundle) assert r.status_code == 201, r.text return r.json() def _story_of(adv_id) -> list[str]: """The story an imported adventure tells, read through its own head.""" db = SessionLocal() try: adventure = db.get(models.Adventure, adv_id) rows = ( db.query(models.Action) .filter( models.Action.adventure_id == adv_id, lineage.path_of(db, adventure).clause(models.Action), ) .order_by(models.Action.depth, models.Action.id) .all() ) return [a.text for a in rows] finally: db.close() def test_i01_i02_a_campaign_round_trips(client): _turns(client, 3) told = _texts(client) imported = _import(client, _export(client)) assert _story_of(imported["id"]) == told assert _gold(imported["id"]) == 3 * GOLD_PER_TURN def test_i03_the_bundle_carries_the_history_the_story_no_longer_tells(client): """A backup that keeps only the active line is not a backup: the retained future is what Redo and every later recovery feature read.""" _turns(client, 3) _undo(client) _play(client, "another way") active = _texts(client) bundle = _export(client) assert len(bundle["branches"]) == 2 imported = _import(client, bundle) assert _story_of(imported["id"]) == active # Every row of the abandoned line arrived too, on its own branch. db = SessionLocal() try: assert ( db.query(models.Action).filter_by(adventure_id=imported["id"]).count() == len(_rows(client.adv_id)) ) assert db.query(models.Branch).filter_by(adventure_id=imported["id"]).count() == 2 finally: db.close() def test_i07_an_undone_head_survives_export_and_import(client): """The Phase 0B finding this milestone exists to close. Before M3 the head depth was derived on import from the newest retained row, so a campaign exported after two Undos came back silently redone to its tip.""" _turns(client, 5) whole = _texts(client) _undo(client) _undo(client) undone = _texts(client) assert len(undone) == len(whole) - 4 bundle = _export(client) assert bundle["headDepth"] == _head(client.adv_id)[1] imported = _import(client, bundle) # It opens exactly where it was left... assert _story_of(imported["id"]) == undone assert imported["can_redo"] is True # ...the later turns arrived as retained history... db = SessionLocal() try: assert ( db.query(models.Action).filter_by(adventure_id=imported["id"]).count() == len(_rows(client.adv_id)) ) finally: db.close() # ...and Redo still walks forward into them, twice, to the same story. for _ in range(2): r = client.post(f"/api/adventures/{imported['id']}/redo") assert r.status_code == 200, r.text assert _story_of(imported["id"]) == whole def test_a_bundle_written_before_m3_opens_at_its_tip(client): """Backward compatibility. A file with no `headDepth` is one written when the head could not be anywhere but the tip, so deriving it is not a fallback — it is the position that file recorded.""" _turns(client, 3) told = _texts(client) bundle = _export(client) del bundle["headDepth"] imported = _import(client, bundle) assert _story_of(imported["id"]) == told def test_a_bundle_that_reads_past_its_own_story_is_refused(client): """Checked in `plan`, before a row is written, for the reason the module docstring gives: an import that discovers a bad coordinate afterwards has already written half a tree.""" _turns(client, 2) bundle = _export(client) bundle["headDepth"] = 999 r = client.post("/api/adventures/import", json=bundle) assert r.status_code == 400 assert "ends at" in r.json()["detail"] def test_the_bundle_carries_which_branches_the_story_left(client): """Every row of an abandoned line arrives on an import either way, so the disposition is the only thing that distinguishes it from an active one. Losing it on a round trip would leave a restored backup unable to tell them apart — which is what the later cleanup and recovery features select on.""" _turns(client, 3) _undo(client) _play(client, "another way") imported = _import(client, _export(client)) db = SessionLocal() try: left = ( db.query(models.Branch) .filter( models.Branch.adventure_id == imported["id"], models.Branch.superseded_at.isnot(None), ) .all() ) origin = ( db.query(models.Branch) .filter( models.Branch.adventure_id == client.adv_id, models.Branch.superseded_at.isnot(None), ) .all() ) assert len(left) == len(origin) == 1 assert left[0].superseded_depth == origin[0].superseded_depth finally: db.close() def test_a_bundle_with_half_a_disposition_imports_as_active(client): """Neither column is read to decide anything, so a file carrying one of them is taken as carrying neither rather than having its missing half invented.""" _turns(client, 3) _undo(client) _play(client, "another way") bundle = _export(client) for branch in bundle["branches"]: branch.pop("supersededDepth", None) imported = _import(client, bundle) db = SessionLocal() try: assert ( db.query(models.Branch) .filter( models.Branch.adventure_id == imported["id"], models.Branch.superseded_at.isnot(None), ) .count() == 0 ) finally: db.close() # ------------------------------------- the narrator-edit guard (M3 closeout) def _edit(client, action_id, text): return client.patch( f"/api/adventures/{client.adv_id}/actions/{action_id}", json={"text": text} ) def _live_ai_at(adv_id, depth): return [ a for a in _rows(adv_id) if a.type == "ai" and a.live and a.depth == depth ][0] def test_editing_a_turn_with_nothing_after_it_is_still_allowed(client): """The guard has to stay out of the way of the operation it protects. A correction to the newest turn contradicts nothing, and that is the case the edit control exists for.""" _turns(client, 2) newest = [a for a in _rows(client.adv_id) if a.type == "ai"][-1] r = _edit(client, newest.id, "Corrected.") assert r.status_code == 200, r.text assert _texts(client)[-1] == "Corrected." def test_editing_a_turn_on_the_visible_story_is_still_allowed(client): """Mid-story is not by itself unsafe: everything descending from the turn is on screen, so the reader can see what their correction has to agree with. Making that case fork is `STORY-BRANCH-SEMANTICS.md` §14-15 and belongs to M5 with the state re-evaluation it needs.""" _turns(client, 3) older = [a for a in _rows(client.adv_id) if a.type == "ai"][0] r = _edit(client, older.id, "Mara wears a green cloak.") assert r.status_code == 200, r.text assert "Mara wears a green cloak." in _texts(client) def test_editing_a_narrator_turn_with_an_undone_future_keeps_it(client): """M5 corrective pass: what §14A refused, §§14-15 now handle. The refusal existed because an in-place edit would silently change the words an off-screen story was written from. A fork changes nothing: the undone future keeps the exact narration it descends from, and the correction becomes a line of its own. """ _turns(client, 3) _undo(client) at_head = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live] target = sorted(at_head, key=lambda a: a.depth)[-2] before, target_id = target.text, target.id undone = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (target.depth or 0)] assert undone, "the fixture needs a future to leave behind" r = _edit(client, target_id, "Something else entirely.") assert r.status_code == 200, r.text rows = {a.id: a for a in _rows(client.adv_id)} # §15.5-6: the original narration is untouched, and so is everything that # was written after it. assert rows[target_id].text == before assert all(old_id in rows for old_id in undone) # §15.4: the correction is what the story now tells. assert "Something else entirely." in _texts(client) assert before not in _texts(client) def test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line(client): """The case a head check alone would miss: after a divergence the head is back at a tip, but a displaced line still runs on past the shared turn. It keeps its words too.""" _turns(client, 3) shared = [a for a in _rows(client.adv_id) if a.type == "ai"][0] shared_id, before = shared.id, shared.text _undo(client) _undo(client) _play(client, "a different road") assert _adventure(client)["can_redo"] is False, "the head is at a tip again" displaced = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (shared.depth or 0)] r = _edit(client, shared_id, "Rewritten under both lines.") assert r.status_code == 200, r.text rows = {a.id: a for a in _rows(client.adv_id)} assert rows[shared_id].text == before assert all(old_id in rows for old_id in displaced), "the displaced line survives" assert "Rewritten under both lines." in _texts(client) def test_a_turn_the_displaced_line_does_not_descend_from_is_still_editable(client): """The guard must be narrow. A branch that forked *before* a turn does not descend from it, so a correction there contradicts nothing on that line.""" _turns(client, 2) _undo(client) _play(client, "a different road") # forks below turn 1 _turns(client, 2) # and continues past the fork newest = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live] target = sorted(newest, key=lambda a: a.depth)[-1] r = _edit(client, target.id, "Corrected on the live line.") assert r.status_code == 200, r.text def test_a_take_that_is_not_live_stays_editable(client): """A take the story is not telling has no continuation of its own — keeping one is what forking is for — so correcting its text cannot contradict anything.""" ScriptedProvider.replies = gold_replies() _play(client, "knock") client.post(f"/api/adventures/{client.adv_id}/retry") discarded = [a for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0] r = _edit(client, discarded.id, "The take nobody chose, corrected.") assert r.status_code == 200, r.text def test_the_guard_lifts_when_the_story_is_brought_back(client): """Refusal is a redirection, not a dead end: the error names Redo, so Redo has to make the edit possible again. The guard now covers a player's own input only. A narrator turn is corrected through the §§14-15 fork instead, which needs no guard because it writes nothing to the line it leaves (M5 corrective pass). """ _turns(client, 3) _undo(client) live = sorted( [a for a in _rows(client.adv_id) if a.type == "do" and a.live], key=lambda a: a.depth, ) target = live[-1] assert _edit(client, target.id, "x").status_code == 400 _redo(client) r = _edit(client, target.id, "Corrected once the story was whole again.") assert r.status_code == 200, r.text