"""Integration tests: drive chat() directly through each ending path. The finale turns are fully scripted (decide_ending fires before run_turn), so no model is needed. time.sleep is patched out — a real run streams ~30 s. """ import app from character import OPENING_LINE def _state(**overrides): base = { "affinity": 90, "treasure": ["is afraid of being forgotten", "had a dog named Nala", "grew up near the sea"], "claimed": ["is afraid of being forgotten", "had a dog named Nala", "grew up near the sea"], "history": [], "turn": 12, "last_recall_turn": 9, "ended": False, "tone": 30, "wounds": [], "ending": None, "last_activity": 0.0, "idle_count": 0, "greeted": False, } base.update(overrides) return base def _drive(state, monkeypatch): monkeypatch.setattr(app.time, "sleep", lambda s: None) return list(app.chat("trigger", state, [])) def _fake_stream(reply, raw_json='{"affinity_delta":1}'): def _gen(*a, **k): # yield growing partials so sentence boundaries surface during the stream half = reply[: max(1, len(reply)//2)] yield half yield reply yield ("__final__", reply, raw_json) return _gen class TestGoodEndingPath: def test_fires_redemption(self, monkeypatch): state = _state() outs = _drive(state, monkeypatch) assert state["ended"] is True assert state["ending"] == "good" texts = [m["content"] for m in outs[-1][2]] assert any("keep your memories" in t for t in texts) assert any("i am not a child" in t for t in texts) def test_treasure_is_returned_not_taken(self, monkeypatch): outs = _drive(_state(), monkeypatch) treasure_html = outs[-1][5] assert "...mine now." not in treasure_html assert "had a dog named Nala" in treasure_html # still yours, lit def test_ends_on_peace_dissolve(self, monkeypatch): outs = _drive(_state(), monkeypatch) entity_html = outs[-1][6] assert "entity-peace" in entity_html assert "entity-dissolve" in entity_html assert outs[-1][0]["placeholder"] == "it's quiet now." class TestLoopEndingPath: def test_flat_tone_earns_the_visitor_loop(self, monkeypatch): state = _state(tone=5) outs = _drive(state, monkeypatch) assert state["ending"] == "loop" final_history = outs[-1][2] assert final_history[-1]["content"] == OPENING_LINE assert final_history[-1]["role"] == "user" # spoken by the visitor def test_treasure_becomes_mine(self, monkeypatch): outs = _drive(_state(tone=5), monkeypatch) assert "...mine now." in outs[-1][5] class TestBadEndingPath: def _bad_state(self): return _state(affinity=12, treasure=[], claimed=[], turn=8, tone=-45, wounds=["you're nothing", "stop whining"]) def test_fires_with_wound_recital(self, monkeypatch): state = self._bad_state() outs = _drive(state, monkeypatch) assert state["ending"] == "bad" final_history = outs[-1][2] texts = [m["content"] for m in final_history] assert any("you're nothing" in t for t in texts) def test_treasure_turns_into_your_words(self, monkeypatch): outs = _drive(self._bad_state(), monkeypatch) treasure_html = outs[-1][5] assert "Your Words" in treasure_html # unredacted by the finale (apostrophes are HTML-escaped, so check # the wound without one) assert "stop whining" in treasure_html def test_input_dies_with_see_you_soon(self, monkeypatch): outs = _drive(self._bad_state(), monkeypatch) assert outs[-1][0]["placeholder"] == "see you soon." def test_frenzy_convulsion_before_threat(self, monkeypatch): outs = _drive(self._bad_state(), monkeypatch) entities = [o[6] for o in outs] assert any("entity-frenzy-wrap" in e for e in entities) # the threat lands after the convulsion, on the settled rage face final_history = outs[-1][2] texts = [m["content"] for m in final_history] assert any("the Gaunt knows your door now" in t for t in texts) class TestEndedSessionStaysDead: def test_no_second_finale(self, monkeypatch): state = _state(ended=True, ending="loop") outs = _drive(state, monkeypatch) assert len(outs) == 1 # single locked yield, no script class TestSafetyNets: def test_too_long_message_declined_in_character(self, monkeypatch): state = _state(affinity=50, treasure=[], claimed=[], tone=0) outs = _drive_msg("x" * 600, state, monkeypatch) assert state["affinity"] == 50 # no state change assert state["turn"] == 12 # turn not consumed reply = outs[-1][2][-1]["content"] assert "too many words" in reply def test_backend_failure_degrades_to_fog(self, monkeypatch): def _boom(*args, **kwargs): raise ConnectionError("ollama down") yield # pragma: no cover (make it a generator) monkeypatch.setattr(app, "run_turn_stream", _boom) state = _state(affinity=50, treasure=["x"], claimed=[], tone=0) outs = _drive_msg("hello", state, monkeypatch) reply = outs[-1][2][-1]["content"] assert "fog swallowed" in reply assert state["affinity"] == 50 # fallback delta 0 def test_backend_failure_does_not_claim_recall(self, monkeypatch): def _boom(*args, **kwargs): raise ConnectionError("ollama down") yield # pragma: no cover (make it a generator) monkeypatch.setattr(app, "run_turn_stream", _boom) state = _state(affinity=60, treasure=["had a dog"], claimed=[], tone=0) _drive_msg("hello", state, monkeypatch) assert state["claimed"] == [] # recall not burned on a dead turn def _drive_msg(msg, state, monkeypatch): monkeypatch.setattr(app.time, "sleep", lambda s: None) return list(app.chat(msg, state, [])) def self_bad_state(): return _state(affinity=12, treasure=[], claimed=[], turn=8, tone=-45, wounds=["you're nothing", "stop whining"]) class TestFinaleBuildAndConvulse: def test_good_finale_builds_with_heartbeat_then_settles_on_peace(self, monkeypatch): outs = _drive(_state(), monkeypatch) entities = [o[6] for o in outs] assert any("entity-redpulse" in e for e in entities) # build pulse assert any("