Add bidirectional RF handoff coordination
This commit is contained in:
460
tests/test_rf_handoff_coordination.py
Normal file
460
tests/test_rf_handoff_coordination.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""Tests for the RX-to-MAIN Git coordination channel without SDR or RF TX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argparse import Namespace
|
||||
from dataclasses import asdict, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf_handoff_channel import (
|
||||
COMPLETE_STATE,
|
||||
EXPECTED_RX_TASK_ID,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
RESULT_JSON_PATH,
|
||||
RESULT_MARKDOWN_PATH,
|
||||
ResultBranchMissing,
|
||||
RxHandoffResult,
|
||||
canonical_result_json,
|
||||
evaluate_ready,
|
||||
result_digest,
|
||||
result_from_mapping,
|
||||
)
|
||||
from tools.rx_agent_watcher import (
|
||||
WatcherState as RxWatcherState,
|
||||
publish_result,
|
||||
save_state as save_rx_state,
|
||||
)
|
||||
from tools.tx_agent_watcher import TxWatcherState, handle_result
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||
HEAD = "a" * 40
|
||||
|
||||
|
||||
def ready_result(**changes: object) -> RxHandoffResult:
|
||||
result = RxHandoffResult(
|
||||
schema_version=1,
|
||||
task_id=EXPECTED_RX_TASK_ID,
|
||||
state=READY_STATE,
|
||||
rx_ready_for_tx=True,
|
||||
process_alive=True,
|
||||
file_growing=True,
|
||||
capture_pid=1234,
|
||||
published_at=NOW.isoformat(),
|
||||
capture_started_at=NOW.isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=120)).isoformat(),
|
||||
capture_path=r"C:\captures\rf001a_ota_capture.cs8",
|
||||
common_clock_verified=True,
|
||||
source_head=HEAD,
|
||||
rf_tx=0,
|
||||
exit_code=None,
|
||||
file_size=None,
|
||||
duration_seconds=None,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
sha256=None,
|
||||
clkin_after="NOT_RUN",
|
||||
rf_tx_observed="UNKNOWN",
|
||||
)
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
def _git(arguments: list[str], cwd: Path) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def git_fixture(tmp_path: Path) -> tuple[Path, Path]:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["config", "user.name", "Test"], work)
|
||||
_git(["config", "user.email", "test@example.invalid"], work)
|
||||
(work / "README.md").write_text(
|
||||
"result branch fixture\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_git(["add", "README.md"], work)
|
||||
_git(["commit", "-m", "Seed result branch"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
|
||||
return remote, work
|
||||
|
||||
|
||||
class FakeSource:
|
||||
def __init__(self, result: RxHandoffResult, digest: str, head: str = HEAD) -> None:
|
||||
self.result = result
|
||||
self.digest = digest
|
||||
self.head = head
|
||||
self.fetch_count = 0
|
||||
|
||||
def fetch(self) -> None:
|
||||
self.fetch_count += 1
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
return self.result, self.digest, self.head
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self.calls = 0
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> Path:
|
||||
assert result.task_id == EXPECTED_RX_TASK_ID
|
||||
assert digest == result_digest(result)
|
||||
assert result_head
|
||||
assert dry_run
|
||||
self.calls += 1
|
||||
self.path.write_text(
|
||||
"FAKE TX ONLY\nRF TX = 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return self.path
|
||||
|
||||
|
||||
def test_result_parsing_rejects_extra_and_inconsistent_fields() -> None:
|
||||
raw = asdict(ready_result())
|
||||
assert result_from_mapping(raw) == ready_result()
|
||||
|
||||
extra = dict(raw)
|
||||
extra["raw_iq"] = "forbidden"
|
||||
with pytest.raises(ValueError, match="result fields disagree"):
|
||||
result_from_mapping(extra)
|
||||
|
||||
wrong = dict(raw)
|
||||
wrong["rf_tx"] = 1
|
||||
with pytest.raises(ValueError, match="rf_tx"):
|
||||
result_from_mapping(wrong)
|
||||
|
||||
wrong_type = dict(raw)
|
||||
wrong_type["capture_pid"] = True
|
||||
with pytest.raises(ValueError, match="integer result fields"):
|
||||
result_from_mapping(wrong_type)
|
||||
|
||||
incomplete = dict(raw)
|
||||
incomplete["file_growing"] = False
|
||||
with pytest.raises(ValueError, match="live capture"):
|
||||
result_from_mapping(incomplete)
|
||||
|
||||
|
||||
def test_complete_result_requires_final_evidence() -> None:
|
||||
final = ready_result(
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=(NOW + timedelta(seconds=121)).isoformat(),
|
||||
exit_code=0,
|
||||
file_size=1_920_000_000,
|
||||
duration_seconds=120.01,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256="b" * 64,
|
||||
clkin_after="DETECTED",
|
||||
rf_tx_observed="EXPECTED_EXTERNAL_TX",
|
||||
)
|
||||
assert result_from_mapping(asdict(final)) == final
|
||||
unreported_counters = replace(
|
||||
final,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
)
|
||||
assert result_from_mapping(asdict(unreported_counters)) == unreported_counters
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "now", "reason"),
|
||||
[
|
||||
(
|
||||
ready_result(
|
||||
published_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||
capture_started_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=89)).isoformat(),
|
||||
),
|
||||
NOW,
|
||||
"STALE_READY",
|
||||
),
|
||||
(
|
||||
ready_result(expected_capture_end=(NOW + timedelta(seconds=9)).isoformat()),
|
||||
NOW,
|
||||
"WRONG_CAPTURE_WINDOW",
|
||||
),
|
||||
(
|
||||
ready_result(
|
||||
capture_started_at=(NOW - timedelta(seconds=111)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=9)).isoformat(),
|
||||
),
|
||||
NOW,
|
||||
"CAPTURE_WINDOW_EXPIRED",
|
||||
),
|
||||
(
|
||||
ready_result(task_id="WRONG_TASK_001"),
|
||||
NOW,
|
||||
"WRONG_TASK_ID",
|
||||
),
|
||||
(
|
||||
ready_result(
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
exit_code=0,
|
||||
file_size=1_920_000_000,
|
||||
duration_seconds=120.0,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256="c" * 64,
|
||||
clkin_after="DETECTED",
|
||||
),
|
||||
NOW,
|
||||
"MISSING_READY",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_freshness_and_window_rejections(
|
||||
result: RxHandoffResult,
|
||||
now: datetime,
|
||||
reason: str,
|
||||
) -> None:
|
||||
assert evaluate_ready(result, now=now).reason == reason
|
||||
|
||||
|
||||
def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None:
|
||||
evaluation = evaluate_ready(ready_result(), now=NOW + timedelta(seconds=2))
|
||||
assert evaluation.ready
|
||||
assert evaluation.reason == "READY"
|
||||
assert evaluation.seconds_remaining == pytest.approx(118.0)
|
||||
|
||||
|
||||
def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
|
||||
result = ready_result()
|
||||
digest = result_digest(result)
|
||||
source = FakeSource(result, digest)
|
||||
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||
output: list[str] = []
|
||||
|
||||
status, state = handle_result(
|
||||
result,
|
||||
digest,
|
||||
HEAD,
|
||||
TxWatcherState(),
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda _prompt: "DENY",
|
||||
output_function=output.append,
|
||||
dry_run=True,
|
||||
)
|
||||
assert status == "RED_APPROVAL_BLOCKED"
|
||||
assert executor.calls == 0
|
||||
assert any("RX IS READY" in line for line in output)
|
||||
|
||||
status, _ = handle_result(
|
||||
result,
|
||||
digest,
|
||||
HEAD,
|
||||
state,
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=3),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=output.append,
|
||||
dry_run=True,
|
||||
)
|
||||
assert status == "DUPLICATE_SUPPRESSED"
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_window_is_rechecked_after_exact_approval(tmp_path: Path) -> None:
|
||||
result = ready_result()
|
||||
digest = result_digest(result)
|
||||
source = FakeSource(result, digest)
|
||||
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||
times = iter((NOW + timedelta(seconds=2), NOW + timedelta(seconds=111)))
|
||||
|
||||
status, _ = handle_result(
|
||||
result,
|
||||
digest,
|
||||
HEAD,
|
||||
TxWatcherState(),
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: next(times),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=lambda _text: None,
|
||||
dry_run=True,
|
||||
max_age_seconds=200.0,
|
||||
)
|
||||
assert status == "CAPTURE_WINDOW_EXPIRED"
|
||||
assert source.fetch_count == 1
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_fake_end_to_end_publish_detect_authorize_without_rf(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
result = ready_result()
|
||||
publisher = GitResultChannel(work)
|
||||
outcome = publisher.publish(result)
|
||||
assert outcome.status == "PUBLISHED"
|
||||
publisher.fetch()
|
||||
|
||||
branch_files = set(
|
||||
_git(["ls-tree", "-r", "--name-only", "origin/rx-node"], work).splitlines()
|
||||
)
|
||||
assert RESULT_JSON_PATH in branch_files
|
||||
assert RESULT_MARKDOWN_PATH in branch_files
|
||||
assert not any(path.endswith((".cs8", ".iq", ".mp4")) for path in branch_files)
|
||||
|
||||
source = GitResultChannel(work)
|
||||
source.fetch()
|
||||
observed, digest, head = source.read()
|
||||
assert observed == result
|
||||
assert head == outcome.commit
|
||||
|
||||
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||
output: list[str] = []
|
||||
status, state = handle_result(
|
||||
observed,
|
||||
digest,
|
||||
head,
|
||||
TxWatcherState(),
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=output.append,
|
||||
dry_run=True,
|
||||
)
|
||||
assert status == "TX_AUTHORIZATION_PREPARED"
|
||||
assert state.last_authorized_digest == digest
|
||||
assert executor.calls == 1
|
||||
assert (tmp_path / "fake_tx.txt").read_text(encoding="utf-8") == (
|
||||
"FAKE TX ONLY\nRF TX = 0\n"
|
||||
)
|
||||
assert any(line == "RF TX = 0" for line in output)
|
||||
|
||||
|
||||
def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
channel = GitResultChannel(work)
|
||||
with pytest.raises(ResultBranchMissing):
|
||||
channel.publish(ready_result())
|
||||
|
||||
|
||||
def test_push_failure_is_reported(tmp_path: Path) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
|
||||
class FailingPushChannel(GitResultChannel):
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if arguments and arguments[0] == "push":
|
||||
raise subprocess.CalledProcessError(
|
||||
1,
|
||||
["git", *arguments],
|
||||
stderr="simulated push failure",
|
||||
)
|
||||
return super()._run(arguments, cwd=cwd, check=check)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError, match="git"):
|
||||
FailingPushChannel(work).publish(ready_result())
|
||||
|
||||
def test_rx_watcher_publishes_ready_only_after_local_task_approval(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
base_head = _git(["rev-parse", "HEAD"], work)
|
||||
_git(["checkout", "-b", "video-development"], work)
|
||||
handoff = work / "docs" / "handoff"
|
||||
handoff.mkdir(parents=True)
|
||||
task = f"""TASK_ID = {EXPECTED_RX_TASK_ID}
|
||||
TASK_TITLE = Fake coordinated capture
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = {base_head}
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = YES
|
||||
RF_TX = NO
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = RX_RESULT_ONLY
|
||||
RISK_LEVEL = YELLOW
|
||||
|
||||
Fake task used only by the local coordination test.
|
||||
"""
|
||||
(handoff / "NEXT_RX_TASK.md").write_text(task, encoding="utf-8")
|
||||
_git(["add", "docs/handoff/NEXT_RX_TASK.md"], work)
|
||||
_git(["commit", "-m", "Add fake RX task"], work)
|
||||
_git(
|
||||
["push", "--set-upstream", "origin", "HEAD:refs/heads/video-development"],
|
||||
work,
|
||||
)
|
||||
source_head = _git(["rev-parse", "HEAD"], work)
|
||||
|
||||
result_path = tmp_path / "ready.json"
|
||||
result_path.write_text(
|
||||
canonical_result_json(ready_result(source_head=source_head)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
state_path = tmp_path / "rx_state.json"
|
||||
save_rx_state(
|
||||
state_path,
|
||||
RxWatcherState(
|
||||
last_seen_task_id=EXPECTED_RX_TASK_ID,
|
||||
last_approved_task_id=EXPECTED_RX_TASK_ID,
|
||||
),
|
||||
)
|
||||
arguments = Namespace(
|
||||
repository=work,
|
||||
state_file=state_path,
|
||||
publish_result=result_path,
|
||||
remote="origin",
|
||||
result_branch="rx-node",
|
||||
)
|
||||
|
||||
assert publish_result(arguments) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "RX RESULT PUBLISH = PUBLISHED" in output
|
||||
assert "RF TX = 0" in output
|
||||
|
||||
channel = GitResultChannel(work)
|
||||
published, _digest, _head = channel.read()
|
||||
assert published == ready_result(source_head=source_head)
|
||||
@@ -169,6 +169,7 @@ def test_dry_run_approval_writes_prompt_and_matching_results(tmp_path: Path) ->
|
||||
assert status == "PREPARED"
|
||||
assert state.last_seen_task_id == "WATCHER_TEST_001"
|
||||
assert state.last_completed_task_id is None
|
||||
assert state.last_approved_task_id == "WATCHER_TEST_001"
|
||||
assert (directory / "TASK.md").exists()
|
||||
markdown = (directory / "RX_RESULT.md").read_text(encoding="utf-8")
|
||||
result = json.loads((directory / "RX_RESULT.json").read_text(encoding="utf-8"))
|
||||
|
||||
Reference in New Issue
Block a user