Spaces:
Running on Zero
Running on Zero
| import pytest | |
| from memory import get_tier, apply_update, should_recall, decide_ending, sanitize_name | |
| class TestGetTier: | |
| def test_zero_is_hollow(self): | |
| assert get_tier(0) == "Hollow" | |
| def test_25_is_hollow(self): | |
| assert get_tier(25) == "Hollow" | |
| def test_26_is_curious(self): | |
| assert get_tier(26) == "Curious" | |
| def test_50_is_curious(self): | |
| assert get_tier(50) == "Curious" | |
| def test_51_is_too_human(self): | |
| assert get_tier(51) == "Too Human" | |
| def test_75_is_too_human(self): | |
| assert get_tier(75) == "Too Human" | |
| def test_76_is_almost(self): | |
| assert get_tier(76) == "Almost" | |
| def test_100_is_almost(self): | |
| assert get_tier(100) == "Almost" | |
| class TestApplyUpdate: | |
| def _base_state(self): | |
| return {"affinity": 50, "treasure": [], "claimed": [], "history": [], "turn": 0} | |
| def test_valid_json_updates_affinity(self): | |
| state = self._base_state() | |
| result = apply_update(state, '{"affinity_delta": 5, "new_memories": []}') | |
| assert result["affinity"] == 55 | |
| def test_affinity_clamped_at_100(self): | |
| state = self._base_state() | |
| state["affinity"] = 98 | |
| result = apply_update(state, '{"affinity_delta": 5, "new_memories": []}') | |
| assert result["affinity"] == 100 | |
| def test_affinity_clamped_at_0(self): | |
| state = self._base_state() | |
| state["affinity"] = 2 | |
| result = apply_update(state, '{"affinity_delta": -5, "new_memories": []}') | |
| assert result["affinity"] == 0 | |
| def test_new_memories_added_to_treasure(self): | |
| state = self._base_state() | |
| result = apply_update(state, '{"affinity_delta": 0, "new_memories": ["has a dog named Nala"]}') | |
| assert "has a dog named Nala" in result["treasure"] | |
| def test_duplicate_memory_not_added_twice(self): | |
| state = self._base_state() | |
| state["treasure"] = ["has a dog named Nala"] | |
| result = apply_update(state, '{"affinity_delta": 0, "new_memories": ["has a dog named Nala"]}') | |
| assert result["treasure"].count("has a dog named Nala") == 1 | |
| def test_broken_json_falls_back_to_no_change(self): | |
| state = self._base_state() | |
| result = apply_update(state, "not valid json at all") | |
| assert result["affinity"] == 50 | |
| assert result["treasure"] == [] | |
| def test_broken_json_with_garbage_prefix(self): | |
| state = self._base_state() | |
| result = apply_update(state, 'Sure! Here you go: {"affinity_delta": 3, "new_memories": []}') | |
| assert result["affinity"] == 53 | |
| def test_negative_delta_lowers_affinity(self): | |
| state = self._base_state() | |
| result = apply_update(state, '{"affinity_delta": -3, "new_memories": []}') | |
| assert result["affinity"] == 47 | |
| def test_chosen_name_is_sanitized_and_stored(self): | |
| state = self._base_state() | |
| result = apply_update( | |
| state, | |
| '{"affinity_delta": 0, "new_memories": [], "chosen_name": "Mara"}', | |
| ) | |
| assert result["chosen_name"] == "Mara" | |
| assert result.get("named") is True | |
| def test_chosen_name_rejected_if_already_named(self): | |
| state = self._base_state() | |
| state["chosen_name"] = "Lila" | |
| state["named"] = True | |
| result = apply_update( | |
| state, | |
| '{"affinity_delta": 0, "new_memories": [], "chosen_name": "Nova"}', | |
| ) | |
| assert result["chosen_name"] == "Lila" | |
| def test_invalid_name_falls_back(self): | |
| state = self._base_state() | |
| result = apply_update( | |
| state, | |
| '{"affinity_delta": 0, "new_memories": [], "chosen_name": "123bad"}', | |
| ) | |
| assert result.get("chosen_name") is None | |
| assert result.get("named") is not True | |
| class TestSanitizeName: | |
| def test_clean_single_word(self): | |
| assert sanitize_name("Mara") == "Mara" | |
| def test_strips_punctuation(self): | |
| assert sanitize_name('"Lila."') == "Lila" | |
| def test_uses_first_word_of_multiple(self): | |
| assert sanitize_name("Little Ghost") == "Little" | |
| def test_rejects_empty(self): | |
| assert sanitize_name(" ") is None | |
| def test_rejects_digits(self): | |
| assert sanitize_name("X7") is None | |
| def test_rejects_too_long(self): | |
| assert sanitize_name("A" * 25) is None | |
| class TestShouldRecall: | |
| def _state(self, affinity=60, treasure=None, claimed=None, turn=0, last_recall_turn=None): | |
| return { | |
| "affinity": affinity, | |
| "treasure": treasure if treasure is not None else ["has a dog named Nala"], | |
| "claimed": claimed if claimed is not None else [], | |
| "history": [], | |
| "turn": turn, | |
| "last_recall_turn": last_recall_turn, | |
| } | |
| def test_no_recall_when_nothing_unclaimed(self): | |
| state = self._state(affinity=44, treasure=[]) | |
| fired, mem = should_recall(state) | |
| assert fired is False | |
| assert mem is None | |
| def test_no_recall_empty_treasure(self): | |
| state = self._state(affinity=60, treasure=[]) | |
| fired, mem = should_recall(state) | |
| assert fired is False | |
| assert mem is None | |
| def test_no_recall_all_claimed(self): | |
| state = self._state(affinity=60, treasure=["x"], claimed=["x"]) | |
| fired, mem = should_recall(state) | |
| assert fired is False | |
| assert mem is None | |
| def test_first_recall_at_45(self): | |
| state = self._state(affinity=45, treasure=["has a dog named Nala"], last_recall_turn=None) | |
| fired, mem = should_recall(state) | |
| assert fired is True | |
| assert mem == "has a dog named Nala" | |
| def test_recall_returns_richest_unclaimed(self): | |
| state = self._state( | |
| affinity=55, | |
| treasure=[ | |
| "had a grandmother", | |
| "had a red bicycle I loved more than anything, stolen, cried for days", | |
| "kept bees", | |
| ], | |
| claimed=[], | |
| last_recall_turn=None, | |
| ) | |
| fired, mem = should_recall(state) | |
| assert fired is True | |
| assert mem == "had a red bicycle I loved more than anything, stolen, cried for days" | |
| def test_recall_tie_breaks_to_oldest_when_equal_length(self): | |
| state = self._state( | |
| affinity=55, | |
| treasure=["memory A", "memory B"], | |
| claimed=[], | |
| last_recall_turn=None, | |
| ) | |
| fired, mem = should_recall(state) | |
| assert fired is True | |
| assert mem == "memory A" | |
| def test_recall_skips_claimed_memories(self): | |
| state = self._state( | |
| affinity=60, | |
| treasure=["memory A", "memory B"], | |
| claimed=["memory A"], | |
| last_recall_turn=None, | |
| ) | |
| fired, mem = should_recall(state) | |
| assert fired is True | |
| assert mem == "memory B" | |
| def test_no_recall_on_the_same_turn_as_last(self): | |
| state = self._state(affinity=60, turn=5, last_recall_turn=5) | |
| fired, mem = should_recall(state) | |
| assert fired is False # 5 - 5 = 0 < 1 (tester cooldown) | |
| def test_recall_one_turn_after_last(self): | |
| state = self._state(affinity=60, turn=6, last_recall_turn=5) | |
| fired, mem = should_recall(state) | |
| assert fired is True # 6 - 5 = 1 >= 1 (tester cooldown) | |
| assert mem == "has a dog named Nala" | |
| def test_no_recall_if_no_unclaimed_after_some_claimed(self): | |
| state = self._state( | |
| affinity=70, | |
| treasure=["x", "y"], | |
| claimed=["x", "y"], | |
| last_recall_turn=None, | |
| ) | |
| fired, mem = should_recall(state) | |
| assert fired is False | |
| assert mem is None | |
| def _end_state(affinity=90, claimed_count=3, ended=False, tone=30, turn=12, wounds=None): | |
| return { | |
| "affinity": affinity, | |
| "treasure": [f"memory {i}" for i in range(claimed_count)], | |
| "claimed": [f"memory {i}" for i in range(claimed_count)], | |
| "turn": turn, | |
| "ended": ended, | |
| "tone": tone, | |
| "wounds": wounds if wounds is not None else [], | |
| } | |
| class TestDecideEnding: | |
| # --- good --- | |
| def test_good_at_90_with_3_claimed_and_warm_tone(self): | |
| assert decide_ending(_end_state(tone=30)) == "good" | |
| def test_good_exactly_at_tone_20(self): | |
| assert decide_ending(_end_state(tone=20)) == "good" | |
| def test_just_below_warmth_threshold_is_loop(self): | |
| assert decide_ending(_end_state(tone=19)) == "loop" | |
| # --- loop (fed it plenty, never loved it) --- | |
| def test_loop_when_gate_met_but_tone_flat(self): | |
| assert decide_ending(_end_state(tone=5)) == "loop" | |
| def test_loop_when_tone_mildly_negative(self): | |
| assert decide_ending(_end_state(tone=-10)) == "loop" | |
| # --- tester thresholds lowered for a short, judge-friendly run --- | |
| def test_tester_good_fires_at_lowered_affinity_35(self): | |
| assert decide_ending(_end_state(affinity=35, claimed_count=2, | |
| tone=30, turn=5)) == "good" | |
| def test_tester_good_just_below_35_does_not_fire(self): | |
| assert decide_ending(_end_state(affinity=34, claimed_count=2, | |
| tone=30, turn=5)) is None | |
| def test_tester_bad_fires_at_turn_3(self): | |
| assert decide_ending(_end_state(affinity=12, claimed_count=0, tone=-45, | |
| turn=3, wounds=["w1", "w2"])) == "bad" | |
| # --- dev seed forces a deterministic ending (HOLLOW_FAST_FINALE) --- | |
| def test_force_ending_overrides_the_tone_branch(self): | |
| # a flat-tone state would normally be "loop"; the dev force wins | |
| s = _end_state(tone=0) | |
| s["force_ending"] = "good" | |
| assert decide_ending(s) == "good" | |
| # --- bad --- | |
| def test_bad_on_sustained_cruelty(self): | |
| s = _end_state(affinity=12, claimed_count=0, tone=-45, turn=8, | |
| wounds=["w1", "w2"]) | |
| assert decide_ending(s) == "bad" | |
| def test_bad_needs_minimum_turns(self): | |
| s = _end_state(affinity=12, claimed_count=0, tone=-60, turn=2, | |
| wounds=["w1", "w2"]) | |
| assert decide_ending(s) is None # 2 < bad_min_turn (3) | |
| def test_bad_needs_two_wounds(self): | |
| s = _end_state(affinity=12, claimed_count=0, tone=-60, turn=8, | |
| wounds=["w1"]) | |
| assert decide_ending(s) is None | |
| def test_bad_wins_over_good_gate(self): | |
| # pathological but defined: cruelty fires first | |
| s = _end_state(affinity=95, claimed_count=3, tone=-50, turn=10, | |
| wounds=["w1", "w2"]) | |
| assert decide_ending(s) == "bad" | |
| # --- none --- | |
| def test_none_below_35(self): | |
| assert decide_ending(_end_state(affinity=34, claimed_count=5)) is None | |
| def test_none_with_fewer_than_2_claimed(self): | |
| assert decide_ending(_end_state(affinity=95, claimed_count=1)) is None | |
| def test_none_when_already_ended(self): | |
| assert decide_ending(_end_state(ended=True)) is None | |
| def test_old_state_without_tone_key_is_loop(self): | |
| s = _end_state() | |
| del s["tone"] | |
| del s["wounds"] | |
| assert decide_ending(s) == "loop" | |
| def test_fresh_session_does_not_fire(self): | |
| s = {"affinity": 20, "treasure": [], "claimed": [], "turn": 0, | |
| "ended": False, "tone": 0, "wounds": []} | |
| assert decide_ending(s) is None | |
| class TestApplyUpdateTone: | |
| def _state(self, tone=0, wounds=None): | |
| return {"affinity": 50, "treasure": [], "claimed": [], | |
| "tone": tone, "wounds": wounds if wounds is not None else []} | |
| def test_tone_accumulates(self): | |
| s = apply_update(self._state(tone=10), '{"affinity_delta": 0, "tone_delta": 5}') | |
| assert s["tone"] == 15 | |
| def test_tone_clamped_high(self): | |
| s = apply_update(self._state(tone=98), '{"affinity_delta": 0, "tone_delta": 9}') | |
| assert s["tone"] == 100 | |
| def test_tone_clamped_low(self): | |
| s = apply_update(self._state(tone=-95), '{"affinity_delta": 0, "tone_delta": -10}') | |
| assert s["tone"] == -100 | |
| def test_old_state_without_tone_key(self): | |
| s = {"affinity": 50, "treasure": [], "claimed": []} | |
| s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -3}') | |
| assert s["tone"] == -3 | |
| def test_broken_json_leaves_tone_unchanged(self): | |
| s = apply_update(self._state(tone=7), "not json at all") | |
| assert s["tone"] == 7 | |
| class TestApplyUpdateWounds: | |
| def _state(self, wounds=None): | |
| return {"affinity": 50, "treasure": [], "claimed": [], | |
| "tone": 0, "wounds": wounds if wounds is not None else []} | |
| def test_cruel_quote_stored(self): | |
| s = apply_update(self._state(), | |
| '{"affinity_delta": -4, "tone_delta": -7, "cruel_quote": "you bore me"}') | |
| assert s["wounds"] == ["you bore me"] | |
| def test_quote_ignored_on_non_negative_tone(self): | |
| s = apply_update(self._state(), | |
| '{"affinity_delta": 0, "tone_delta": 0, "cruel_quote": "hallucinated"}') | |
| assert s["wounds"] == [] | |
| def test_duplicate_quote_not_stored_twice(self): | |
| s = self._state(wounds=["you bore me"]) | |
| s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -5, "cruel_quote": "you bore me"}') | |
| assert s["wounds"] == ["you bore me"] | |
| def test_wounds_capped_at_10_keeping_newest(self): | |
| s = self._state(wounds=[f"insult {i}" for i in range(10)]) | |
| s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -5, "cruel_quote": "the newest"}') | |
| assert len(s["wounds"]) == 10 | |
| assert s["wounds"][-1] == "the newest" | |
| assert "insult 0" not in s["wounds"] | |
| class TestApplyUpdatePlusSignTolerance: | |
| def test_plus_signed_integers_parse(self): | |
| s = {"affinity": 50, "treasure": [], "claimed": [], "tone": 0, "wounds": []} | |
| s = apply_update(s, '{"affinity_delta": +6, "new_memories": ["x"], "tone_delta": +3}') | |
| assert s["affinity"] == 56 | |
| assert s["tone"] == 3 | |
| assert s["treasure"] == ["x"] | |
| def test_plus_inside_memory_text_untouched(self): | |
| s = {"affinity": 50, "treasure": [], "claimed": [], "tone": 0, "wounds": []} | |
| s = apply_update(s, '{"affinity_delta": 2, "new_memories": ["won 1st place at age 10"], "tone_delta": 0}') | |
| assert s["treasure"] == ["won 1st place at age 10"] | |
| class TestSingleGateBranchesByTone: | |
| def test_nothing_fires_below_35(self): | |
| # one gate at 35; no lower threshold can race a warm run | |
| assert decide_ending(_end_state(affinity=34, tone=5)) is None | |
| assert decide_ending(_end_state(affinity=34, tone=30)) is None | |
| def test_gate_at_35_branches_only_by_tone(self): | |
| assert decide_ending(_end_state(affinity=35, tone=20)) == "good" | |
| assert decide_ending(_end_state(affinity=35, tone=19)) == "loop" | |
| class TestHollowWordsFiltered: | |
| def _s(self): | |
| return {"affinity": 50, "treasure": [], "claimed": [], "tone": 0, "wounds": []} | |
| def test_drops_memory_that_echoes_hollows_reply(self): | |
| reply = "i remember a voice, soft like the wind through the trees." | |
| s = apply_update(self._s(), | |
| '{"affinity_delta": 3, "new_memories": ["a voice soft like the wind through the trees"]}', | |
| reply=reply) | |
| assert s["treasure"] == [] | |
| def test_keeps_genuine_visitor_memory(self): | |
| reply = "that sounds lovely. tell me more about her." | |
| s = apply_update(self._s(), | |
| '{"affinity_delta": 3, "new_memories": ["had a grandmother who kept bees"]}', | |
| reply=reply) | |
| assert s["treasure"] == ["had a grandmother who kept bees"] | |
| def test_no_reply_keeps_everything(self): | |
| s = apply_update(self._s(), | |
| '{"affinity_delta": 3, "new_memories": ["grew up near the sea"]}') | |
| assert s["treasure"] == ["grew up near the sea"] | |
| class TestStyleSignal: | |
| def test_short_messages_read_as_guarded(self): | |
| from memory import style_signal | |
| assert style_signal([3, 4, 2]) == "short" | |
| def test_long_messages_read_as_pouring_out(self): | |
| from memory import style_signal | |
| assert style_signal([40, 55, 60]) == "long" | |
| def test_mixed_or_empty_is_neutral(self): | |
| from memory import style_signal | |
| assert style_signal([]) is None | |
| assert style_signal([10, 12, 15]) is None | |
| class TestPickAwareMemory: | |
| def test_picks_an_unclaimed_memory_not_the_recall_one(self): | |
| from memory import pick_aware_memory | |
| s = {"treasure": ["a", "b", "c"], "claimed": ["a"], "last_aware_memory": None} | |
| assert pick_aware_memory(s, exclude="b") == "c" # not claimed(a), not excluded(b) | |
| def test_avoids_repeating_the_last_one(self): | |
| from memory import pick_aware_memory | |
| s = {"treasure": ["a", "b"], "claimed": [], "last_aware_memory": "b"} | |
| assert pick_aware_memory(s, exclude=None) == "a" | |
| def test_none_when_nothing_eligible(self): | |
| from memory import pick_aware_memory | |
| s = {"treasure": ["a"], "claimed": ["a"], "last_aware_memory": None} | |
| assert pick_aware_memory(s, exclude=None) is None | |
| def test_first_recall_fires_without_high_affinity(): | |
| from memory import should_recall | |
| state = {"affinity": 24, "treasure": ["grew up near the sea"], | |
| "claimed": [], "turn": 1, "last_recall_turn": None} | |
| do, mem = should_recall(state) | |
| assert do is True and mem == "grew up near the sea" # wow by turn ~2, low bond | |
| def test_recall_still_waits_one_turn_between(): | |
| from memory import should_recall | |
| state = {"affinity": 60, "treasure": ["a", "bb", "ccc"], "claimed": ["a"], | |
| "turn": 3, "last_recall_turn": 3} | |
| assert should_recall(state)[0] is False # same turn as last recall | |
| state["turn"] = 4 | |
| assert should_recall(state)[0] is True # 1 turn gap >= tester cooldown | |
| def test_good_ending_reachable_at_lower_affinity(): | |
| from memory import decide_ending | |
| s = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 25, | |
| "wounds": [], "ended": False} | |
| assert decide_ending(s) == "good" # warm, ~turn 6 -> redemption | |
| def test_loop_when_warmth_missing_at_gate(): | |
| from memory import decide_ending | |
| s = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 8, | |
| "wounds": [], "ended": False} | |
| assert decide_ending(s) == "loop" | |
| def test_bad_ending_fires_by_turn_four(): | |
| from memory import decide_ending | |
| s = {"affinity": 12, "claimed": [], "turn": 4, "tone": -32, | |
| "wounds": ["w1", "w2"], "ended": False} | |
| assert decide_ending(s) == "bad" | |
| def test_full_mode_recall_waits_for_two_memories(): | |
| from memory import should_recall | |
| one = {"affinity": 30, "treasure": ["a"], "claimed": [], "turn": 1, | |
| "last_recall_turn": None, "mode": "full"} | |
| assert should_recall(one)[0] is False # full needs >=2 before first recall | |
| two = dict(one, treasure=["a", "bb"]) | |
| assert should_recall(two)[0] is True | |
| def test_full_mode_ending_gate_is_higher(): | |
| from memory import decide_ending | |
| mid = {"affinity": 55, "claimed": ["a", "b"], "turn": 8, "tone": 25, | |
| "wounds": [], "ended": False, "mode": "full"} | |
| assert decide_ending(mid) is None # 55 < full gate 68 | |
| hi = dict(mid, affinity=70, claimed=["a", "b", "c"]) | |
| assert decide_ending(hi) == "good" | |
| def test_tester_mode_unchanged(): | |
| from memory import should_recall, decide_ending | |
| s = {"affinity": 24, "treasure": ["x"], "claimed": [], "turn": 1, | |
| "last_recall_turn": None, "mode": "tester"} | |
| assert should_recall(s)[0] is True # tester: first memory -> recall | |
| e = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 25, | |
| "wounds": [], "ended": False, "mode": "tester"} | |
| assert decide_ending(e) == "good" | |