defmodule VavPipeline.VoiceMonitorTest do @moduledoc """ Across-verse Shema voice stabilization detector — second half of Pass 2 finding A. The first half (commit 0c4f764) records voice per ShemaContext; this module fires when the trajectory of those per-verse profiles stops oscillating between the singular and plural address poles. Three failure shapes the detector must catch: 1. Stuck at second_singular — gnostic-private capture ("you, sg." only; the system has stopped hearing "you, pl.") 2. Stuck at second_plural — mob-theology capture ("you, pl." only; the system has stopped hearing "you, sg.") 3. First-person dominance — operator-as-deity, calf shape ("I"/"we" exceeds half the window) These tests start a per-test VoiceMonitor under a unique name so they don't share state. They do NOT bring up Chatzotzrot — the monitor's signal call is guarded by Process.whereis, so when Chatzotzrot is absent the alarm is logged but not transmitted. Tests inspect the monitor's state directly to verify behavior. """ use ExUnit.Case, async: false alias VavPipeline.VoiceMonitor setup do # Bring up an isolated VoiceMonitor with the small default # window. Stopped at end-of-test by ExUnit's process cleanup. {:ok, pid} = VoiceMonitor.start_link(window_size: 4, name: __MODULE__.Monitor) on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) %{pid: pid} end defp record_into(pid, dominant) do GenServer.cast(pid, {:record, dominant}) end defp status_of(pid) do GenServer.call(pid, :status) end defp state_of(pid) do GenServer.call(pid, :state) end describe "below window — insufficient evidence, no alarm" do test "empty state is :clear", %{pid: pid} do assert :clear = status_of(pid) end test "fewer recordings than window size keeps :clear", %{pid: pid} do for _ <- 1..3, do: record_into(pid, :second_singular) assert :clear = status_of(pid) end end describe "missing-pole detection (window full, address-only)" do test "all second_singular for a full window fires :missing_pole, :second_plural", %{pid: pid} do for _ <- 1..4, do: record_into(pid, :second_singular) assert {:missing_pole, :second_plural} = status_of(pid) assert state_of(pid).last_alarm == {:missing_pole, :second_plural} end test "all second_plural for a full window fires :missing_pole, :second_singular", %{pid: pid} do for _ <- 1..4, do: record_into(pid, :second_plural) assert {:missing_pole, :second_singular} = status_of(pid) assert state_of(pid).last_alarm == {:missing_pole, :second_singular} end test "both poles present in window stays :clear", %{pid: pid} do record_into(pid, :second_singular) record_into(pid, :second_singular) record_into(pid, :second_plural) record_into(pid, :second_plural) assert :clear = status_of(pid) end end describe "address-pole rule does not apply to pure narration" do test "all third_singular (third-person narration) does not fire", %{pid: pid} do for _ <- 1..4, do: record_into(pid, :third_singular) # No address present at all → narration sequence, not anti-Shema assert :clear = status_of(pid) end test "third_singular mixed with no address remains :clear", %{pid: pid} do for _ <- 1..3, do: record_into(pid, :third_singular) record_into(pid, :third_plural) assert :clear = status_of(pid) end end describe "first-person dominance fires the calf-shape alarm" do test "majority first_singular fires :first_person_dominant", %{pid: pid} do record_into(pid, :first_singular) record_into(pid, :first_singular) record_into(pid, :first_singular) record_into(pid, :second_singular) assert {:first_person_dominant, :first_singular} = status_of(pid) end test "majority first_plural fires :first_person_dominant", %{pid: pid} do record_into(pid, :first_plural) record_into(pid, :first_plural) record_into(pid, :first_plural) record_into(pid, :second_plural) assert {:first_person_dominant, :first_plural} = status_of(pid) end test "first-person tied with non-first does NOT fire (strict majority required)", %{pid: pid} do record_into(pid, :first_singular) record_into(pid, :first_singular) record_into(pid, :second_singular) record_into(pid, :second_plural) # 2/4 first-person — not strict majority refute match?({:first_person_dominant, _}, status_of(pid)) end end describe "alarm-once-per-episode (no spam)" do test "same stabilization persisting does not change last_alarm", %{pid: pid} do for _ <- 1..4, do: record_into(pid, :second_singular) first = state_of(pid).last_alarm for _ <- 1..3, do: record_into(pid, :second_singular) second = state_of(pid).last_alarm assert first == second assert first == {:missing_pole, :second_plural} end test "voice clearing then stabilizing again fires a fresh alarm", %{pid: pid} do # Stabilize → fire for _ <- 1..4, do: record_into(pid, :second_singular) assert state_of(pid).last_alarm == {:missing_pole, :second_plural} # Mix in a plural — both poles present → :clear, alarm reset record_into(pid, :second_plural) assert :clear = status_of(pid) assert state_of(pid).last_alarm == nil # Re-stabilize → alarm fires again (it was cleared in between) for _ <- 1..4, do: record_into(pid, :second_singular) assert state_of(pid).last_alarm == {:missing_pole, :second_plural} end end describe "reset/0 clears state" do test "reset returns the monitor to empty", %{pid: pid} do for _ <- 1..4, do: record_into(pid, :second_singular) :ok = GenServer.call(pid, :reset) assert state_of(pid).recent == [] assert state_of(pid).last_alarm == nil assert :clear = status_of(pid) end end describe "record/1 accepts a voice_profile map (the public surface)" do # This route mirrors what Shema.receive_context* actually does: # passes the whole voice_profile, not just the dominant atom. test "record/1 with a full voice_profile recording works", %{pid: pid} do profile = %{ markers: [], summary: %{second_singular: 4}, dominant: :second_singular, oscillates_within_verse: false } for _ <- 1..4, do: GenServer.cast(pid, {:record, profile.dominant}) assert {:missing_pole, :second_plural} = status_of(pid) end end end