mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-09-06 19:30:35 +00:00
158 lines
5.9 KiB
Python
158 lines
5.9 KiB
Python
|
|
"""Tests for the event recorder + snapshot capture pipeline (the bridge to CrisisViz)."""
|
||
|
|
|
||
|
|
import json
|
||
|
|
from dataclasses import asdict
|
||
|
|
|
||
|
|
from crisis.demo import Simulation
|
||
|
|
from crisis.recorder import (
|
||
|
|
EventRecorder,
|
||
|
|
EventType,
|
||
|
|
SimEvent,
|
||
|
|
StepSnapshot,
|
||
|
|
VertexSnapshot,
|
||
|
|
NodeSnapshot,
|
||
|
|
capture_snapshot,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestEventRecorder:
|
||
|
|
|
||
|
|
def test_empty_recorder_has_no_events(self):
|
||
|
|
rec = EventRecorder()
|
||
|
|
assert rec.events == []
|
||
|
|
assert rec.snapshots == []
|
||
|
|
assert rec.max_step() == 0
|
||
|
|
|
||
|
|
def test_sequence_numbers_are_monotonic(self):
|
||
|
|
rec = EventRecorder()
|
||
|
|
rec.record(1, EventType.STEP_BEGIN, "")
|
||
|
|
rec.record(1, EventType.MESSAGE_CREATED, "alice")
|
||
|
|
rec.record(2, EventType.STEP_END, "")
|
||
|
|
seqs = [e.seq for e in rec.events]
|
||
|
|
assert seqs == sorted(seqs)
|
||
|
|
assert len(set(seqs)) == len(seqs)
|
||
|
|
|
||
|
|
def test_filter_by_step(self):
|
||
|
|
rec = EventRecorder()
|
||
|
|
rec.record(1, EventType.STEP_BEGIN, "")
|
||
|
|
rec.record(2, EventType.STEP_BEGIN, "")
|
||
|
|
rec.record(2, EventType.STEP_END, "")
|
||
|
|
rec.record(3, EventType.STEP_BEGIN, "")
|
||
|
|
assert len(rec.events_at_step(2)) == 2
|
||
|
|
assert len(rec.events_at_step(1)) == 1
|
||
|
|
assert rec.max_step() == 3
|
||
|
|
|
||
|
|
def test_filter_by_type(self):
|
||
|
|
rec = EventRecorder()
|
||
|
|
rec.record(1, EventType.STEP_BEGIN, "")
|
||
|
|
rec.record(1, EventType.MESSAGE_CREATED, "a")
|
||
|
|
rec.record(1, EventType.MESSAGE_CREATED, "b")
|
||
|
|
assert len(rec.events_of_type(EventType.MESSAGE_CREATED)) == 2
|
||
|
|
assert len(rec.events_of_type(EventType.STEP_END)) == 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestSimulationRecording:
|
||
|
|
"""The recorder must capture events emitted by a real simulation run."""
|
||
|
|
|
||
|
|
def _tiny_sim_run(self, num_steps: int = 5) -> tuple[Simulation, EventRecorder]:
|
||
|
|
rec = EventRecorder()
|
||
|
|
sim = Simulation(
|
||
|
|
num_honest=3,
|
||
|
|
num_byzantine=0,
|
||
|
|
pow_zeros=0,
|
||
|
|
difficulty=0,
|
||
|
|
connectivity_k=0,
|
||
|
|
seed=42,
|
||
|
|
recorder=rec,
|
||
|
|
synchronous=True,
|
||
|
|
)
|
||
|
|
sim.run(num_steps=num_steps, verbose=False)
|
||
|
|
return sim, rec
|
||
|
|
|
||
|
|
def test_recorder_collects_events_per_step(self):
|
||
|
|
_, rec = self._tiny_sim_run(num_steps=3)
|
||
|
|
assert len(rec.events) > 0
|
||
|
|
assert rec.max_step() == 3
|
||
|
|
|
||
|
|
def test_step_lifecycle_events_present(self):
|
||
|
|
"""Every step must emit STEP_BEGIN and STEP_END."""
|
||
|
|
_, rec = self._tiny_sim_run(num_steps=4)
|
||
|
|
begins = rec.events_of_type(EventType.STEP_BEGIN)
|
||
|
|
ends = rec.events_of_type(EventType.STEP_END)
|
||
|
|
assert len(begins) == 4
|
||
|
|
assert len(ends) == 4
|
||
|
|
|
||
|
|
def test_messages_are_recorded(self):
|
||
|
|
"""At least one message-creation event should appear per step with honest nodes."""
|
||
|
|
_, rec = self._tiny_sim_run(num_steps=3)
|
||
|
|
created = rec.events_of_type(EventType.MESSAGE_CREATED)
|
||
|
|
delivered = rec.events_of_type(EventType.MESSAGE_DELIVERED)
|
||
|
|
assert len(created) > 0
|
||
|
|
assert len(delivered) > 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestSnapshotCapture:
|
||
|
|
|
||
|
|
def test_snapshot_is_well_formed(self):
|
||
|
|
rec = EventRecorder()
|
||
|
|
sim = Simulation(num_honest=3, num_byzantine=0, pow_zeros=0,
|
||
|
|
difficulty=0, connectivity_k=0, seed=42,
|
||
|
|
recorder=rec, synchronous=True)
|
||
|
|
sim.run(num_steps=5, verbose=False)
|
||
|
|
|
||
|
|
snap = capture_snapshot(step=5, nodes=sim.nodes,
|
||
|
|
weight_system=sim.weight_system)
|
||
|
|
|
||
|
|
assert isinstance(snap, StepSnapshot)
|
||
|
|
assert snap.step == 5
|
||
|
|
assert set(snap.node_snapshots.keys()) == {n.name for n in sim.nodes}
|
||
|
|
|
||
|
|
for ns in snap.node_snapshots.values():
|
||
|
|
assert isinstance(ns, NodeSnapshot)
|
||
|
|
assert ns.vertex_count > 0
|
||
|
|
for vs in ns.vertices:
|
||
|
|
assert isinstance(vs, VertexSnapshot)
|
||
|
|
assert len(vs.digest_full) > 0
|
||
|
|
assert len(vs.process_id_hex) == 8
|
||
|
|
assert vs.weight >= 0
|
||
|
|
|
||
|
|
def test_snapshot_vertex_ids_match_graph(self):
|
||
|
|
"""Snapshot vertex digests must correspond to actual graph state."""
|
||
|
|
sim = Simulation(num_honest=2, num_byzantine=0, pow_zeros=0,
|
||
|
|
difficulty=0, seed=42, synchronous=True)
|
||
|
|
sim.run(num_steps=3, verbose=False)
|
||
|
|
snap = capture_snapshot(step=3, nodes=sim.nodes,
|
||
|
|
weight_system=sim.weight_system)
|
||
|
|
for node in sim.nodes:
|
||
|
|
ns = snap.node_snapshots[node.name]
|
||
|
|
graph_digests = {v.message_digest.hex() for v in node.graph.all_vertices()}
|
||
|
|
snap_digests = {vs.digest_full for vs in ns.vertices}
|
||
|
|
assert snap_digests == graph_digests
|
||
|
|
|
||
|
|
|
||
|
|
class TestJsonSerializability:
|
||
|
|
"""The whole point of recorder + snapshots is to round-trip through JSON for CrisisViz."""
|
||
|
|
|
||
|
|
def test_snapshot_is_json_serializable(self):
|
||
|
|
sim = Simulation(num_honest=2, num_byzantine=0, pow_zeros=0,
|
||
|
|
difficulty=0, seed=42, synchronous=True)
|
||
|
|
sim.run(num_steps=3, verbose=False)
|
||
|
|
snap = capture_snapshot(step=3, nodes=sim.nodes,
|
||
|
|
weight_system=sim.weight_system)
|
||
|
|
as_dict = asdict(snap)
|
||
|
|
# Should not raise; should produce a non-trivial string
|
||
|
|
encoded = json.dumps(as_dict, default=str)
|
||
|
|
assert len(encoded) > 100
|
||
|
|
|
||
|
|
def test_event_data_is_json_serializable(self):
|
||
|
|
rec = EventRecorder()
|
||
|
|
sim = Simulation(num_honest=3, num_byzantine=0, pow_zeros=0,
|
||
|
|
difficulty=0, seed=42, recorder=rec, synchronous=True)
|
||
|
|
sim.run(num_steps=3, verbose=False)
|
||
|
|
|
||
|
|
# Each event's `data` dict must be JSON-encodable (export_json depends on this).
|
||
|
|
for evt in rec.events:
|
||
|
|
# `default=str` covers bytes-as-hex-string fallbacks; the recorder is
|
||
|
|
# supposed to have already hex-encoded its bytes, so this is a safety net.
|
||
|
|
json.dumps(evt.data, default=str)
|