test(v2): add JSONL session replay harness (V2-P0-007)

SessionReplayer replays golden fixtures through DeterministicScenario;
add HBP group voice session, behavior tests, and dev CAPTURE scaffold.
Set pytest pythonpath so bare `pytest` finds tests.* imports.
pull/1/head
Rodrigo Pérez 4 months ago
parent 6c1195ab6c
commit 25dbc971b0

3
.gitignore vendored

@ -36,6 +36,9 @@ json/*
# Internal
docs-priv/
# Dev-only session captures (V2-P0-007); anonymize before promoting to fixtures/
tests/fixtures/sessions/_capture/
# Docs policy: keep source markdown + mkdocs configs in git; ignore generated assets
docs/build-assets/
requirements-docs.txt

@ -28,6 +28,8 @@ docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.3"]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
markers = [
"behavior: integration-style behavior tests (P0/P1)",
"smoke: quick routing smoke tests",

@ -0,0 +1,3 @@
{"type":"meta","version":1,"name":"hbp_group_voice_short","apply_startup_bridges":true,"config":{"GLOBAL":{"SERVER_ID":9990,"USE_ACL":false,"GEN_STAT_BRIDGES":false,"VALIDATE_SERVER_IDS":false,"TALKER_ALIAS":false},"REPORTS":{"REPORT":false},"ALIASES":{"PATH":"./","SUB_MAP_FILE":""},"SYSTEMS":{"MASTER-A":{"MODE":"MASTER","ENABLED":true,"REPEAT":true,"MAX_PEERS":1,"IP":"127.0.0.1","PORT":0,"PASSPHRASE":"","GROUP_HANGTIME":0,"USE_ACL":false,"DEFAULT_UA_TIMER":10,"SINGLE_MODE":true,"VOICE_IDENT":false,"TS2_STATIC":"52090","TS1_STATIC":"","DEFAULT_REFLECTOR":0,"GENERATOR":0,"ALLOW_UNREG_ID":true,"PEERS":{}},"MASTER-B":{"MODE":"MASTER","ENABLED":true,"REPEAT":true,"MAX_PEERS":1,"IP":"127.0.0.1","PORT":0,"PASSPHRASE":"","GROUP_HANGTIME":0,"USE_ACL":false,"DEFAULT_UA_TIMER":1,"SINGLE_MODE":true,"VOICE_IDENT":false,"TS2_STATIC":"","TS1_STATIC":"","DEFAULT_REFLECTOR":0,"GENERATOR":0,"ALLOW_UNREG_ID":true,"PEERS":{}}}},"bridges":{"52090":[{"SYSTEM":"MASTER-A","TS":2,"TGID":52090,"ACTIVE":true,"TIMEOUT":60,"TO_TYPE":"ON","OFF":[],"ON":[52090],"RESET":[],"TIMER":0},{"SYSTEM":"MASTER-B","TS":2,"TGID":52090,"ACTIVE":true,"TIMEOUT":60,"TO_TYPE":"ON","OFF":[],"ON":[52090],"RESET":[],"TIMER":0}]},"expect":{"forwards":{"MASTER-B":2},"dst_id":52090}}
{"type":"ingress","channel":"hbp","system":"MASTER-A","dt":0,"packet":"voice_head","base":{"peer_id":1001,"rf_src":3120001,"dst_id":52090,"slot":2,"stream_id":2155905152}}
{"type":"ingress","channel":"hbp","system":"MASTER-A","dt":0.06,"packet":"voice_burst","base":{"peer_id":1001,"rf_src":3120001,"dst_id":52090,"slot":2,"stream_id":2155905152},"seq":1,"dtype_vseq":1}

@ -0,0 +1,360 @@
"""JSONL session replay for deterministic bridge tests (V2-P0-007).
Session file format (one JSON object per line):
- ``meta`` — optional first line: ``config``, ``bridges``, ``apply_startup_bridges``, ``expect``
- ``ingress`` — inject one packet: ``channel`` (hbp|obp|unit), ``system``, ``dt`` (clock advance
seconds before inject), and either ``packet`` shortcut or ``spec`` fields for PacketSpec
- ``packet`` shortcuts: ``voice_head``, ``voice_burst``, ``voice_term`` (+ ``base``, ``seq``, …)
Capture tap (dev only; anonymize before commit)::
CAPTURE=1 pytest tests/replay/test_session_replay.py::test_capture_startup_voice_session -q
Writes ``tests/fixtures/sessions/_capture/<name>.jsonl`` (gitignored).
"""
from __future__ import annotations
import copy
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterator
from adn_server.domain import bytes_3, int_id
from adn_server.domain.hbp_protocol import (
HBPF_DATA_SYNC,
HBPF_SLT_VHEAD,
HBPF_SLT_VTERM,
HBPF_VOICE,
)
from tests.harness.assertions import assert_forwarded
from tests.harness.deterministic import (
DeterministicScenario,
PacketSpec,
minimal_config,
patch_bridge_wall_time,
)
SESSION_VERSION = 1
FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" / "sessions"
_CAPTURE_DIR = FIXTURES_DIR / "_capture"
_FRAME_ALIASES: dict[str, tuple[int, int]] = {
"vhead": (HBPF_DATA_SYNC, HBPF_SLT_VHEAD),
"voice": (HBPF_VOICE, 0),
"vterm": (HBPF_DATA_SYNC, HBPF_SLT_VTERM),
}
@dataclass
class SessionExpect:
forwards: dict[str, int] = field(default_factory=dict)
dst_id: int | None = None
@dataclass
class SessionMeta:
version: int = SESSION_VERSION
name: str = ""
config: dict[str, Any] | None = None
bridges: dict[str, list[dict[str, Any]]] | None = None
apply_startup_bridges: bool = False
expect: SessionExpect = field(default_factory=SessionExpect)
@dataclass
class IngressEvent:
channel: str
system: str
dt: float = 0.0
packet: str | None = None
base: dict[str, Any] = field(default_factory=dict)
spec: dict[str, Any] = field(default_factory=dict)
seq: int | None = None
dtype_vseq: int | None = None
@dataclass
class SessionDefinition:
meta: SessionMeta
events: list[IngressEvent] = field(default_factory=list)
def bridges_from_json(raw: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
bridges: dict[str, list[dict[str, Any]]] = {}
for key, entries in raw.items():
out_entries: list[dict[str, Any]] = []
for ent in entries:
row = dict(ent)
tgid = row.get("TGID")
if isinstance(tgid, int):
row["TGID"] = bytes_3(tgid)
for list_field in ("ON", "OFF", "RESET"):
if list_field in row:
row[list_field] = [
bytes_3(v) if isinstance(v, int) else v for v in row[list_field]
]
out_entries.append(row)
bridges[key] = out_entries
return bridges
def bridges_to_json(bridges: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
out: dict[str, list[dict[str, Any]]] = {}
for key, entries in bridges.items():
serialized: list[dict[str, Any]] = []
for ent in entries:
row = dict(ent)
tgid = row.get("TGID")
if isinstance(tgid, bytes):
row["TGID"] = int_id(tgid)
for list_field in ("ON", "OFF", "RESET"):
if list_field in row:
row[list_field] = [
int_id(v) if isinstance(v, bytes) else v for v in row[list_field]
]
serialized.append(row)
out[key] = serialized
return out
def _parse_meta(obj: dict[str, Any]) -> SessionMeta:
expect_raw = obj.get("expect") or {}
expect = SessionExpect(
forwards=dict(expect_raw.get("forwards") or {}),
dst_id=expect_raw.get("dst_id"),
)
return SessionMeta(
version=int(obj.get("version", SESSION_VERSION)),
name=str(obj.get("name", "")),
config=obj.get("config"),
bridges=obj.get("bridges"),
apply_startup_bridges=bool(obj.get("apply_startup_bridges")),
expect=expect,
)
def _parse_ingress(obj: dict[str, Any]) -> IngressEvent:
return IngressEvent(
channel=str(obj["channel"]),
system=str(obj["system"]),
dt=float(obj.get("dt", 0.0)),
packet=obj.get("packet"),
base=dict(obj.get("base") or {}),
spec=dict(obj.get("spec") or {}),
seq=obj.get("seq"),
dtype_vseq=obj.get("dtype_vseq"),
)
def load_session(path: str | Path) -> SessionDefinition:
meta = SessionMeta()
events: list[IngressEvent] = []
path = Path(path)
with path.open(encoding="utf-8") as fh:
for line_no, line in enumerate(fh, 1):
line = line.strip()
if not line or line.startswith("#"):
continue
obj = json.loads(line)
kind = obj.get("type")
if kind == "meta":
meta = _parse_meta(obj)
elif kind == "ingress":
events.append(_parse_ingress(obj))
else:
raise ValueError(f"{path}:{line_no}: unknown record type {kind!r}")
if not events:
raise ValueError(f"{path}: no ingress events")
return SessionDefinition(meta=meta, events=events)
def _spec_from_dict(raw: dict[str, Any]) -> PacketSpec:
kwargs = dict(raw)
if "frame" in kwargs:
frame_key = str(kwargs.pop("frame"))
ft, dv = _FRAME_ALIASES.get(frame_key, (kwargs.get("frame_type"), kwargs.get("dtype_vseq")))
if ft is not None:
kwargs["frame_type"] = ft
if dv is not None:
kwargs["dtype_vseq"] = dv
if "payload" in kwargs and isinstance(kwargs["payload"], str):
kwargs["payload"] = bytes.fromhex(kwargs["payload"])
return PacketSpec(**kwargs)
def _base_spec(event: IngressEvent) -> PacketSpec:
if event.spec:
return _spec_from_dict(event.spec)
return _spec_from_dict(event.base)
def _event_to_spec(event: IngressEvent) -> PacketSpec:
base = _base_spec(event)
shortcut = event.packet
if shortcut == "voice_head":
return DeterministicScenario.voice_head_spec(base)
if shortcut == "voice_burst":
return DeterministicScenario.voice_burst_spec(
base,
seq=int(event.seq if event.seq is not None else 1),
dtype_vseq=int(event.dtype_vseq if event.dtype_vseq is not None else 1),
)
if shortcut == "voice_term":
return DeterministicScenario.voice_term_spec(
base,
seq=int(event.seq if event.seq is not None else 99),
)
if shortcut:
raise ValueError(f"unknown packet shortcut {shortcut!r}")
return base
class SessionReplayer:
def __init__(self, session: SessionDefinition) -> None:
self.session = session
@classmethod
def from_path(cls, path: str | Path) -> SessionReplayer:
return cls(load_session(path))
def run(self) -> DeterministicScenario:
meta = self.session.meta
bridges = bridges_from_json(meta.bridges) if meta.bridges else None
scenario = DeterministicScenario(
config=meta.config or minimal_config(("MASTER-A", "MASTER-B")),
bridges=bridges,
)
if meta.apply_startup_bridges:
scenario.bridge.apply_startup_bridges()
with patch_bridge_wall_time(scenario.clock):
for event in self.session.events:
if event.dt:
scenario.clock.advance(event.dt)
spec = _event_to_spec(event)
if event.channel == "hbp":
scenario.inject_hbp(event.system, spec)
elif event.channel == "obp":
scenario.inject_obp(event.system, spec)
elif event.channel == "unit":
scenario.inject_unit(event.system, spec)
else:
raise ValueError(f"unknown channel {event.channel!r}")
return scenario
def assert_expectations(self, scenario: DeterministicScenario) -> None:
expect = self.session.meta.expect
for system, count in expect.forwards.items():
assert_forwarded(
scenario,
system,
count=count,
dst_id=expect.dst_id,
)
def replay_session(path: str | Path) -> DeterministicScenario:
replayer = SessionReplayer.from_path(path)
scenario = replayer.run()
replayer.assert_expectations(scenario)
return scenario
def iter_fixture_sessions() -> Iterator[Path]:
if not FIXTURES_DIR.is_dir():
return
yield from sorted(FIXTURES_DIR.glob("*.jsonl"))
def config_to_json(config: dict[str, Any]) -> dict[str, Any]:
"""JSON-safe config (ints for SERVER_ID / NETWORK_ID bytes, empty passphrase strings)."""
out = copy.deepcopy(config)
global_cfg = out.get("GLOBAL")
if isinstance(global_cfg, dict):
sid = global_cfg.get("SERVER_ID")
if isinstance(sid, bytes):
global_cfg["SERVER_ID"] = int.from_bytes(sid, "big")
for syscfg in out.get("SYSTEMS", {}).values():
if not isinstance(syscfg, dict):
continue
nid = syscfg.get("NETWORK_ID")
if isinstance(nid, bytes):
syscfg["NETWORK_ID"] = int.from_bytes(nid, "big")
pp = syscfg.get("PASSPHRASE")
if isinstance(pp, bytes):
syscfg["PASSPHRASE"] = ""
return out
def _json_sanitize(val: Any) -> Any:
if isinstance(val, bytes):
return val.decode("utf-8", errors="replace") if val else ""
if isinstance(val, dict):
return {k: _json_sanitize(v) for k, v in val.items()}
if isinstance(val, list):
return [_json_sanitize(v) for v in val]
if isinstance(val, tuple):
return [_json_sanitize(v) for v in val]
return val
@dataclass
class SessionCapture:
meta: SessionMeta
events: list[dict[str, Any]] = field(default_factory=list)
def add_ingress(self, event: IngressEvent) -> None:
row: dict[str, Any] = {
"type": "ingress",
"channel": event.channel,
"system": event.system,
"dt": event.dt,
}
if event.packet:
row["packet"] = event.packet
if event.base:
row["base"] = event.base
if event.spec:
row["spec"] = event.spec
if event.seq is not None:
row["seq"] = event.seq
if event.dtype_vseq is not None:
row["dtype_vseq"] = event.dtype_vseq
self.events.append(row)
def write(self, path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
meta_row: dict[str, Any] = {
"type": "meta",
"version": self.meta.version,
"name": self.meta.name,
"apply_startup_bridges": self.meta.apply_startup_bridges,
}
if self.meta.config is not None:
meta_row["config"] = config_to_json(self.meta.config)
if self.meta.bridges is not None:
meta_row["bridges"] = _json_sanitize(self.meta.bridges)
if self.meta.expect.forwards or self.meta.expect.dst_id is not None:
meta_row["expect"] = {
"forwards": self.meta.expect.forwards,
"dst_id": self.meta.expect.dst_id,
}
with path.open("w", encoding="utf-8") as fh:
fh.write(json.dumps(meta_row, separators=(",", ":")) + "\n")
for row in self.events:
fh.write(json.dumps(row, separators=(",", ":")) + "\n")
@staticmethod
def capture_path(name: str) -> Path:
safe = name.replace("/", "_").replace("::", "__")
return _CAPTURE_DIR / f"{safe}.jsonl"
def capture_enabled() -> bool:
return os.environ.get("CAPTURE", "").strip().lower() in ("1", "true", "yes")

@ -0,0 +1,111 @@
"""JSONL session replay tests (V2-P0-007)."""
from __future__ import annotations
import pytest
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge, minimal_config
from tests.harness.session_replay import (
FIXTURES_DIR,
IngressEvent,
SessionCapture,
SessionExpect,
SessionMeta,
SessionReplayer,
bridges_to_json,
capture_enabled,
load_session,
replay_session,
)
_HBP_FIXTURE = FIXTURES_DIR / "hbp_group_voice_short.jsonl"
def test_load_session_parses_meta_and_ingress() -> None:
session = load_session(_HBP_FIXTURE)
assert session.meta.name == "hbp_group_voice_short"
assert session.meta.apply_startup_bridges is True
assert len(session.events) == 2
assert session.meta.expect.forwards["MASTER-B"] == 2
@pytest.mark.behavior
def test_replay_hbp_group_voice_short_matches_golden() -> None:
"""Golden JSONL session: VHEAD + burst on MASTER-A forwards twice to MASTER-B."""
replay_session(_HBP_FIXTURE)
@pytest.mark.behavior
def test_replay_matches_inline_harness() -> None:
"""Replay output equals hand-written DeterministicScenario inject sequence."""
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
bridges = active_bridge(52090, (("MASTER-A", 2), ("MASTER-B", 2)))
inline = DeterministicScenario(config=config, bridges=bridges)
inline.bridge.apply_startup_bridges()
base = PacketSpec(dst_id=52090, stream_id=0x80808080, slot=2)
inline.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
inline.inject_hbp(
"MASTER-A",
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
)
replayed = SessionReplayer.from_path(_HBP_FIXTURE).run()
assert len(replayed.capture.packets) == len(inline.capture.packets)
assert replayed.capture.for_system("MASTER-B") == inline.capture.for_system("MASTER-B")
@pytest.mark.behavior
def test_capture_roundtrip(tmp_path) -> None:
"""SessionCapture writes JSONL that SessionReplayer can load."""
bridges = bridges_to_json(active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))))
meta = SessionMeta(
name="roundtrip",
bridges=bridges,
apply_startup_bridges=False,
expect=SessionExpect(forwards={"MASTER-B": 1}, dst_id=91),
)
cap = SessionCapture(meta=meta)
cap.add_ingress(
IngressEvent(
channel="hbp",
system="MASTER-A",
dt=0,
packet="voice_head",
base={"dst_id": 91, "stream_id": 0x01020304, "slot": 2},
)
)
out = tmp_path / "roundtrip.jsonl"
cap.write(out)
scenario = SessionReplayer.from_path(out).run()
SessionReplayer(load_session(out)).assert_expectations(scenario)
@pytest.mark.behavior
@pytest.mark.skipif(not capture_enabled(), reason="set CAPTURE=1 to write fixture under _capture/")
def test_capture_startup_voice_session() -> None:
"""Dev tap: CAPTURE=1 records a session JSONL under fixtures/sessions/_capture/."""
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
bridges = bridges_to_json(active_bridge(52090, (("MASTER-A", 2), ("MASTER-B", 2))))
base = {"dst_id": 52090, "stream_id": 0x80808080, "slot": 2, "peer_id": 1001, "rf_src": 3120001}
cap = SessionCapture(
meta=SessionMeta(
name="capture_startup_voice",
config=config,
bridges=bridges,
apply_startup_bridges=True,
expect=SessionExpect(forwards={"MASTER-B": 2}, dst_id=52090),
)
)
cap.add_ingress(IngressEvent("hbp", "MASTER-A", 0, packet="voice_head", base=base))
cap.add_ingress(
IngressEvent("hbp", "MASTER-A", 0.06, packet="voice_burst", base=base, seq=1, dtype_vseq=1)
)
path = SessionCapture.capture_path("capture_startup_voice")
cap.write(path)
replay_session(path)
Loading…
Cancel
Save

Powered by TurnKey Linux.