"""test_wake_word.py — Offline tests for wake_word_node (Issue #320).

Stubs out rclpy and ROS message types so tests run without a ROS install.
numpy is required (standard on the Jetson).
"""

import importlib.util
import math
import struct
import sys
import time
import types
import unittest

import numpy as np


# ── ROS2 stubs ────────────────────────────────────────────────────────────────

def _make_ros_stubs():
    for mod_name in ("rclpy", "rclpy.node", "rclpy.qos",
                     "std_msgs", "std_msgs.msg"):
        if mod_name not in sys.modules:
            sys.modules[mod_name] = types.ModuleType(mod_name)

    class _Node:
        def __init__(self, name="node"):
            self._name = name
            if not hasattr(self, "_params"):
                self._params = {}
            self._pubs   = {}
            self._subs   = {}
            self._logs   = []
            self._timers = []

        def declare_parameter(self, name, default):
            if name not in self._params:
                self._params[name] = default

        def get_parameter(self, name):
            class _P:
                def __init__(self, v): self.value = v
            return _P(self._params.get(name))

        def create_publisher(self, msg_type, topic, qos):
            pub = _FakePub()
            self._pubs[topic] = pub
            return pub

        def create_subscription(self, msg_type, topic, cb, qos):
            self._subs[topic] = cb
            return object()

        def create_timer(self, period, cb):
            self._timers.append(cb)
            return object()

        def get_logger(self):
            node = self
            class _L:
                def info(self, m):  node._logs.append(("INFO",  m))
                def warn(self, m):  node._logs.append(("WARN",  m))
                def error(self, m): node._logs.append(("ERROR", m))
            return _L()

        def destroy_node(self): pass

    class _FakePub:
        def __init__(self):
            self.msgs = []
        def publish(self, msg):
            self.msgs.append(msg)

    class _QoSProfile:
        def __init__(self, depth=10): self.depth = depth

    class _Bool:
        def __init__(self): self.data = False

    class _UInt8MultiArray:
        def __init__(self): self.data = b""

    rclpy_mod = sys.modules["rclpy"]
    rclpy_mod.init     = lambda args=None: None
    rclpy_mod.spin     = lambda node: None
    rclpy_mod.shutdown = lambda: None

    sys.modules["rclpy.node"].Node          = _Node
    sys.modules["rclpy.qos"].QoSProfile     = _QoSProfile
    sys.modules["std_msgs.msg"].Bool         = _Bool
    sys.modules["std_msgs.msg"].UInt8MultiArray = _UInt8MultiArray

    return _Node, _FakePub, _Bool, _UInt8MultiArray


_Node, _FakePub, _Bool, _UInt8MultiArray = _make_ros_stubs()


# ── Module loader ─────────────────────────────────────────────────────────────

_SRC = (
    "/Users/seb/AI/saltylab-firmware/jetson/ros2_ws/src/"
    "saltybot_social/saltybot_social/wake_word_node.py"
)


def _load_mod():
    spec = importlib.util.spec_from_file_location("wake_word_testmod", _SRC)
    mod  = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _make_node(mod, **kwargs):
    node = mod.WakeWordNode.__new__(mod.WakeWordNode)
    defaults = {
        "audio_topic":      "/social/speech/audio_raw",
        "output_topic":     "/saltybot/wake_word_detected",
        "sample_rate":      16000,
        "window_s":         1.5,
        "hop_s":            0.1,
        "energy_threshold": 0.02,
        "match_threshold":  0.82,
        "cooldown_s":       2.0,
        "template_path":    "",
        "n_fft":            512,
        "n_mels":           40,
    }
    defaults.update(kwargs)
    node._params = dict(defaults)
    mod.WakeWordNode.__init__(node)
    return node


def _make_pcm_bytes(samples: np.ndarray) -> bytes:
    """Encode float32 array [-1,1] to PCM-16 LE bytes."""
    ints = np.clip(samples * 32768.0, -32768, 32767).astype(np.int16)
    return ints.tobytes()


def _make_audio_msg(samples: np.ndarray) -> _UInt8MultiArray:
    m = _UInt8MultiArray()
    m.data = _make_pcm_bytes(samples)
    return m


