461 lines
13 KiB
Python
461 lines
13 KiB
Python
"""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)
|