"""Tester for mesh.py: soner, fordeling og statuspolling. Dispatcheren får synthesize/play_local/send inn som funksjoner, så alt her går uten nett. Den ekte HTTP-transporten (send_to_zone mot en levende /api/play) prøves i test_routes.py, der announcer-appen finnes. """ from __future__ import annotations import sys import threading import time from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import mesh # noqa: E402 # -------------------------------------------------------------------------- # Soner fra config # -------------------------------------------------------------------------- def test_zones_fra_config(): z = mesh.zones_from_config({"her": "local", "scene": "http://10.0.0.5:8090/", "res": "https://r.lan"}) assert z["her"].local and z["her"].url == "" assert not z["scene"].local and z["scene"].url == "http://10.0.0.5:8090" # uten skråstrek assert list(z) == ["her", "scene", "res"] # rekkefølgen bevares @pytest.mark.parametrize("raw, feil", [ ({"scene": "10.0.0.5:8090"}, "verken"), ({"scene": "ftp://x"}, "verken"), ({"": "local"}, "ugyldig sonenavn"), ({"har mellomrom": "local"}, "ugyldig sonenavn"), ({"x" * 33: "local"}, "ugyldig sonenavn"), ]) def test_ugyldig_zones_feiler_hoyt(raw, feil): with pytest.raises(ValueError, match=feil): mesh.zones_from_config(raw) # -------------------------------------------------------------------------- # Dispatcher # -------------------------------------------------------------------------- ZONES = mesh.zones_from_config({"her": "local", "a": "http://a", "b": "http://b"}) def make(tmp_path, *, send=None, synth=None, timeout=1.0, on_finished=None): wav = tmp_path / "x.wav" wav.write_bytes(b"RIFF") played: list[tuple[str, Path]] = [] sent: list[str] = [] def play_local(d, path): played.append((d.text, path)) return 42 def default_send(zone, path, d): sent.append(zone.name) return "queued", 7, "" disp = mesh.Dispatcher( ZONES, synthesize=synth or (lambda *a: wav), play_local=play_local, send=send or default_send, timeout=timeout, on_finished=on_finished, ) return disp, played, sent, wav def test_resolve(): disp, *_ = make(Path("/tmp")) assert disp.resolve(None) == ["her", "a", "b"] assert disp.resolve([]) == ["her", "a", "b"] assert disp.resolve(["b", "a", "b"]) == ["b", "a"] # dedupe, rekkefølge som bedt assert disp.resolve("a") == ["a"] with pytest.raises(ValueError, match="Ukjent sone: c"): disp.resolve(["a", "c"]) with pytest.raises(ValueError, match="liste"): disp.resolve({"a": 1}) def test_fordeling_til_alle(tmp_path): ferdig = [] disp, played, sent, wav = make(tmp_path, on_finished=ferdig.append) d = disp.submit("Hallo", "finn", 1.0, "none", ["her", "a", "b"], user="Kari") assert d.pending assert disp.run_once(timeout=1) is d assert not d.pending assert {n: z.status for n, z in d.zones.items()} == {"her": "queued", "a": "queued", "b": "queued"} assert d.zones["her"].item_id == 42 and d.zones["a"].item_id == 7 assert played == [("Hallo", wav)] assert sorted(sent) == ["a", "b"] assert ferdig == [d] assert d.accepted == ["her", "a", "b"] assert d.summary() == "her: kø #42, a: kø #7, b: kø #7" def test_bare_valgte_soner(tmp_path): disp, played, sent, _ = make(tmp_path) disp.submit("x", "finn", 1.0, "none", ["b"]) disp.run_once(timeout=1) assert played == [] and sent == ["b"] def test_en_sone_nede_stopper_ikke_de_andre(tmp_path): def send(zone, path, d): if zone.name == "b": return "unreachable", None, "connection refused" return "queued", 3, "" disp, played, _, _ = make(tmp_path, send=send) d = disp.submit("x", "finn", 1.0, "none", ["her", "a", "b"]) disp.run_once(timeout=1) assert d.zones["a"].status == "queued" assert d.zones["b"].status == "unreachable" and "refused" in d.zones["b"].error assert d.zones["her"].status == "queued" assert d.accepted == ["her", "a"] assert "b: nede" in d.summary() def test_send_som_kaster_blir_unreachable(tmp_path): def send(zone, path, d): raise OSError("kabelen er ute") disp, *_ = make(tmp_path, send=send) d = disp.submit("x", "finn", 1.0, "none", ["a"]) disp.run_once(timeout=1) assert d.zones["a"].status == "unreachable" assert "kabelen" in d.zones["a"].error def test_treg_sone_gir_opp_etter_timeout(tmp_path): def send(zone, path, d): time.sleep(2) return "queued", 1, "" disp, *_ = make(tmp_path, send=send, timeout=0.1) d = disp.submit("x", "finn", 1.0, "none", ["a"]) t0 = time.time() disp.run_once(timeout=1) assert time.time() - t0 < 1.8 assert d.zones["a"].status == "unreachable" assert "i tide" in d.zones["a"].error def test_syntesefeil_feiler_alle_soner(tmp_path): def synth(*a): raise RuntimeError("edge-tts nede") ferdig = [] disp, played, sent, _ = make(tmp_path, synth=synth, on_finished=ferdig.append) d = disp.submit("x", "finn", 1.0, "none", ["her", "a"]) disp.run_once(timeout=1) assert d.error == "edge-tts nede" assert all(z.status == "failed" for z in d.zones.values()) assert played == [] and sent == [] assert d.accepted == [] assert ferdig == [d] # planen må få vite det def test_lokal_feil_rammer_bare_lokal(tmp_path): disp, _, sent, _ = make(tmp_path) def boom(d, path): raise RuntimeError("ingen lyd") disp.play_local = boom d = disp.submit("x", "finn", 1.0, "none", ["her", "a"]) disp.run_once(timeout=1) assert d.zones["her"].status == "failed" and "ingen lyd" in d.zones["her"].error assert d.zones["a"].status == "queued" def test_snapshot_nyeste_forst_og_uten_ip(tmp_path): disp, *_ = make(tmp_path) disp.submit("en", "finn", 1.0, "none", ["a"], sender="10.0.0.9") disp.submit("to", "finn", 1.0, "none", ["a"], sender="10.0.0.9") snap = disp.snapshot() assert [d["text"] for d in snap] == ["to", "en"] assert snap[0]["sender"] == "" assert disp.snapshot(show_ip=True)[0]["sender"] == "10.0.0.9" assert "zones" in snap[0] and snap[0]["zones"]["a"]["status"] == "sending" def test_run_once_pa_tom_ko(tmp_path): disp, *_ = make(tmp_path) assert disp.run_once(timeout=0.05) is None # -------------------------------------------------------------------------- # ZonePoller # -------------------------------------------------------------------------- def test_poller_henter_eksterne_og_slar_opp_lokal_ferskt(): svar = {"a": {"reachable": True, "current": None, "queued": 2}, "b": {"reachable": False, "error": "timeout"}} lokal = {"n": 0} def local_status(): lokal["n"] += 1 return {"current": None, "queued": lokal["n"]} p = mesh.ZonePoller(ZONES, fetch=lambda z: svar[z.name], interval=99, local_status=local_status) before = p.snapshot() assert before["a"]["reachable"] is False and "ennå" in before["a"]["error"] assert before["her"]["reachable"] is True and before["her"]["queued"] == 1 p.poll_once() after = p.snapshot() assert after["a"] == {"reachable": True, "current": None, "queued": 2, "checked": after["a"]["checked"]} assert after["b"]["reachable"] is False and after["b"]["error"] == "timeout" assert after["her"]["queued"] == 2 # lokal slås opp på nytt hver gang assert list(after) == ["her", "a", "b"] def test_poller_taler_fetch_som_kaster(): def fetch(z): raise RuntimeError("nei") p = mesh.ZonePoller(ZONES, fetch=fetch, interval=99) p.poll_once() # skal ikke kaste assert p.snapshot()["a"]["reachable"] is False # -------------------------------------------------------------------------- # Transport mot noe som ikke finnes # -------------------------------------------------------------------------- def test_send_til_sone_som_ikke_finnes(tmp_path): wav = tmp_path / "x.wav"; wav.write_bytes(b"RIFF") d = mesh.Dispatch("abc", "x", "finn", 1.0, "none", {}) status, item_id, err = mesh.send_to_zone(mesh.Zone("z", "http://127.0.0.1:1"), wav, d, secret="s", timeout=1) assert status == "unreachable" and item_id is None and err def test_status_fra_sone_som_ikke_finnes(): r = mesh.fetch_zone_status(mesh.Zone("z", "http://127.0.0.1:1"), secret="s", timeout=1) assert r["reachable"] is False and r["error"] # -------------------------------------------------------------------------- # Registry: sonene som melder seg # -------------------------------------------------------------------------- def test_register_og_rekkefolge(): r = mesh.Registry({"fast": mesh.Zone("fast", "http://fast")}, local=mesh.Zone("her"), stale_seconds=90) r.register("b", "http://b:8090/", "0.3") r.register("a", "http://a:8090") z = r.zones() assert list(z) == ["her", "fast", "a", "b"] # lokal, statisk, så registrerte sortert assert z["b"].url == "http://b:8090" # uten skråstrek info = r.info() assert info["her"]["source"] == "local" assert info["fast"]["source"] == "static" assert info["b"]["source"] == "registered" and info["b"]["version"] == "0.3" and info["b"]["last_seen"] def test_statisk_og_lokal_vinner_over_den_som_melder_seg(): r = mesh.Registry({"fast": mesh.Zone("fast", "http://fast")}, local=mesh.Zone("her")) with pytest.raises(ValueError, match="er satt i"): r.register("fast", "http://ond") with pytest.raises(ValueError, match="eget navn"): r.register("her", "http://ond") assert r.zones()["fast"].url == "http://fast" @pytest.mark.parametrize("name, url, feil", [ ("", "http://x", "ugyldig sonenavn"), ("har mellomrom", "http://x", "ugyldig sonenavn"), ("ok", "10.0.0.5:8090", "http"), ("ok", "", "http"), ]) def test_register_avviser_tull(name, url, feil): with pytest.raises(ValueError, match=feil): mesh.Registry({}).register(name, url) def test_hjerteslag_oppdaterer_sist_sett_og_bytte_av_adresse(): r = mesh.Registry({}, stale_seconds=90) r.register("a", "http://a") t1 = r.registered["a"].last_seen time.sleep(0.01) r.register("a", "http://a") assert r.registered["a"].last_seen > t1 assert r.registered["a"].first_seen <= t1 r.register("a", "http://a-ny") # sonen fikk ny IP assert r.zones()["a"].url == "http://a-ny" def test_sone_som_slutter_a_sla_tas_ut(): r = mesh.Registry({}, stale_seconds=90) r.register("a", "http://a") r.register("b", "http://b") now = time.time() r.registered["a"].last_seen = now - 100 assert r.prune(now) == ["a"] assert list(r.zones()) == ["b"] assert r.prune(now) == [] def test_dispatcher_med_dynamiske_soner(tmp_path): """Sonen forsvinner mellom innlevering og fordeling.""" r = mesh.Registry({}, local=None) r.register("a", "http://a") wav = tmp_path / "x.wav"; wav.write_bytes(b"RIFF") disp = mesh.Dispatcher(r.zones, synthesize=lambda *a: wav, play_local=lambda d, p: 1, send=lambda z, p, d: ("queued", 1, ""), timeout=1) assert disp.names() == ["a"] d = disp.submit("x", "finn", 1.0, "none", ["a"]) r.registered.clear() # meldt av disp.run_once(timeout=1) assert d.zones["a"].status == "unreachable" and "borte" in d.zones["a"].error def test_poller_glemmer_soner_som_er_borte(): r = mesh.Registry({}) r.register("a", "http://a") p = mesh.ZonePoller(r.zones, fetch=lambda z: {"reachable": True, "queued": 0, "current": None}, interval=99) p.poll_once() assert "a" in p.snapshot() r.registered.clear() p.poll_once() assert p.snapshot() == {} and p.cache == {} # -------------------------------------------------------------------------- # Heartbeat: sonens side # -------------------------------------------------------------------------- def test_local_ip_towards_loopback(): assert mesh.local_ip_towards("127.0.0.1", 8090) == "127.0.0.1" def test_hjerteslag_uten_kjent_sentral_venter(): calls = [] hb = mesh.Heartbeat(name="a", port=8093, secret="s", sentral=lambda: None, post=lambda *a, **k: calls.append(k) or (True, "")) assert hb.beat_once() is False assert calls == [] and "ingen sentral" in hb.last_error assert hb.status()["sentral"] is None def test_hjerteslag_melder_seg_med_riktig_adresse(): calls = [] hb = mesh.Heartbeat(name="a", port=8093, secret="s", sentral=lambda: "http://127.0.0.1:8091/", version="0.3", post=lambda sentral, **k: calls.append((sentral, k)) or (True, "")) assert hb.beat_once() is True url, k = calls[0] assert url == "http://127.0.0.1:8091/" assert k == {"name": "a", "url": "http://127.0.0.1:8093", "secret": "s", "version": "0.3"} assert hb.last_ok and hb.last_error == "" and hb.status()["sentral"] == "http://127.0.0.1:8091/" def test_hjerteslag_med_fast_egen_url(): calls = [] hb = mesh.Heartbeat(name="a", port=8093, secret="s", sentral=lambda: "http://s:8090", url="http://10.9.9.9:8093/", post=lambda sentral, **k: calls.append(k) or (True, "")) hb.beat_once() assert calls[0]["url"] == "http://10.9.9.9:8093" def test_hjerteslag_husker_feil(): hb = mesh.Heartbeat(name="a", port=8093, secret="s", sentral=lambda: "http://127.0.0.1:8091", post=lambda *a, **k: (False, "401 Feil mesh-nøkkel")) assert hb.beat_once() is False assert hb.last_ok is None and "401" in hb.status()["error"] def test_post_register_mot_noe_som_ikke_finnes(): ok, err = mesh.post_register("http://127.0.0.1:1", name="a", url="http://x", secret="s", timeout=1) assert ok is False and err