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"