def _sine(freq: float, duration: float, sr: int = 16000,
          amp: float = 0.5) -> np.ndarray:
    """Generate a mono sine wave."""
    t = np.arange(int(sr * duration)) / sr
    return (amp * np.sin(2 * math.pi * freq * t)).astype(np.float32)


def _silence(duration: float, sr: int = 16000) -> np.ndarray:
    return np.zeros(int(sr * duration), dtype=np.float32)


def _make_template(mod, sr: int = 16000, n_fft: int = 512,
                   n_mels: int = 40) -> np.ndarray:
    """Compute a template from a synthetic 'wake word' signal for testing."""
    signal = _sine(300, 1.5, sr, amp=0.6)
    hop = n_fft // 2
    return mod.compute_log_mel(signal, sr, n_fft, n_mels, hop)


# ── Tests: pcm16_to_float ─────────────────────────────────────────────────────

class TestPcm16ToFloat(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def _conv(self, samples):
        raw = _make_pcm_bytes(np.array(samples, dtype=np.float32))
        return self.mod.pcm16_to_float(raw)

    def test_zeros(self):
        out = self._conv([0.0, 0.0, 0.0])
        np.testing.assert_allclose(out, [0.0, 0.0, 0.0], atol=1e-4)

    def test_positive(self):
        out = self._conv([0.5])
        self.assertAlmostEqual(float(out[0]), 0.5, places=3)

    def test_negative(self):
        out = self._conv([-0.5])
        self.assertAlmostEqual(float(out[0]), -0.5, places=3)

    def test_roundtrip_length(self):
        arr = np.linspace(-1.0, 1.0, 100, dtype=np.float32)
        raw = _make_pcm_bytes(arr)
        out = self.mod.pcm16_to_float(raw)
        self.assertEqual(len(out), 100)

    def test_empty_bytes_returns_empty(self):
        out = self.mod.pcm16_to_float(b"")
        self.assertEqual(len(out), 0)

    def test_odd_byte_ignored(self):
        # 3 bytes → 1 complete int16 + 1 orphan byte
        out = self.mod.pcm16_to_float(b"\x00\x40\xff")
        self.assertEqual(len(out), 1)


# ── Tests: rms ────────────────────────────────────────────────────────────────

class TestRms(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def test_zero_signal(self):
        self.assertAlmostEqual(self.mod.rms(np.zeros(100)), 0.0)

    def test_constant_signal(self):
        # RMS of constant 0.5 = 0.5
        self.assertAlmostEqual(self.mod.rms(np.full(100, 0.5)), 0.5, places=5)

    def test_sine_rms(self):
        # RMS of sin = amp / sqrt(2)
        amp = 0.8
        s = _sine(440, 1.0, amp=amp)
        expected = amp / math.sqrt(2)
        self.assertAlmostEqual(self.mod.rms(s), expected, places=2)

    def test_empty_array(self):
        self.assertEqual(self.mod.rms(np.array([])), 0.0)


# ── Tests: mel_filterbank ─────────────────────────────────────────────────────

class TestMelFilterbank(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def test_shape(self):
        fb = self.mod.mel_filterbank(16000, 512, 40)
        self.assertEqual(fb.shape, (40, 257))

    def test_non_negative(self):
        fb = self.mod.mel_filterbank(16000, 512, 40)
        self.assertTrue((fb >= 0).all())

    def test_rows_sum_positive(self):
        fb = self.mod.mel_filterbank(16000, 512, 40)
        self.assertTrue((fb.sum(axis=1) > 0).all())

    def test_custom_n_mels(self):
        fb = self.mod.mel_filterbank(16000, 256, 20)
        self.assertEqual(fb.shape[0], 20)


# ── Tests: compute_log_mel ────────────────────────────────────────────────────

class TestComputeLogMel(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def test_output_shape_rows(self):
        s = _sine(440, 1.5)
        out = self.mod.compute_log_mel(s, 16000, n_fft=512, n_mels=40, hop=256)
        self.assertEqual(out.shape[0], 40)

    def test_output_has_time_axis(self):
        s = _sine(440, 1.5)
        out = self.mod.compute_log_mel(s, 16000, n_fft=512, n_mels=40, hop=256)
        self.assertGreater(out.shape[1], 0)

    def test_output_finite(self):
        s = _sine(440, 1.5)
        out = self.mod.compute_log_mel(s, 16000, n_fft=512, n_mels=40, hop=256)
        self.assertTrue(np.isfinite(out).all())

    def test_silence_gives_low_values(self):
        s = _silence(1.5)
        out = self.mod.compute_log_mel(s, 16000, n_fft=512, n_mels=40, hop=256)
        # All values should be very small (near log(1e-10))
        self.assertTrue((out < -20).all())

    def test_short_signal_no_crash(self):
        # Shorter than one FFT frame
        s = np.zeros(100, dtype=np.float32)
        out = self.mod.compute_log_mel(s, 16000, n_fft=512, n_mels=40, hop=256)
        self.assertEqual(out.shape[0], 40)


# ── Tests: cosine_sim ─────────────────────────────────────────────────────────

class TestCosineSim(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def test_identical_vectors(self):
        v = np.array([1.0, 2.0, 3.0])
        self.assertAlmostEqual(self.mod.cosine_sim(v, v), 1.0, places=5)

    def test_orthogonal_vectors(self):
        a = np.array([1.0, 0.0])
        b = np.array([0.0, 1.0])
        self.assertAlmostEqual(self.mod.cosine_sim(a, b), 0.0, places=5)

    def test_opposite_vectors(self):
        v = np.array([1.0, 2.0, 3.0])
        self.assertAlmostEqual(self.mod.cosine_sim(v, -v), -1.0, places=5)

    def test_zero_vector_returns_zero(self):
        a = np.zeros(5)
        b = np.ones(5)
        self.assertEqual(self.mod.cosine_sim(a, b), 0.0)

    def test_2d_arrays(self):
        a = np.ones((4, 10))
        b = np.ones((4, 10))
        self.assertAlmostEqual(self.mod.cosine_sim(a, b), 1.0, places=5)

    def test_different_lengths_truncated(self):
        a = np.array([1.0, 2.0, 3.0, 4.0])
        b = np.array([1.0, 2.0, 3.0])
        # Should not crash, uses min length
        result = self.mod.cosine_sim(a, b)
        self.assertTrue(-1.0 <= result <= 1.0)

    def test_range_is_bounded(self):
        rng = np.random.default_rng(42)
        a = rng.standard_normal(100)
        b = rng.standard_normal(100)
        result = self.mod.cosine_sim(a, b)
        self.assertGreaterEqual(result, -1.0)
        self.assertLessEqual(result,     1.0)


# ── Tests: AudioRingBuffer ────────────────────────────────────────────────────

class TestAudioRingBuffer(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def _buf(self, max_samples=1000):
        return self.mod.AudioRingBuffer(max_samples)

    def test_empty_initially(self):
        b = self._buf()
        self.assertEqual(len(b), 0)

    def test_push_increases_len(self):
        b = self._buf()
        b.push(np.ones(100, dtype=np.float32))
        self.assertEqual(len(b), 100)

    def test_get_window_none_when_short(self):
        b = self._buf()
        b.push(np.ones(50, dtype=np.float32))
        self.assertIsNone(b.get_window(100))

    def test_get_window_ok_when_full(self):
        b = self._buf()
        data = np.arange(200, dtype=np.float32)
        b.push(data)
        w = b.get_window(100)
        self.assertIsNotNone(w)
        self.assertEqual(len(w), 100)

    def test_get_window_returns_latest(self):
        b = self._buf()
        b.push(np.zeros(100, dtype=np.float32))
        b.push(np.ones(100, dtype=np.float32))
        w = b.get_window(100)
        np.testing.assert_allclose(w, np.ones(100))

    def test_maxlen_evicts_oldest(self):
        b = self._buf(max_samples=100)
        b.push(np.zeros(60, dtype=np.float32))
        b.push(np.ones(60, dtype=np.float32))   # should evict 20 zeros
        self.assertEqual(len(b), 100)
        w = b.get_window(100)
        # Last 40 samples should be ones
        np.testing.assert_allclose(w[-40:], np.ones(40))

    def test_exact_window_size(self):
        b = self._buf(500)
        data = np.arange(300, dtype=np.float32)
        b.push(data)
        w = b.get_window(300)
        np.testing.assert_allclose(w, data)


# ── Tests: WakeWordDetector ───────────────────────────────────────────────────

class TestWakeWordDetector(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.mod = _load_mod()
        cls.sr = 16000
        cls.template = _make_template(cls.mod, cls.sr)

    def _det(self, template=None, energy_thr=0.02, match_thr=0.82):
        return self.mod.WakeWordDetector(template, energy_thr, match_thr,
                                         self.sr, 512, 40)

    def test_no_template_never_detects(self):
        det = self._det(template=None)
        loud = _sine(300, 1.5, self.sr, amp=0.8)
        detected, _, _ = det.detect(loud)
        self.assertFalse(detected)

    def test_has_template_false_when_no_template(self):
        det = self._det(template=None)
        self.assertFalse(det.has_template)

    def test_has_template_true_when_set(self):
        det = self._det(template=self.template)
        self.assertTrue(det.has_template)

    def test_silence_below_energy_gate(self):
        det = self._det(template=self.template, energy_thr=0.02)
        silence = _silence(1.5, self.sr)
        detected, rms_val, sim = det.detect(silence)
        self.assertFalse(detected)
        self.assertAlmostEqual(rms_val, 0.0, places=4)
        self.assertAlmostEqual(sim, 0.0, places=4)

    def test_returns_rms_value(self):
        det = self._det(template=None)
        s = _sine(300, 1.5, self.sr, amp=0.5)
        _, rms_val, _ = det.detect(s)
        expected = 0.5 / math.sqrt(2)
        self.assertAlmostEqual(rms_val, expected, places=2)

    def test_identical_signal_detects(self):
        """Signal identical to template should give sim ≈ 1.0 → detect."""
        signal = _sine(300, 1.5, self.sr, amp=0.6)
        det = self._det(template=self.template, match_thr=0.99)
        detected, _, sim = det.detect(signal)
        # Sim must be very high for an identical-source signal
        self.assertGreater(sim, 0.99)
        self.assertTrue(detected)

    def test_different_signal_low_sim(self):
        """A very different signal (white noise) should have low similarity."""
        rng = np.random.default_rng(7)
        noise = (rng.standard_normal(int(self.sr * 1.5)) * 0.4).astype(np.float32)
        det = self._det(template=self.template, match_thr=0.82)
        _, _, sim = det.detect(noise)
        # White noise sim to a tonal template should be < 0.6
        self.assertLess(sim, 0.6)

    def test_threshold_boundary_low(self):
        """Setting match_thr=0.0 with a loud signal should fire if template set."""
        signal = _sine(300, 1.5, self.sr, amp=0.6)
        det = self._det(template=self.template, match_thr=0.0)
        detected, _, _ = det.detect(signal)
        self.assertTrue(detected)

    def test_threshold_boundary_high(self):
        """Setting match_thr=1.1 (above max) should never fire."""
        signal = _sine(300, 1.5, self.sr, amp=0.6)
        det = self._det(template=self.template, match_thr=1.1)
        detected, _, _ = det.detect(signal)
        self.assertFalse(detected)

    def test_energy_below_threshold_skips_matching(self):
        """Low energy → sim returned as 0.0 regardless of template."""
        very_quiet = _sine(300, 1.5, self.sr, amp=0.001)
        det = self._det(template=self.template, energy_thr=0.1)
        detected, rms_val, sim = det.detect(very_quiet)
        self.assertFalse(detected)
        self.assertAlmostEqual(sim, 0.0, places=5)


# ── Tests: node init ──────────────────────────────────────────────────────────

class TestNodeInit(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def test_instantiates(self):
        self.assertIsNotNone(_make_node(self.mod))

    def test_pub_registered(self):
        node = _make_node(self.mod)
        self.assertIn("/saltybot/wake_word_detected", node._pubs)

    def test_sub_registered(self):
        node = _make_node(self.mod)
        self.assertIn("/social/speech/audio_raw", node._subs)

    def test_timer_registered(self):
        node = _make_node(self.mod)
        self.assertGreater(len(node._timers), 0)

    def test_custom_topics(self):
        node = _make_node(self.mod,
                          audio_topic="/my/audio",
                          output_topic="/my/wake")
        self.assertIn("/my/audio", node._subs)
        self.assertIn("/my/wake",  node._pubs)

    def test_no_template_passive(self):
        node = _make_node(self.mod, template_path="")
        self.assertFalse(node._detector.has_template)

    def test_bad_template_path_warns(self):
        node = _make_node(self.mod, template_path="/nonexistent/template.npy")
        warns = [m for lvl, m in node._logs if lvl == "WARN"]
        self.assertTrue(any("template" in m.lower() or "passive" in m.lower()
                            for m in warns))

    def test_ring_buffer_allocated(self):
        node = _make_node(self.mod)
        self.assertIsNotNone(node._buf)

    def test_window_n_computed(self):
        node = _make_node(self.mod, sample_rate=16000, window_s=1.5)
        self.assertEqual(node._win_n, 24000)


# ── Tests: _on_audio callback ─────────────────────────────────────────────────

class TestOnAudio(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def setUp(self):
        self.node = _make_node(self.mod)

    def _push(self, samples):
        msg = _make_audio_msg(samples)
        self.node._subs["/social/speech/audio_raw"](msg)

    def test_pushes_samples_to_buffer(self):
        self._push(_sine(440, 0.5))
        self.assertGreater(len(self.node._buf), 0)

    def test_buffer_grows_with_pushes(self):
        chunk = _sine(440, 0.1)
        before = len(self.node._buf)
        self._push(chunk)
        after  = len(self.node._buf)
        self.assertGreater(after, before)

    def test_bad_data_no_crash(self):
        msg = _UInt8MultiArray()
        msg.data = b"\xff"   # 1 orphan byte — yields 0 samples, no crash
        self.node._subs["/social/speech/audio_raw"](msg)

    def test_multiple_chunks_accumulate(self):
        for _ in range(5):
            self._push(_sine(440, 0.1))
        self.assertGreater(len(self.node._buf), 0)


# ── Tests: detection callback ─────────────────────────────────────────────────

class TestDetectionCallback(unittest.TestCase):
    @classmethod
    def setUpClass(cls): cls.mod = _load_mod()

    def _node_with_template(self, **kwargs):
        template = _make_template(self.mod)
        node = _make_node(self.mod, **kwargs)
        node._detector = self.mod.WakeWordDetector(
            template, energy_threshold=0.02, match_threshold=0.0,   # thr=0 → always fires when loud
            sample_rate=16000, n_fft=512, n_mels=40
        )
        return node

    def _fill_buffer(self, node, signal):
        msg = _make_audio_msg(signal)
        node._subs["/social/speech/audio_raw"](msg)

    def test_no_data_no_publish(self):
        node = _make_node(self.mod)
        node._detection_cb()
        self.assertEqual(len(node._pubs["/saltybot/wake_word_detected"].msgs), 0)

    def test_insufficient_buffer_no_publish(self):
        node = self._node_with_template()
        # Push only 0.1 s but window is 1.5 s
        self._fill_buffer(node, _sine(300, 0.1))
        node._detection_cb()
        self.assertEqual(len(node._pubs["/saltybot/wake_word_detected"].msgs), 0)

    def test_detects_and_publishes_true(self):
        node = self._node_with_template()
        # Fill with a loud 300 Hz sine (matches template)
        self._fill_buffer(node, _sine(300, 1.5, amp=0.6))
        node._detection_cb()
        pub = node._pubs["/saltybot/wake_word_detected"]
        self.assertEqual(len(pub.msgs), 1)
        self.assertTrue(pub.msgs[0].data)

    def test_cooldown_suppresses_second_detection(self):
        node = self._node_with_template(cooldown_s=60.0)
        self._fill_buffer(node, _sine(300, 1.5, amp=0.6))
        node._detection_cb()
        # Second call immediately → cooldown active
        self._fill_buffer(node, _sine(300, 1.5, amp=0.6))
        node._detection_cb()
        pub = node._pubs["/saltybot/wake_word_detected"]
        self.assertEqual(len(pub.msgs), 1)

    def test_cooldown_expired_allows_second(self):
        node = self._node_with_template(cooldown_s=0.0)
        self._fill_buffer(node, _sine(300, 1.5, amp=0.6))
        node._detection_cb()
        self._fill_buffer(node, _sine(300, 1.5, amp=0.6))
        node._detection_cb()
        pub = node._pubs["/saltybot/wake_word_detected"]
        self.assertEqual(len(pub.msgs), 2)

    def test_no_template_never_publishes(self):
        node = _make_node(self.mod, template_path="")
        self._fill_buffer(node, _sine(300, 1.5, amp=0.8))
        node._detection_cb()
        pub = node._pubs["/saltybot/wake_word_detected"]
        self.assertEqual(len(pub.msgs), 0)

    def test_silence_no_publish(self):
        node = self._node_with_template()
        self._fill_buffer(node, _silence(1.5))
        node._detection_cb()
        pub = node._pubs["/saltybot/wake_word_detected"]
        self.assertEqual(len(pub.msgs), 0)

    def test_detection_logs_info(self):
        node = self._node_with_template()
        self._fill_buffer(node, _sine(300, 1.5, amp=0.6))
        node._detection_cb()
        infos = [m for lvl, m in node._logs if lvl == "INFO"]
        self.assertTrue(any("detected" in m.lower() or "hey salty" in m.lower()
                            for m in infos))


# ── Tests: source content ─────────────────────────────────────────────────────

class TestNodeSrc(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        with open(_SRC) as f: cls.src = f.read()

    def test_issue_tag(self):            self.assertIn("#320", self.src)
    def test_audio_topic(self):          self.assertIn("/social/speech/audio_raw", self.src)
    def test_output_topic(self):         self.assertIn("/saltybot/wake_word_detected", self.src)
    def test_wake_word_name(self):       self.assertIn("hey salty", self.src)
    def test_compute_log_mel(self):      self.assertIn("compute_log_mel", self.src)
    def test_cosine_sim(self):           self.assertIn("cosine_sim", self.src)
    def test_mel_filterbank(self):       self.assertIn("mel_filterbank", self.src)
    def test_audio_ring_buffer(self):    self.assertIn("AudioRingBuffer", self.src)
    def test_wake_word_detector(self):   self.assertIn("WakeWordDetector", self.src)
    def test_energy_threshold(self):     self.assertIn("energy_threshold", self.src)
    def test_match_threshold(self):      self.assertIn("match_threshold", self.src)
    def test_cooldown(self):             self.assertIn("cooldown_s", self.src)
    def test_template_path(self):        self.assertIn("template_path", self.src)
    def test_pcm16_decode(self):         self.assertIn("pcm16_to_float", self.src)
    def test_threading_lock(self):       self.assertIn("threading.Lock", self.src)
    def test_numpy_used(self):           self.assertIn("import numpy", self.src)
    def test_main_defined(self):         self.assertIn("def main", self.src)
    def test_uint8_multiarray(self):     self.assertIn("UInt8MultiArray", self.src)


# ── Tests: config / launch / setup ────────────────────────────────────────────

class TestConfig(unittest.TestCase):
    _CONFIG = (
        "/Users/seb/AI/saltylab-firmware/jetson/ros2_ws/src/"
        "saltybot_social/config/wake_word_params.yaml"
    )
    _LAUNCH = (
        "/Users/seb/AI/saltylab-firmware/jetson/ros2_ws/src/"
        "saltybot_social/launch/wake_word.launch.py"
    )
    _SETUP = (
        "/Users/seb/AI/saltylab-firmware/jetson/ros2_ws/src/"
        "saltybot_social/setup.py"
    )

    def test_config_exists(self):
        import os; self.assertTrue(os.path.exists(self._CONFIG))

    def test_config_energy_threshold(self):
        with open(self._CONFIG) as f: c = f.read()
        self.assertIn("energy_threshold", c)

    def test_config_match_threshold(self):
        with open(self._CONFIG) as f: c = f.read()
        self.assertIn("match_threshold", c)

    def test_config_template_path(self):
        with open(self._CONFIG) as f: c = f.read()
        self.assertIn("template_path", c)

    def test_config_cooldown(self):
        with open(self._CONFIG) as f: c = f.read()
        self.assertIn("cooldown_s", c)

    def test_launch_exists(self):
        import os; self.assertTrue(os.path.exists(self._LAUNCH))

    def test_launch_has_template_arg(self):
        with open(self._LAUNCH) as f: c = f.read()
        self.assertIn("template_path", c)

    def test_launch_has_threshold_arg(self):
        with open(self._LAUNCH) as f: c = f.read()
        self.assertIn("match_threshold", c)

    def test_entry_point_in_setup(self):
        with open(self._SETUP) as f: c = f.read()
        self.assertIn("wake_word_node", c)


if __name__ == "__main__":
    unittest.main()
