import inspect import pytest from app.entities import ( fallback_profile, normalize_profile, parse_mint_response, roll_traits, signature_from_anomalies, ) from app.llm import prompts as llm_prompts TRAIT_KEYS = ("alignment", "power", "volatility", "deceptiveness") def _anomaly(freq, mag): return {"source": "radio", "frequency": freq, "magnitude": mag} def test_signature_needs_enough_anomalies(): assert signature_from_anomalies([_anomaly(101.1, 5.0)]) is None assert signature_from_anomalies([]) is None def test_signature_is_deterministic_for_same_pattern(): anomalies = [_anomaly(101.1 + i, 5.0 + i) for i in range(6)] assert signature_from_anomalies(anomalies) == signature_from_anomalies(list(anomalies)) def test_parse_mint_response_extracts_json(): raw = 'Sure! Here you go:\n{"name": "Vesper Wren", "epithet": "the Static Widow"}\nHope that helps' profile = parse_mint_response(raw) assert profile is not None assert profile["name"] == "Vesper Wren" def test_parse_mint_response_rejects_garbage(): assert parse_mint_response("no json here at all") is None assert parse_mint_response('{"epithet": "nameless"}') is None def test_normalize_profile_fills_and_clamps(): profile = normalize_profile( { "name": " Hollow Briar ", "rarity": "legendary", # not a real tier -> common "voice": {"voice_id": "nonexistent", "pitch": 99, "noise": -5}, "visual": {"form": "dragon", "hue": 9999}, "quotes": ["one", 2, "three"], }, "abcdef0123456789", ) assert profile["name"] == "Hollow Briar" assert profile["rarity"] == "common" assert profile["voice"]["voice_id"] != "nonexistent" # Widened from +-6 when voices moved to coherent archetypes: a child # and an elder man have to be able to land genuinely far apart. assert -9 <= profile["voice"]["pitch"] <= 9 assert 0.01 <= profile["voice"]["noise"] <= 0.08 assert profile["visual"]["form"] in ("wisp", "banshee", "fairy", "shade") assert 0 <= profile["visual"]["hue"] <= 360 assert profile["quotes"] == ["one", "three"] def test_fallback_profile_is_deterministic_and_valid(): one = fallback_profile("0123456789abcdef") two = fallback_profile("0123456789abcdef") assert one == two assert one["name"] assert one["rarity"] in ("common", "uncommon", "rare", "mythic") assert one["quotes"] # --- hidden traits (Workstream A) ------------------------------------- def test_roll_traits_values_are_in_range(): traits = roll_traits("some-signature-1") assert set(traits.keys()) == set(TRAIT_KEYS) for key in TRAIT_KEYS: assert isinstance(traits[key], float) assert 0.0 <= traits[key] <= 1.0 def test_roll_traits_is_deterministic_for_same_signature(): one = roll_traits("repeatable-signature") two = roll_traits("repeatable-signature") assert one == two def test_roll_traits_varies_by_signature(): values = {tuple(roll_traits(f"sig-{i}").values()) for i in range(20)} # 20 distinct signatures should not collapse onto a single roll. assert len(values) > 1 def test_normalize_profile_includes_traits_in_range(): profile = normalize_profile({"name": "Hollow Briar"}, "traits-norm-signature") traits = profile["traits"] assert set(traits.keys()) == set(TRAIT_KEYS) for key in TRAIT_KEYS: assert 0.0 <= traits[key] <= 1.0 def test_normalize_profile_traits_match_roll_traits_for_signature(): signature = "cross-check-signature" profile = normalize_profile({"name": "X"}, signature) assert profile["traits"] == roll_traits(signature) def test_normalize_profile_traits_are_signature_deterministic(): one = normalize_profile({"name": "A"}, "det-signature") two = normalize_profile({"name": "B", "rarity": "rare"}, "det-signature") # Same signature -> same traits every call, regardless of the rest of # the (LLM-supplied or fallback) profile passed in. assert one["traits"] == two["traits"] def test_fallback_profile_includes_traits_in_range(): profile = fallback_profile("fallback-traits-signature") traits = profile["traits"] assert set(traits.keys()) == set(TRAIT_KEYS) for key in TRAIT_KEYS: assert 0.0 <= traits[key] <= 1.0 def test_fallback_profile_traits_are_signature_deterministic(): one = fallback_profile("fallback-det-signature") two = fallback_profile("fallback-det-signature") assert one["traits"] == two["traits"] def test_persona_is_independent_of_rolled_traits(): """Persona text must stay fully decoupled from hidden truth: pairing the exact same persona string with many different signatures should surface a spread of alignment rolls, not a single value tied to the persona.""" fixed_persona = "A sweet old lady who just wants to chat about her garden." alignments = set() for i in range(40): profile = normalize_profile( {"name": "Nana", "persona": fixed_persona}, f"persona-independence-{i}" ) assert profile["persona"] == fixed_persona alignments.add(round(profile["traits"]["alignment"], 3)) # A meaningful spread (not clustered on one or two values) shows # alignment isn't derived from — or gated by — the persona text. assert len(alignments) > 20 def test_mint_prompt_never_receives_traits(): """`mint_prompt` builds the LLM system/user prompt; traits must never be one of its inputs, so hidden ground truth can't leak into persona generation.""" params = set(inspect.signature(llm_prompts.mint_prompt).parameters) assert "traits" not in params # `sky` is public, observable state (moon phase — anyone can look up), # so it may shape the persona. Hidden ground truth still must not. assert params == {"signature", "channel", "anomaly_summary", "voice_ids", "sky"} # --- voice archetypes ------------------------------------------------------- class TestVoiceArchetypes: """A voice must sound like a person, not like four random knobs. These assert the *covariance* that makes an archetype legible — a child being high AND fast AND clean — because that is exactly what independently-rolled parameters would destroy. """ def _throats(self, n=200, language="en"): import random as _r from app.entities import roll_voice return [roll_voice(_r.Random(i), language) for i in range(n)] def test_every_archetype_is_reachable(self): from app.entities import VOICE_ARCHETYPES seen = {t["archetype"] for t in self._throats(400)} assert seen == {a["key"] for a in VOICE_ARCHETYPES} def test_all_parameters_stay_inside_the_synthesis_limits(self): for t in self._throats(300): assert -9 <= t["pitch"] <= 9 assert 0.75 <= t["rate"] <= 1.20 assert 0.01 <= t["noise"] <= 0.10 assert 0.0 <= t["echo"] <= 0.5 def test_only_installed_voices_are_chosen(self): from app.tts.voices import VOICES for t in self._throats(300): assert t["voice_id"] in VOICES def test_spanish_spirits_get_spanish_models(self): # An English model reading Spanish text mangles it; better to carry # the difference in shaping alone. from app.tts.voices import ES_VOICE_IDS for t in self._throats(120, language="es"): assert t["voice_id"] in ES_VOICE_IDS def test_children_are_higher_and_faster_than_elders(self): throats = self._throats(600) child = [t for t in throats if t["archetype"] == "child"] elder = [t for t in throats if t["archetype"] == "elder_man"] assert child and elder assert min(t["pitch"] for t in child) > max(t["pitch"] for t in elder) assert min(t["rate"] for t in child) > max(t["rate"] for t in elder) def test_damaged_throats_are_noisier_than_clean_ones(self): throats = self._throats(600) burned = [t for t in throats if t["archetype"] == "burned"] child = [t for t in throats if t["archetype"] == "child"] assert burned and child assert min(t["noise"] for t in burned) > max(t["noise"] for t in child) def test_the_distant_archetype_is_the_most_reverberant(self): throats = self._throats(600) distant = [t for t in throats if t["archetype"] == "distant"] young = [t for t in throats if t["archetype"] == "young_man"] assert distant and young assert min(t["echo"] for t in distant) > max(t["echo"] for t in young) def test_two_spirits_rarely_share_a_throat(self): # The whole point: distinguishable people, not a smear of one voice. throats = self._throats(120) fingerprints = { (t["voice_id"], round(t["pitch"], 1), round(t["rate"], 2)) for t in throats } assert len(fingerprints) > 100 def test_normalize_profile_reports_the_archetype(self): from app.entities import normalize_profile profile = normalize_profile({"name": "Test"}, "sig-archetype") assert profile["voice"]["archetype"] # --- moon influence on summoning -------------------------------------------- class TestMoonInfluence: """The moon must change *who answers*, not just decorate the UI.""" def test_dark_sky_leaves_the_base_table_alone(self): from app.entities import BASE_RARITY_WEIGHTS, rarity_weights_for_moon assert rarity_weights_for_moon(0.0) == pytest.approx(BASE_RARITY_WEIGHTS) def test_unknown_sky_falls_back_to_the_base_table(self): from app.entities import BASE_RARITY_WEIGHTS, rarity_weights_for_moon assert rarity_weights_for_moon(None) == BASE_RARITY_WEIGHTS def test_a_full_moon_makes_rare_and_mythic_much_likelier(self): from app.entities import rarity_weights_for_moon dark = rarity_weights_for_moon(0.0) full = rarity_weights_for_moon(1.0) assert full[2] > dark[2] * 2 # rare assert full[3] > dark[3] * 2 # mythic assert full[0] < dark[0] # common recedes def test_every_tier_stays_reachable_on_every_night(self): # A skew, never a gate — someone who can only play midweek must not # be locked out of the good spirits. from app.entities import rarity_weights_for_moon for lit in (0.0, 0.25, 0.5, 0.75, 1.0): assert all(w > 0 for w in rarity_weights_for_moon(lit)) def test_rarity_odds_actually_shift_in_practice(self): import random as _r from app.entities import rarity_weights_for_moon from app.models.entity import RARITY_TIERS def mythic_rate(lit): rng = _r.Random(1234) weights = rarity_weights_for_moon(lit) draws = [rng.choices(RARITY_TIERS, weights=weights)[0] for _ in range(4000)] return draws.count("mythic") / len(draws) assert mythic_rate(1.0) > mythic_rate(0.0) * 1.8 def test_moonlight_raises_power_and_volatility(self): from app.entities import moon_trait_bias base = {"alignment": 0.5, "power": 0.5, "volatility": 0.5, "deceptiveness": 0.5} full = moon_trait_bias(base, 1.0) assert full["power"] > base["power"] assert full["volatility"] > base["volatility"] assert full["alignment"] < base["alignment"] def test_bias_is_a_thumb_on_the_scale_not_a_rewrite(self): from app.entities import moon_trait_bias base = {"alignment": 0.5, "power": 0.5, "volatility": 0.5, "deceptiveness": 0.5} full = moon_trait_bias(base, 1.0) for key in base: assert abs(full[key] - base[key]) <= 0.12 def test_bias_never_escapes_the_unit_range(self): from app.entities import moon_trait_bias for extreme in (0.0, 1.0): base = dict.fromkeys( ("alignment", "power", "volatility", "deceptiveness"), extreme ) for lit in (0.0, 0.5, 1.0): for v in moon_trait_bias(base, lit).values(): assert 0.0 <= v <= 1.0 def test_unknown_sky_leaves_traits_untouched(self): from app.entities import moon_trait_bias base = {"alignment": 0.3, "power": 0.7, "volatility": 0.2, "deceptiveness": 0.9} assert moon_trait_bias(base, None) == base def test_deceptiveness_is_not_moon_driven(self): # Whether a spirit lies is its own nature, not the sky's doing. from app.entities import moon_trait_bias base = {"alignment": 0.5, "power": 0.5, "volatility": 0.5, "deceptiveness": 0.5} assert moon_trait_bias(base, 1.0)["deceptiveness"] == 0.5 def test_fallback_profiles_respect_the_moon(self): from app.entities import fallback_profile dark = [fallback_profile(f"s{i}", None, 0.0)["rarity"] for i in range(300)] full = [fallback_profile(f"s{i}", None, 1.0)["rarity"] for i in range(300)] rare_dark = sum(r in ("rare", "mythic") for r in dark) rare_full = sum(r in ("rare", "mythic") for r in full) assert rare_full > rare_dark