| """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): |
| |
| 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 |
|
|
| 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" |
|
|
| 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 |
| |
| |
| 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) |
| |
| 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 |
|
|
|
|
| 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 |
| assert state["turn"] == 12 |
| 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 |
| 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 |
|
|
| def test_backend_failure_does_not_claim_recall(self, monkeypatch): |
| def _boom(*args, **kwargs): |
| raise ConnectionError("ollama down") |
| yield |
| 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"] == [] |
|
|
|
|
| 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) |
| assert any("<audio autoplay loop" in e for e in entities) |
| assert any("entity-convulse-soft" in e for e in entities) |
| |
| assert any("entity-peace" in e and "cue-audio" in e for e in entities) |
| assert "entity-peace" in entities[-1] |
|
|
| def test_loop_finale_builds_then_convulses_and_settles_on_end(self, monkeypatch): |
| outs = _drive(_state(tone=5), monkeypatch) |
| entities = [o[6] for o in outs] |
| assert any("<audio autoplay loop" in e for e in entities) |
| assert any("entity-frenzy-wrap" in e for e in entities) |
| |
| assert any("entity-end" in e and "cue-audio" in e for e in entities) |
| assert "entity-end" in entities[-1] |
|
|
| def test_bad_finale_builds_with_heartbeat_before_the_frenzy(self, monkeypatch): |
| outs = _drive(self_bad_state(), monkeypatch) |
| entities = [o[6] for o in outs] |
| assert any("<audio autoplay loop" in e for e in entities) |
| assert any("entity-frenzy-wrap" in e for e in entities) |
|
|
|
|
| class TestToneClassMapping: |
| def test_warm_at_15(self): |
| assert app._tone_class(15) == "tone-warm" |
| assert app._tone_class(40) == "tone-warm" |
|
|
| def test_hostile_at_minus_22(self): |
| assert app._tone_class(-22) == "tone-hostile" |
| assert app._tone_class(-50) == "tone-hostile" |
|
|
| def test_wounded_between_hostile_and_neutral(self): |
| assert app._tone_class(-15) == "tone-wounded" |
| assert app._tone_class(-20) == "tone-wounded" |
|
|
| def test_neutral_elsewhere(self): |
| assert app._tone_class(0) == "tone-neutral" |
| assert app._tone_class(14) == "tone-neutral" |
| assert app._tone_class(-14) == "tone-neutral" |
|
|
|
|
| class TestRenderTitle: |
| def test_default_title_is_hollow(self): |
| assert "Hollow" in app._render_title({"chosen_name": None}) |
|
|
| def test_named_title_shows_chosen_name(self): |
| out = app._render_title({"chosen_name": "Mara"}) |
| assert "Mara" in out |
| assert "it calls itself <b>Mara</b>" in out |
|
|
| def test_name_is_escaped(self): |
| out = app._render_title({"chosen_name": "<script>"}) |
| assert "<script>" not in out |
| assert "<script>" in out |
|
|
| def test_title_emits_end_marker_only_when_asked(self): |
| s = {"ended": True, "ending": "good"} |
| assert 'ended-now' not in app._render_title(s) |
| out = app._render_title(s, show_end=True) |
| assert 'ended-now' in out and 'data-ending="good"' in out |
| |
| assert 'ended-now' not in app._render_title({"ended": False}, show_end=True) |
|
|
|
|
| class TestPristineState: |
| def test_has_naming_fields(self): |
| fresh = app._pristine_state() |
| assert "chosen_name" in fresh |
| assert "named" in fresh |
| assert fresh["chosen_name"] is None |
| assert fresh["named"] is False |
|
|
|
|
| class TestPendingBubble: |
| def test_start_turn_adds_user_and_pending(self): |
| out_input, out_btn, hist, out_state = app._start_turn("hello", _state(affinity=20, treasure=[], claimed=[], turn=0), []) |
| assert hist[-2] == {"role": "user", "content": "hello"} |
| assert hist[-1] == {"role": "assistant", "content": app._PENDING} |
|
|
| def test_chat_strips_pending_before_replying(self, monkeypatch): |
| monkeypatch.setattr(app, "run_turn_stream", _fake_stream("hi there", '{"affinity_delta": 1}')) |
| monkeypatch.setattr(app.time, "sleep", lambda s: None) |
| state = _state(affinity=20, treasure=[], claimed=[], turn=0, tone=0) |
| history = [{"role": "user", "content": "hello"}, |
| {"role": "assistant", "content": app._PENDING}] |
| outs = list(app.chat("hello", state, history)) |
| final_hist = outs[-1][2] |
| |
| assert final_hist[-1]["content"] == "hi there" |
| assert app._PENDING not in [m["content"] for m in final_hist] |
|
|
|
|
| class TestRestart: |
| def test_pristine_state_is_a_fresh_game(self): |
| s = app._pristine_state() |
| assert s["affinity"] == 20 |
| assert s["treasure"] == [] and s["claimed"] == [] |
| assert s["turn"] == 0 |
| assert s["ended"] is False and s["ending"] is None |
|
|
| def test_reset_returns_opening_line_and_enabled_input(self): |
| out = app._reset() |
| input_update, btn_update, chatbot, fresh = out[0], out[1], out[2], out[3] |
| assert chatbot == [{"role": "assistant", "content": OPENING_LINE}] |
| assert fresh["affinity"] == 20 and fresh["ended"] is False |
| |
| assert input_update["interactive"] is True |
| assert btn_update["interactive"] is True |
|
|
|
|
| class TestVoice: |
| def test_first_turn_speaks_its_own_reply(self, monkeypatch): |
| |
| |
| |
| captured = {} |
| def fake_speak(text): |
| captured["text"] = text |
| return "FAKEB64" |
| monkeypatch.setattr(app, "run_turn_stream", |
| _fake_stream("a normal reply.", '{"affinity_delta":1}')) |
| monkeypatch.setattr(app.time, "sleep", lambda s: None) |
| monkeypatch.setattr(app, "should_recall", lambda s: (False, None)) |
| monkeypatch.setattr(app, "speak", fake_speak) |
| state = _state(affinity=20, treasure=[], claimed=[], tone=0, turn=0) |
| outs = list(app.chat("hello", state, [])) |
| assert any("FAKEB64" in o[7] for o in outs) |
| assert captured["text"] == "a normal reply." |
|
|
| def test_second_turn_speaks_the_reply(self, monkeypatch): |
| captured = {} |
| monkeypatch.setattr(app, "run_turn_stream", |
| _fake_stream("a normal reply.", '{"affinity_delta":1}')) |
| monkeypatch.setattr(app.time, "sleep", lambda s: None) |
| monkeypatch.setattr(app, "should_recall", lambda s: (False, None)) |
| monkeypatch.setattr(app, "speak", lambda t: captured.setdefault("text", t) or "FAKEB64") |
| state = _state(affinity=20, treasure=[], claimed=[], tone=0, turn=3) |
| list(app.chat("hi again", state, [])) |
| assert captured["text"] == "a normal reply." |
|
|
| def test_recall_turn_speaks_the_reply(self, monkeypatch): |
| monkeypatch.setattr(app, "run_turn_stream", |
| _fake_stream("the sea. that was me.", '{"affinity_delta":1}')) |
| monkeypatch.setattr(app.time, "sleep", lambda s: None) |
| monkeypatch.setattr(app, "should_recall", lambda s: (True, "grew up near the sea")) |
| monkeypatch.setattr(app, "speak", lambda text: "FAKEB64") |
| state = _state(affinity=60, treasure=["grew up near the sea"], claimed=[], tone=0) |
| outs = list(app.chat("hi", state, [])) |
| assert any("FAKEB64" in o[7] for o in outs) |
|
|
| def test_reply_is_always_voiced(self, monkeypatch): |
| |
| monkeypatch.setattr(app, "run_turn_stream", |
| _fake_stream("the sea. that was me.", '{"affinity_delta":1}')) |
| monkeypatch.setattr(app.time, "sleep", lambda s: None) |
| monkeypatch.setattr(app, "should_recall", lambda s: (True, "grew up near the sea")) |
| monkeypatch.setattr(app, "speak", lambda text: "FAKEB64") |
| state = _state(affinity=60, treasure=["grew up near the sea"], claimed=[], tone=0) |
| outs = list(app.chat("hi", state, [])) |
| assert any("FAKEB64" in o[7] for o in outs) |
|
|
| def test_every_reply_is_spoken_not_just_recall(self, monkeypatch): |
| monkeypatch.setattr(app, "run_turn_stream", |
| _fake_stream("the wind is cold tonight.", '{"affinity_delta":1}')) |
| monkeypatch.setattr(app.time, "sleep", lambda s: None) |
| monkeypatch.setattr(app, "should_recall", lambda s: (False, None)) |
| monkeypatch.setattr(app, "speak", lambda text: "FAKEB64") |
| state = _state(affinity=30, treasure=[], claimed=[], tone=0) |
| outs = list(app.chat("hello", state, [])) |
| assert any("FAKEB64" in o[7] for o in outs) |
|
|
| def test_reply_is_voiced_per_sentence_then_tail(self, monkeypatch): |
| monkeypatch.setattr(app, "run_turn_stream", |
| _fake_stream("the sea. that was me.")) |
| monkeypatch.setattr(app.time, "sleep", lambda s: None) |
| monkeypatch.setattr(app, "should_recall", lambda s: (False, None)) |
| monkeypatch.setattr(app, "speak", lambda t: "FAKEB64") |
| state = _state(affinity=40, treasure=[], claimed=[], tone=0, turn=2) |
| outs = list(app.chat("hi", state, [])) |
| assert any("FAKEB64" in o[7] for o in outs) |
|
|
|
|
| class TestEnterGame: |
| def test_enter_sets_mode_and_reveals_game(self): |
| outs = app._enter_game("full") |
| assert outs[0]["visible"] is False |
| assert outs[1]["visible"] is True |
| assert outs[2]["mode"] == "full" |
| assert outs[3][0]["content"] == app.OPENING_LINE |
|
|
|
|
| class TestMenuAsset: |
| def test_menu_html_has_title_and_background(self): |
| assert "menu-title" in app._menu_html() |
| assert app._BACKGROUND_URI.startswith("data:image/webp") |
|
|
|
|
| class TestHowToPlay: |
| def test_howto_copy_present(self): |
| assert "Three endings" in app._HOWTO_HTML |
|
|
| def test_open_howto_reveals_overlay(self): |
| out = app.gr.update(visible=True) |
| assert out["visible"] is True |
|
|
| def test_close_howto_hides_overlay(self): |
| out = app.gr.update(visible=False) |
| assert out["visible"] is False |
| outs = app._enter_game("full") |
| assert outs[0]["visible"] is False |
| assert outs[1]["visible"] is True |
| assert outs[2]["mode"] == "full" |
| assert outs[3][0]["content"] == app.OPENING_LINE |
|
|
|
|
| class TestOpeningRitual: |
| def test_head_js_carries_the_ritual_and_menu_music(self): |
| assert "openingRitual" in app._HEAD_JS |
| assert "startMenuMusic" in app._HEAD_JS |
| assert "stopMenuMusic" in app._HEAD_JS |
| assert "CHIME_SRC" in app._HEAD_JS |
| assert "GREETING_SRC" in app._HEAD_JS |
| assert "MENU_SRC" in app._HEAD_JS |
| assert "restart-btn" in app._HEAD_JS |
| assert "menu-mode-btn" in app._HEAD_JS |
| assert "__CHIME__" not in app._HEAD_JS |
| assert "__GREETING__" not in app._HEAD_JS |
| assert "__MENU__" not in app._HEAD_JS |
|
|
| def test_chime_constant_is_real_audio(self): |
| assert app._CHIME_URI.startswith("data:audio/wav;base64,") |
| assert len(app._CHIME_B64) > 100 |
|
|
| def test_menu_loop_constant_is_real_audio(self): |
| assert app._MENU_LOOP_URI.startswith("data:audio/wav;base64,") |
| assert len(app._MENU_LOOP_B64) > 1000 |
|
|
|
|
| class TestFinaleVoice: |
| def test_good_finale_voices_hollow_lines(self, monkeypatch): |
| monkeypatch.setattr(app, "speak", lambda text: "FAKEB64") |
| outs = _drive(_state(), monkeypatch) |
| assert any("FAKEB64" in o[7] for o in outs) |
|
|
| class TestMemoryPlea: |
| def test_level_escalates_with_barren_turns(self): |
| assert app._memory_plea_level(_state(barren_turns=0), recall_turn=False) == 0 |
| assert app._memory_plea_level(_state(barren_turns=2), recall_turn=False) == 1 |
| assert app._memory_plea_level(_state(barren_turns=4), recall_turn=False) == 2 |
| assert app._memory_plea_level(_state(barren_turns=6), recall_turn=False) == 3 |
|
|
| def test_silent_on_recall_and_when_withdrawn(self): |
| assert app._memory_plea_level(_state(barren_turns=9), recall_turn=True) == 0 |
| assert app._memory_plea_level(_state(barren_turns=9, tone=-30), recall_turn=False) == 0 |
|
|
|
|
| class TestIdle: |
| def test_idle_appends_a_canned_line_and_drops_bond(self, monkeypatch): |
| import time as _t |
| monkeypatch.setattr(app, "speak", lambda text: None) |
| state = _state(affinity=30, treasure=[], claimed=[], tone=0) |
| state["last_activity"] = _t.time() - 999 |
| state["greeted"] = True |
| new_state, hist, voice, bond = app._on_idle( |
| state, [{"role": "assistant", "content": "x"}]) |
| assert hist[-1]["role"] == "assistant" |
| assert new_state["affinity"] < 30 |
| assert new_state["tone"] < 0 |
| assert "bond-meter" in bond |
|
|
| def test_idle_escalates_to_hostile(self, monkeypatch): |
| import time as _t |
| monkeypatch.setattr(app, "speak", lambda text: None) |
| state = _state(affinity=40, treasure=[], claimed=[], tone=0) |
| state["greeted"] = True |
| state["idle_count"] = 5 |
| state["last_activity"] = _t.time() - 999 |
| _, hist, _, _ = app._on_idle(state, [{"role": "assistant", "content": "x"}]) |
| assert hist[-1]["content"] in app._IDLE_TIERS[2] |
|
|
| def test_idle_silent_returns_four_noops_when_pending_or_ended(self, monkeypatch): |
| monkeypatch.setattr(app, "speak", lambda text: None) |
| |
| pending = _state(); pending["greeted"] = True |
| out = app._on_idle(pending, [{"role": "assistant", "content": app._PENDING}]) |
| assert len(out) == 4 |
| assert not isinstance(out[1], list) |
|
|
| def test_idle_never_fires_while_a_turn_is_in_flight(self, monkeypatch): |
| import time as _t |
| monkeypatch.setattr(app, "speak", lambda text: None) |
| state = _state(affinity=30, treasure=[], claimed=[], tone=0) |
| state["last_activity"] = _t.time() - 999 |
| |
| hist = [{"role": "user", "content": "hi"}, |
| {"role": "assistant", "content": app._PENDING}] |
| out = app._on_idle(state, hist) |
| assert not isinstance(out[1], list) |
|
|
| def test_idle_fires_without_server_time_gate(self, monkeypatch): |
| monkeypatch.setattr(app, "speak", lambda t: "FAKEB64") |
| state = _state() |
| new_state, hist, voice, bond = app._on_idle( |
| state, [{"role": "assistant", "content": "x"}]) |
| assert hist[-1]["content"] in [l for tier in app._IDLE_TIERS for l in tier] |
| assert new_state["idle_count"] == 1 |
|
|
| def test_idle_still_skips_when_pending_or_ended(self): |
| pend = app._on_idle(_state(), [{"role": "assistant", "content": "...hollow-typing..."}]) |
| assert pend[1] == app.gr.update() or not isinstance(pend[1], list) |
| ended = _state(); ended["ended"] = True |
| out = app._on_idle(ended, [{"role": "assistant", "content": "x"}]) |
| assert not isinstance(out[1], list) |
|
|
|
|
| class TestSentenceStreaming: |
| def test_new_sentences_splits_on_terminators(self): |
| s, off = app._new_sentences("Pepe... Lili. And", 0) |
| assert s == ["Pepe...", "Lili."] |
| assert "And" not in "".join(s) |
| s2, off2 = app._new_sentences("Pepe... Lili. And Jack!", off) |
| assert s2 == ["And Jack!"] |
|
|
|
|
| class TestIntroFlow: |
| def test_enter_game_seeds_tone(self): |
| |
| warm = app._enter_game("tester", 12) |
| assert warm[2]["tone"] == 12 |
| assert warm[2]["mode"] == "tester" |
| guarded = app._enter_game("full", -8) |
| assert guarded[2]["tone"] == -8 |
| |
| assert app._enter_game("tester")[2]["tone"] == 0 |
|
|
| def test_show_intro_returns_mode(self): |
| out = app._show_intro("full") |
| |
| assert out[-1] == "full" |
|
|
| def test_intro_image_uri_missing_file_falls_back(self): |
| assert app._img_uri("assets/does_not_exist.webp") == "" |
|
|
| def test_intro_pos_aligns_with_cards(self): |
| |
| assert len(app._INTRO_POS) == len(app.INTRO_CARDS) |
| assert all(isinstance(p, str) and p for p in app._INTRO_POS) |
| |
| import json |
| cards = json.loads(app._INTRO_CARDS_JSON) |
| assert len(cards) == len(app.INTRO_CARDS) |
| assert all({"img", "text", "pos"} <= set(c) for c in cards) |
|
|
|
|
| def test_menu_html_has_letterbox_frame(): |
| html = app._menu_html() |
| |
| assert 'id="menu-frame"' in html |
| assert 'class="menu-bg"' in html |
| |
| assert html.count(app._BACKGROUND_URI) >= 2 |
|
|
|
|
| def test_intro_opts_proxy_to_hidden_buttons(): |
| |
| src = app._HEAD_JS |
| assert 'intro-opts' in src |
| |
| import inspect |
| mod = inspect.getsource(app) |
| assert 'id="intro-opts"' in mod |
| assert 'data-target="btn-offer"' in mod |
| assert 'data-target="btn-keep"' in mod |
|
|
|
|
| def test_menu_card_has_option_rows(): |
| html = app._menu_html() |
| assert 'id="menu-card"' in html |
| assert 'data-target="btn-tester"' in html |
| assert 'data-target="btn-full"' in html |
| assert 'data-target="btn-howto"' in html |
| assert 'class="menu-opt"' in html |
| |
| assert 'menu-opt' in app._HEAD_JS |
|
|