Spaces:
Running on Zero
Running on Zero
| import html as _html_mod | |
| from render import render_entity, render_treasure, render_recovered | |
| def test_entity_renders_silhouette_scene(): | |
| from render import render_entity | |
| html = render_entity(20) | |
| assert 'entity-silhouette' in html # the new free-standing figure class | |
| assert 'entity-portal' not in html # the gothic arch is gone | |
| def test_entity_cue_marker(): | |
| from render import render_entity | |
| assert 'data-cue="recall"' in render_entity(60, cue="recall", seq=3) | |
| assert 'data-seq="3"' in render_entity(60, cue="recall", seq=3) | |
| # no cue → no marker | |
| assert 'cue-now' not in render_entity(20) | |
| class TestRenderRecovered: | |
| def test_empty_shows_nothing(self): | |
| out = render_recovered(0) | |
| assert "remembers nothing of itself" in out.lower() | |
| assert "drawer-head" in out | |
| def test_reveals_fragments_in_order(self): | |
| import html as _html | |
| from character import OWN_FRAGMENTS | |
| out = render_recovered(2) | |
| assert _html.escape(OWN_FRAGMENTS[0], quote=True) in out | |
| assert _html.escape(OWN_FRAGMENTS[1], quote=True) in out | |
| assert _html.escape(OWN_FRAGMENTS[2], quote=True) not in out | |
| def test_capped_at_total_fragments(self): | |
| from character import OWN_FRAGMENTS | |
| out = render_recovered(99) | |
| assert out.count('class="recovered-item') == len(OWN_FRAGMENTS) | |
| def test_recovered_weaves_stolen(self): | |
| from character import OWN_FRAGMENTS | |
| html = render_recovered(1, claimed=["a red bicycle, the summer i turned seven"]) | |
| assert _html_mod.escape(OWN_FRAGMENTS[0], quote=True) in html # its real past | |
| assert "a red bicycle" in html # the stolen memory | |
| assert "stolen" in html # marked | |
| # back-compat: no claimed arg still works | |
| assert "remembers nothing of itself" in render_recovered(0).lower() | |
| class TestRenderTreasure: | |
| def test_empty_shows_placeholder(self): | |
| out = render_treasure([]) | |
| assert "nothing yet. tell me something true." in out | |
| def test_empty_has_no_memory_item(self): | |
| # The memory-item style marker must not appear when there are no memories | |
| out = render_treasure([]) | |
| assert "border-left:2px solid #5a3a7a" not in out | |
| def test_single_memory_appears(self): | |
| out = render_treasure(["has a dog named Nala"]) | |
| assert "has a dog named Nala" in out | |
| def test_multiple_memories_all_appear(self): | |
| out = render_treasure(["has a dog named Nala", "grew up near the sea"]) | |
| assert "has a dog named Nala" in out | |
| assert "grew up near the sea" in out | |
| def test_html_tags_are_escaped(self): | |
| out = render_treasure(["<script>alert('x')</script>"]) | |
| assert "<script>" not in out | |
| assert "<script>" in out | |
| def test_ampersand_is_escaped(self): | |
| out = render_treasure(["mom & dad"]) | |
| assert "mom & dad" in out | |
| def test_header_present_in_both_states(self): | |
| assert "Treasure" in render_treasure([]) | |
| assert "Treasure" in render_treasure(["x"]) | |
| class TestRenderEntity: | |
| def test_idle_contains_scene_and_webp_image(self): | |
| out = render_entity(20) | |
| assert "entity-scene" in out | |
| assert "entity-silhouette" in out | |
| assert "data:image/webp;base64," in out | |
| def test_low_affinity_is_dark_and_faint(self): | |
| out = render_entity(0) | |
| assert "brightness(0.72)" in out | |
| assert "opacity:0.82" in out | |
| assert "scale(" not in out # size never changes — frame always fits | |
| def test_full_affinity_is_bright_full(self): | |
| out = render_entity(100) | |
| assert "brightness(1.0)" in out | |
| assert "opacity:1.0" in out | |
| assert "scale(" not in out | |
| def test_affinity_is_clamped(self): | |
| assert render_entity(-10) == render_entity(0) | |
| assert render_entity(140) == render_entity(100) | |
| def test_sway_present_below_almost_tier(self): | |
| assert "entity-sway" in render_entity(75) | |
| def test_sway_removed_at_almost_tier(self): | |
| assert "entity-sway" not in render_entity(76) | |
| def test_almost_face_at_almost_tier(self): | |
| # the silhouette itself swaps to the "almost" face at high bond | |
| assert "entity-face-almost" in render_entity(90) | |
| def test_flash_overlay_only_in_flash_modes(self): | |
| assert "entity-flash" not in render_entity(40, "idle") | |
| assert "entity-flash" in render_entity(40, "flash") | |
| assert "entity-flash-strong" in render_entity(40, "flash_strong") | |
| assert "entity-flash-strong" not in render_entity(40, "flash") | |
| def test_flash_flickers_through_every_face(self): | |
| # the flash stacks all four portraits to convulse between them | |
| out = render_entity(0, "flash") | |
| assert "flick-0" in out and "flick-3" in out | |
| assert out.count("entity-flick") == 4 | |
| def test_end_mode_shows_end_image_only(self): | |
| out = render_entity(100, "end") | |
| assert "entity-end" in out | |
| assert "entity-sway" not in out | |
| assert "entity-flash" not in out | |
| class TestRenderTreasureFinale: | |
| def test_struck_memory_is_crossed_out(self): | |
| out = render_treasure(["had a dog named Nala", "grew up near the sea"], | |
| struck={"had a dog named Nala"}) | |
| assert "line-through" in out | |
| def test_unstruck_render_has_no_strike(self): | |
| out = render_treasure(["had a dog named Nala"]) | |
| assert "line-through" not in out | |
| def test_mine_replaces_all_items(self): | |
| out = render_treasure(["had a dog named Nala"], mine=True) | |
| assert "...mine now." in out | |
| assert "had a dog named Nala" not in out | |
| class TestRenderEntityUX: | |
| def test_fully_sharp_by_bond_45(self): | |
| out = render_entity(45) | |
| assert "brightness(1.0)" in out | |
| assert "opacity:1.0" in out | |
| def test_recognizable_silhouette_at_start(self): | |
| # bond 20 (session start) must already read as a child, not a smudge | |
| out = render_entity(20) | |
| # t = 20/45 ≈ 0.444; brightness = 0.72 + 0.28*0.444 ≈ 0.84 | |
| assert "brightness(0.84)" in out | |
| # opacity = 0.82 + 0.18*0.444 ≈ 0.90 | |
| assert "opacity:0.9" in out | |
| def test_scene_tone_class_present(self): | |
| assert 'class="entity-scene tone-warm"' in render_entity(50, tone=20) | |
| assert 'class="entity-scene tone-hostile"' in render_entity(50, tone=-30) | |
| def test_flash_animation_key_alternates_by_seq(self): | |
| assert "flash-0" in render_entity(40, "flash", seq=0) | |
| assert "flash-1" in render_entity(40, "flash", seq=1) | |
| assert "flash-0" in render_entity(40, "flash", seq=2) | |
| def test_ghost_echo_only_on_strong_flash_and_end(self): | |
| assert "entity-ghost" not in render_entity(40, "idle") | |
| assert "entity-ghost" not in render_entity(40, "flash") | |
| assert "entity-ghost" in render_entity(40, "flash_strong") | |
| assert "entity-ghost" in render_entity(95, "end") | |
| class TestRenderTreasureUX: | |
| def test_panel_has_scroll_class(self): | |
| assert "treasure-panel" in render_treasure([]) | |
| assert "treasure-panel" in render_treasure(["had a dog named Nala"]) | |
| def test_older_memories_fade(self): | |
| out = render_treasure(["oldest memory", "middle memory", "newest memory"]) | |
| assert "opacity:0.86" in out # oldest of three: 1 - 0.07*2 | |
| assert "opacity:1.0" in out # newest stays fully lit | |
| def test_age_fade_has_floor(self): | |
| out = render_treasure([f"memory {i}" for i in range(20)]) | |
| assert "opacity:0.45" in out | |
| def test_claimed_memories_marked(self): | |
| out = render_treasure(["mine still", "taken one"], claimed={"taken one"}) | |
| assert "border-left-color:#3a2a50" in out | |
| def test_unclaimed_render_unmarked(self): | |
| out = render_treasure(["mine still"]) | |
| assert "border-left-color:#3a2a50" not in out | |
| def test_just_claimed_item_gets_claiming_class(self): | |
| out = render_treasure(["mine still", "taken one"], | |
| claimed={"taken one"}, | |
| just_claimed="taken one") | |
| assert "claiming" in out | |
| assert "class=\"treasure-item claiming" in out | |
| class TestTreasureWounds: | |
| def test_redacted_entry_per_wound(self): | |
| html_out = render_treasure([], wounds=["you freak", "you bore me"]) | |
| assert html_out.count("▮") >= 8 # two entries of 4-7 blocks | |
| def test_redacted_text_never_in_dom(self): | |
| html_out = render_treasure([], wounds=["you freak"]) | |
| assert "you freak" not in html_out # no inspect-element spoiler | |
| def test_revealed_wound_shows_text(self): | |
| html_out = render_treasure([], wounds=["you freak", "you bore me"], revealed=1) | |
| assert "you freak" in html_out | |
| assert "you bore me" not in html_out | |
| def test_revealed_wound_is_escaped(self): | |
| html_out = render_treasure([], wounds=["<b>rude</b>"], revealed=1) | |
| assert "<b>" not in html_out | |
| assert "<b>" in html_out | |
| def test_yours_mode_changes_header(self): | |
| html_out = render_treasure(["a memory"], wounds=["w1"], revealed=1, yours=True) | |
| assert "Your Words" in html_out | |
| assert "a memory" not in html_out # memories discarded in yours mode | |
| def test_wounds_render_alongside_memories(self): | |
| html_out = render_treasure(["had a dog"], wounds=["w1"]) | |
| assert "had a dog" in html_out | |
| assert "▮" in html_out | |
| def test_no_wounds_is_backward_compatible(self): | |
| assert render_treasure(["had a dog"]) == render_treasure(["had a dog"], wounds=[]) | |
| def test_wounds_alone_suppress_empty_placeholder(self): | |
| html_out = render_treasure([], wounds=["w1"]) | |
| assert "nothing yet" not in html_out | |
| class TestMaterialization: | |
| def test_low_bond_uses_darkness_and_fog_not_blur(self): | |
| html = render_entity(10, "idle", seq=0) # barely materialized | |
| assert "blur(" not in html # no loading-look blur | |
| assert "brightness(" in html # shadowed instead | |
| def test_high_bond_is_bright_and_clear(self): | |
| html = render_entity(45, "idle", seq=0) | |
| assert "brightness(1" in html or "brightness(0.9" in html | |
| class TestEntityPeaceModes: | |
| def test_peace_shows_peace_face_no_ghost(self): | |
| out = render_entity(90, "peace") | |
| assert "entity-peace" in out | |
| assert "entity-ghost" not in out # no menace in the redemption | |
| def test_peace_dissolve_combines_peace_and_dissolve(self): | |
| out = render_entity(90, "peace_dissolve") | |
| assert "entity-peace" in out | |
| assert "entity-dissolve" in out | |
| def test_idle_has_no_dissolve(self): | |
| assert "entity-dissolve" not in render_entity(50, "idle") | |
| class TestEntityRageMode: | |
| def test_rage_mode_shows_rage_face_with_tint(self): | |
| out = render_entity(12, "rage") | |
| assert "entity-rage" in out | |
| assert "entity-rage-tint" in out | |
| def test_rage_mode_has_ghost_echo(self): | |
| assert "entity-ghost" in render_entity(12, "rage") | |
| def test_idle_has_no_rage_artifacts(self): | |
| out = render_entity(50, "idle") | |
| assert "entity-rage" not in out | |
| class TestEntityFrenzyMode: | |
| def test_frenzy_stacks_all_faces(self): | |
| out = render_entity(12, "frenzy") | |
| assert "entity-frenzy-wrap" in out | |
| assert out.count("entity-flick") == 4 | |
| def test_frenzy_hides_rage_face_beneath(self): | |
| # the reveal happens AFTER the convulsion — the smudge stays under it | |
| out = render_entity(12, "frenzy") | |
| assert 'class="entity-img entity-rage"' not in out | |
| assert "entity-rage-tint" in out | |
| def test_frenzy_has_triple_screen_stab_and_sting(self): | |
| out = render_entity(12, "frenzy") | |
| assert "entity-ghost-frenzy" in out | |
| assert "cue-audio" in out # the sting (queued) | |
| class TestReplyChime: | |
| def test_idle_has_no_audio(self): | |
| # the per-reply chime was removed — the child SPEAKS every reply now, | |
| # so the chime was redundant (and fired out of sync under streaming) | |
| assert "<audio" not in render_entity(50, "idle", seq=3) | |
| def test_idle_is_stable_across_seq(self): | |
| # with no chime, idle renders identically regardless of the turn counter | |
| assert render_entity(50, "idle", seq=0) == render_entity(50, "idle", seq=1) | |
| def test_finale_modes_have_no_chime(self): | |
| for mode in ("end", "rage", "peace", "peace_dissolve"): | |
| out = render_entity(90, mode) | |
| assert "<audio autoplay" not in out, mode | |
| def test_frenzy_keeps_only_the_sting(self): | |
| out = render_entity(12, "frenzy") | |
| assert out.count("cue-audio") == 1 | |
| assert "<audio autoplay" not in out # the sting routes via the queue | |
| class TestBuildMode: | |
| def test_build_loops_the_heartbeat_and_not_the_chime(self): | |
| html = render_entity(50, "build", seq=4) | |
| assert "<audio autoplay loop" in html # heartbeat bed loops | |
| assert "entity-redpulse" in html # red suspense pulse | |
| # the per-turn music-box chime must NOT play during a finale build | |
| assert html.count("<audio") == 1 | |
| def test_build_keeps_the_child_visible(self): | |
| html = render_entity(50, "build", seq=4) | |
| assert "entity-img" in html | |
| class TestConvulsionModes: | |
| def test_convulse_good_is_gentle_and_silent(self): | |
| html = render_entity(80, "convulse_good", seq=2) | |
| assert "entity-convulse-soft" in html # soft animation wrapper | |
| assert "entity-rage-tint" not in html # no blood-red tint | |
| assert "entity-settle-face" in html # eases into the smile | |
| # silent: the sigh plays on the settle, not during the convulsion | |
| assert "<audio" not in html | |
| def test_convulse_loop_is_fast_and_silent(self): | |
| html = render_entity(80, "convulse_loop", seq=2) | |
| assert "entity-frenzy-wrap" in html # reuses the hard convulse | |
| assert "entity-convulse-loop" in html # faster 3-cycle variant | |
| assert "entity-settle-face" in html # eases into the end face | |
| # silent: the flatline plays on the settle | |
| assert "<audio" not in html | |
| class TestSettleModes: | |
| def test_peace_settle_shows_the_smile_and_sighs(self): | |
| html = render_entity(80, "peace_settle", seq=2) | |
| assert "entity-peace" in html # the smile | |
| assert "cue-audio" in html # the relief sigh (queued) | |
| def test_end_settle_shows_the_end_face_and_flatlines(self): | |
| html = render_entity(80, "end_settle", seq=2) | |
| assert "entity-end" in html | |
| assert "cue-audio" in html # the flatline (queued) | |