Spaces:
Sleeping
Sleeping
| """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 "<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) # 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 | |