hollow / tests /test_app.py
Pabloler21's picture
feat(finale): tie the bad-ending threat to the lore (Caor/the Gaunt, the given)
cadf187
Raw
History Blame Contribute Delete
25.2 kB
"""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("<audio autoplay loop" in e for e in entities) # heartbeat bed
assert any("entity-convulse-soft" in e for e in entities) # soft convulse
# the relief sigh plays ON the settle (peace face + cue audio together)
assert any("entity-peace" in e and "cue-audio" in e for e in entities)
assert "entity-peace" in entities[-1] # settles happy
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) # heartbeat bed
assert any("entity-frenzy-wrap" in e for e in entities) # convulse_loop
# the flatline plays ON the settle (end face + cue audio together)
assert any("entity-end" in e and "cue-audio" in e for e in entities)
assert "entity-end" in entities[-1] # settles on end
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) # heartbeat bed
assert any("entity-frenzy-wrap" in e for e in entities) # frenzy (sting)
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 "&lt;script&gt;" 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) # not by default
out = app._render_title(s, show_end=True)
assert 'ended-now' in out and 'data-ending="good"' in out
# never on a live (not-ended) state even if asked
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]
# the "..." is gone, replaced by the real reply; no duplicate
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
# input re-enabled (a finale leaves it dead)
assert input_update["interactive"] is True
assert btn_update["interactive"] is True
class TestVoice:
def test_first_turn_speaks_its_own_reply(self, monkeypatch):
# the opening ritual (chime -> greeting) is queued by the head
# controller on the first gesture; the first message's reply voices the
# reply itself.
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) # the reply is voiced
assert captured["text"] == "a normal reply." # ...not the opening line
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) # not first
list(app.chat("hi again", state, []))
assert captured["text"] == "a normal reply." # later turns voice the 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") # voice available
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) # voice channel carries audio
def test_reply_is_always_voiced(self, monkeypatch):
# mute/volume now live in the browser; the server always sends audio
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) # audio always present
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)) # NO recall
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) # a plain reply is spoken too
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) # spoken somewhere in the stream
class TestEnterGame:
def test_enter_sets_mode_and_reveals_game(self):
outs = app._enter_game("full")
assert outs[0]["visible"] is False # menu hidden
assert outs[1]["visible"] is True # game shown
assert outs[2]["mode"] == "full" # mode stored on state
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 # menu hidden
assert outs[1]["visible"] is True # game shown
assert outs[2]["mode"] == "full" # mode stored on state
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 # restart re-greets
assert "menu-mode-btn" in app._HEAD_JS # mode buttons stop music + greet
assert "__CHIME__" not in app._HEAD_JS # placeholders were filled
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 # a real synthesized note
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) # at least one spoken line
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 # long idle
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 # ignoring lowers the bond
assert new_state["tone"] < 0 # and sours the tone
assert "bond-meter" in bond # the meter re-rendered
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 # already ignored a lot
state["last_activity"] = _t.time() - 999
_, hist, _, _ = app._on_idle(state, [{"role": "assistant", "content": "x"}])
assert hist[-1]["content"] in app._IDLE_TIERS[2] # the hostile tier
def test_idle_silent_returns_four_noops_when_pending_or_ended(self, monkeypatch):
monkeypatch.setattr(app, "speak", lambda text: None)
# timing moved to client; only pending/ended guards remain on server
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) # chatbot untouched
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 # would be "idle"...
# ...but the "..." pending bubble means a turn is mid-flight -> no-op
hist = [{"role": "user", "content": "hi"},
{"role": "assistant", "content": app._PENDING}]
out = app._on_idle(state, hist)
assert not isinstance(out[1], list) # chatbot untouched (no race)
def test_idle_fires_without_server_time_gate(self, monkeypatch):
monkeypatch.setattr(app, "speak", lambda t: "FAKEB64")
state = _state() # fresh last_activity (would be "not idle")
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) # no appended line
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) # the unfinished tail is not spoken
s2, off2 = app._new_sentences("Pepe... Lili. And Jack!", off)
assert s2 == ["And Jack!"]
class TestIntroFlow:
def test_enter_game_seeds_tone(self):
# state is the 3rd return value (index 2)
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
# default seed is neutral
assert app._enter_game("tester")[2]["tone"] == 0
def test_show_intro_returns_mode(self):
out = app._show_intro("full")
# last return value is the pending mode string carried to the choice buttons
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):
# one focal position per card, all non-empty strings
assert len(app._INTRO_POS) == len(app.INTRO_CARDS)
assert all(isinstance(p, str) and p for p in app._INTRO_POS)
# the card payload carries img, text, and pos for every card
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()
# the sharp centered frame and the (now blurred) backdrop both exist
assert 'id="menu-frame"' in html
assert 'class="menu-bg"' in html
# both reference the forest art
assert html.count(app._BACKGROUND_URI) >= 2
def test_intro_opts_proxy_to_hidden_buttons():
# the panel hosts the option rows, each targeting a hidden Gradio button id
src = app._HEAD_JS
assert 'intro-opts' in src # reveal logic references the opts
# the intro markup is built inline in the Blocks; check the module source
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
# the JS wires the rows
assert 'menu-opt' in app._HEAD_JS