Add bidirectional RF handoff coordination

This commit is contained in:
LittleSam129
2026-09-21 17:02:49 +03:00
parent 34f5ace545
commit bc55d0516d
14 changed files with 1978 additions and 556 deletions

View 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)

View File

@@ -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"))