"""Tests for ratatoskr.voices — config load + dialogue/narration segmentation (DEC-11).""" import json from typing import ClassVar from ratatoskr.voices import ( DEFAULT_VOICE_CONFIG, load_voice_config, resolve_voice_spans, segment_dialogue, ) class TestSegmentDialogue: def test_narration_only_no_quotes(self) -> None: assert segment_dialogue("She tilts her head.") == [("narration", "She tilts her head.")] def test_straight_quotes_dialogue(self) -> None: assert segment_dialogue('"Hi there."') == [("dialogue", "Hi there.")] def test_curly_quotes_dialogue(self) -> None: assert segment_dialogue("“Hi there.”") == [("dialogue", "Hi there.")] def test_mixed_narration_and_dialogue_order_preserved(self) -> None: text = "She smiles. “Hello.” She left." assert segment_dialogue(text) == [ ("narration", "She smiles. "), ("dialogue", "Hello."), ("narration", " She left."), ] def test_empty_and_whitespace_spans_dropped(self) -> None: # The between-quotes gap here is a single space → dropped; leading empty narration too. assert segment_dialogue('"A" "B"') == [("dialogue", "A"), ("dialogue", "B")] def test_apostrophe_is_not_a_boundary(self) -> None: # Curly and straight apostrophes stay inside the span (no word split). assert segment_dialogue("Donut’s crown and Carl's cat") == [ # noqa: RUF001 ("narration", "Donut’s crown and Carl's cat") # noqa: RUF001 ] def test_unbalanced_trailing_open_quote_is_dialogue(self) -> None: assert segment_dialogue('She said "hello') == [ ("narration", "She said "), ("dialogue", "hello"), ] def test_empty_text(self) -> None: assert segment_dialogue("") == [] class TestResolveVoiceSpans: CFG: ClassVar[dict] = { "default": "glados", "agents": { "ratatoskr:donut": {"voice": "donut"}, "ratatoskr:sindra": {"voice": "miranda", "narration_voice": "emmie"}, }, } def test_unmapped_agent_single_default_voice(self) -> None: assert resolve_voice_spans(self.CFG, "mimir", "hello") == [("glados", "hello")] def test_non_str_agent_id_single_default(self) -> None: assert resolve_voice_spans(self.CFG, ["donut"], "hi") == [("glados", "hi")] def test_mapped_no_narration_single_voice(self) -> None: # Donut is dialogue-only (no narration_voice) → whole turn in one voice, no split, # even though the text has no quotes. assert resolve_voice_spans(self.CFG, "ratatoskr:donut", "The crown is mine.") == [ ("donut", "The crown is mine.") ] def test_mapped_with_narration_splits(self) -> None: spans = resolve_voice_spans( self.CFG, "ratatoskr:sindra", "She smiles. “Hello.”" ) assert spans == [("emmie", "She smiles. "), ("miranda", "Hello.")] def test_mapped_with_narration_all_dialogue(self) -> None: assert resolve_voice_spans(self.CFG, "ratatoskr:sindra", '"Only speech here."') == [ ("miranda", "Only speech here.") ] def test_segmentation_empty_falls_back_to_single_span(self) -> None: # Text that segments to nothing (only quote chars) still yields one span, never []. out = resolve_voice_spans(self.CFG, "ratatoskr:sindra", '""') assert len(out) == 1 and out[0][0] == "miranda" class TestLoadVoiceConfig: def test_absent_file_returns_builtin_default(self, tmp_path) -> None: cfg = load_voice_config(str(tmp_path / "nope.json")) assert cfg == DEFAULT_VOICE_CONFIG assert cfg is not DEFAULT_VOICE_CONFIG # a copy, not the shared constant def test_valid_file_loaded(self, tmp_path) -> None: p = tmp_path / "voices.json" p.write_text(json.dumps({"default": "glados", "agents": {"x:y": {"voice": "emmie"}}})) cfg = load_voice_config(str(p)) assert cfg["agents"]["x:y"]["voice"] == "emmie" def test_malformed_json_falls_back(self, tmp_path) -> None: p = tmp_path / "voices.json" p.write_text("{not json") assert load_voice_config(str(p)) == DEFAULT_VOICE_CONFIG def test_missing_agents_mapping_falls_back(self, tmp_path) -> None: p = tmp_path / "voices.json" p.write_text(json.dumps({"default": "glados"})) assert load_voice_config(str(p)) == DEFAULT_VOICE_CONFIG def test_default_key_backfilled(self, tmp_path) -> None: p = tmp_path / "voices.json" p.write_text(json.dumps({"agents": {"x:y": {"voice": "emmie"}}})) assert load_voice_config(str(p))["default"] == "glados"