feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -19,6 +19,8 @@ from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||
CaptureFormatError,
|
||||
capture_mqtt,
|
||||
iter_capture_frames,
|
||||
read_capture_clock_envelope,
|
||||
seal_capture_clock,
|
||||
validate_private_ipv4,
|
||||
)
|
||||
|
||||
@@ -97,11 +99,17 @@ def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
|
||||
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
observed = []
|
||||
clock_ready: list[bool] = []
|
||||
|
||||
def on_clock_established() -> None:
|
||||
origin = tmp_path / "capture" / "mqtt.timeline.origin.json"
|
||||
clock_ready.append(origin.is_file() and bool(origin.read_bytes()))
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "capture",
|
||||
duration_seconds=30,
|
||||
on_clock_established=on_clock_established,
|
||||
on_message_recorded=observed.append,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
@@ -109,6 +117,7 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
|
||||
assert fake.connect_calls == [("192.168.1.50", 1883, 30)]
|
||||
assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]]
|
||||
assert fake.disconnect_count == 1
|
||||
assert clock_ready == [True]
|
||||
assert summary["stop_reason"] == "keyboard_interrupt"
|
||||
assert summary["message_count"] == 1
|
||||
assert summary["payload_bytes"] == len(fake.payload)
|
||||
@@ -151,8 +160,26 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
|
||||
|
||||
saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text())
|
||||
assert saved_summary == summary
|
||||
assert saved_summary["schema_version"] == 2
|
||||
assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||
for artifact_name in ("mqtt.raw.k1mqtt", "mqtt.metadata.jsonl", "mqtt.summary.json"):
|
||||
clock = read_capture_clock_envelope(
|
||||
capture_dir / "mqtt.timeline.json",
|
||||
expected_sha256=saved_summary["artifact_hashes"]["capture_clock_sha256"],
|
||||
)
|
||||
assert clock.started_monotonic_ns <= observed[0].received_monotonic_ns
|
||||
assert observed[0].received_monotonic_ns <= clock.completed_monotonic_ns
|
||||
assert clock.duration_ns > 0
|
||||
assert saved_summary["session_elapsed_seconds"] == pytest.approx(
|
||||
clock.duration_ns / 1_000_000_000
|
||||
)
|
||||
assert saved_summary["artifacts"]["capture_clock"] == "mqtt.timeline.json"
|
||||
for artifact_name in (
|
||||
"mqtt.raw.k1mqtt",
|
||||
"mqtt.metadata.jsonl",
|
||||
"mqtt.timeline.origin.json",
|
||||
"mqtt.timeline.json",
|
||||
"mqtt.summary.json",
|
||||
):
|
||||
assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600
|
||||
|
||||
|
||||
@@ -216,6 +243,65 @@ def test_capture_can_be_stopped_by_owner_without_losing_artifacts(tmp_path: Path
|
||||
assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload
|
||||
|
||||
|
||||
def test_owner_seals_session_clock_after_all_producers_stop(tmp_path: Path) -> None:
|
||||
capture_dir = tmp_path / "capture"
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
capture_dir,
|
||||
duration_seconds=30,
|
||||
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
|
||||
)
|
||||
provisional = read_capture_clock_envelope(capture_dir / "mqtt.timeline.json")
|
||||
assert summary["capture_clock_scope"] == "transport"
|
||||
|
||||
sealed = seal_capture_clock(capture_dir)
|
||||
|
||||
updated = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
|
||||
assert updated["capture_clock_scope"] == "session"
|
||||
sealed_path = capture_dir / updated["artifacts"]["capture_clock"]
|
||||
assert sealed_path.name == f"mqtt.timeline.session-{sealed.artifact_sha256}.json"
|
||||
assert (capture_dir / "mqtt.timeline.json").read_bytes()
|
||||
assert sealed.started_monotonic_ns == provisional.started_monotonic_ns
|
||||
assert sealed.completed_monotonic_ns >= provisional.completed_monotonic_ns
|
||||
assert updated["artifact_hashes"]["capture_clock_sha256"] == sealed.artifact_sha256
|
||||
assert updated["session_elapsed_seconds"] == pytest.approx(sealed.duration_ns / 1_000_000_000)
|
||||
|
||||
repeated = seal_capture_clock(capture_dir)
|
||||
repeated_summary = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
|
||||
assert repeated == sealed
|
||||
assert repeated_summary == updated
|
||||
|
||||
|
||||
def test_owner_seal_is_retryable_if_summary_pointer_switch_fails(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capture_dir = tmp_path / "capture"
|
||||
capture_mqtt(
|
||||
"192.168.1.50",
|
||||
capture_dir,
|
||||
duration_seconds=30,
|
||||
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
|
||||
)
|
||||
provisional_summary = (capture_dir / "mqtt.summary.json").read_bytes()
|
||||
|
||||
with monkeypatch.context() as context:
|
||||
context.setattr(
|
||||
capture_module,
|
||||
"_write_json_atomic_replace",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("injected switch failure")),
|
||||
)
|
||||
with pytest.raises(CaptureError, match="injected switch failure"):
|
||||
seal_capture_clock(capture_dir)
|
||||
|
||||
assert (capture_dir / "mqtt.summary.json").read_bytes() == provisional_summary
|
||||
assert list(capture_dir.glob("mqtt.timeline.session-*.json"))
|
||||
sealed = seal_capture_clock(capture_dir)
|
||||
summary = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["capture_clock_scope"] == "session"
|
||||
assert summary["artifact_hashes"]["capture_clock_sha256"] == sealed.artifact_sha256
|
||||
|
||||
|
||||
def test_preview_failure_happens_after_raw_message_is_preserved(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
capture_dir = tmp_path / "capture"
|
||||
|
||||
Reference in New Issue
Block a user