"""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 ( CHANNEL_TEST_TASK_ID, COMPLETE_STATE, EXPECTED_RX_TASK_ID, GitResultChannel, READY_STATE, RESULT_JSON_PATH, RESULT_MARKDOWN_PATH, ResultBranchMissing, ResultChannelError, ResultHistoryRewritten, RxHandoffResult, canonical_result_json, evaluate_ready, result_digest, result_from_mapping, ) from tools.rf_handoff_smoke import run_smoke from tools.rx_agent_watcher import ( WatcherState as RxWatcherState, publish_result, require_live_ready_window, save_state as save_rx_state, ) from tools.tx_agent_watcher import ( TxWatcherState, handle_result, load_state as load_tx_state, migrate_state_cursor, process_history_cycle, request_stop, save_state as save_tx_state, ) 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=60)).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 channel_ready_result(**changes: object) -> RxHandoffResult: return ready_result( task_id=CHANNEL_TEST_TASK_ID, capture_path="MODEL_FREE_FAKE_CAPTURE", common_clock_verified=False, **changes, ) def complete_result( result: RxHandoffResult | None = None, **changes: object, ) -> RxHandoffResult: source = result or ready_result() complete = replace( source, state=COMPLETE_STATE, rx_ready_for_tx=False, process_alive=False, file_growing=False, published_at=(NOW + timedelta(seconds=61)).isoformat(), exit_code=0, file_size=960_000_000, duration_seconds=60.01, overruns=0, usb_errors=0, file_errors=0, sha256="b" * 64, clkin_after="DETECTED" if source.common_clock_verified else "NOT_RUN", rf_tx_observed="UNKNOWN", ) return replace(complete, **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 def commit_on_result_branch( work: Path, temporary_root: Path, message: str, *, json_text: str | None = None, allow_empty: bool = False, ) -> str: """Create one test-only result branch commit from the main repository.""" channel = GitResultChannel(work) channel.fetch() checkout = temporary_root / f"result-{message.replace(' ', '-')}" _git(["worktree", "add", "--detach", str(checkout), "origin/rx-node"], work) try: if json_text is not None: result_path = checkout / RESULT_JSON_PATH result_path.parent.mkdir(parents=True, exist_ok=True) result_path.write_text(json_text, encoding="utf-8") _git(["add", "--", RESULT_JSON_PATH], checkout) commit_arguments = [ "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", ] if allow_empty: commit_arguments.append("--allow-empty") commit_arguments.extend(["-m", message]) _git(commit_arguments, checkout) commit = _git(["rev-parse", "HEAD"], checkout) _git(["push", "origin", "HEAD:refs/heads/rx-node"], checkout) return commit finally: _git(["worktree", "remove", "--force", str(checkout)], 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 return None def read(self) -> tuple[RxHandoffResult, str, str]: return self.result, self.digest, self.head def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]: assert commit == self.head return self.read() def commits_since( self, last_processed_commit: str | None, ) -> tuple[list[str], str]: return ([] if last_processed_commit == self.head else [self.head], self.head) def find_commit_by_digest(self, digest: str) -> str | None: return self.head if digest == self.digest else None 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_types_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 process"): result_from_mapping(incomplete) def test_complete_result_accepts_explicit_unreported_counters() -> None: final = complete_result() assert result_from_mapping(asdict(final)) == final unreported = replace( final, overruns=None, usb_errors=None, file_errors=None, ) assert result_from_mapping(asdict(unreported)) == unreported @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=29)).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=51)).isoformat(), expected_capture_end=(NOW + timedelta(seconds=9)).isoformat(), ), NOW, "CAPTURE_WINDOW_EXPIRED", ), (ready_result(task_id="WRONG_TASK_001"), NOW, "WRONG_TASK_ID"), ( complete_result(), NOW, "MISSING_READY", ), ( ready_result(source_head="c" * 40), NOW, "WRONG_SOURCE_HEAD", ), ], ) def test_freshness_window_task_and_source_rejections( result: RxHandoffResult, now: datetime, reason: str, ) -> None: assert evaluate_ready( result, now=now, expected_source_head=HEAD, ).reason == reason def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None: evaluation = evaluate_ready( ready_result(), now=NOW + timedelta(seconds=2), expected_source_head=HEAD, ) assert evaluation.ready assert evaluation.reason == "READY" assert evaluation.seconds_remaining == pytest.approx(58.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, expected_source_head=HEAD, ) 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, expected_source_head=HEAD, ) assert status == "DUPLICATE_SUPPRESSED" assert executor.calls == 0 def test_window_and_commit_are_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=51))) 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, expected_source_head=HEAD, max_age_seconds=200.0, ) assert status == "CAPTURE_WINDOW_EXPIRED" assert source.fetch_count == 1 assert executor.calls == 0 source = FakeSource(result, digest, head="d" * 40) status, _ = handle_result( result, digest, HEAD, TxWatcherState(), source, executor, now_function=lambda: NOW + timedelta(seconds=2), input_function=lambda _prompt: "AUTHORIZE RF TX", output_function=lambda _text: None, dry_run=True, expected_source_head=HEAD, ) assert status == "RESULT_CHANGED" assert executor.calls == 0 def test_stop_request_clears_stale_watcher_pid(tmp_path: Path) -> None: state_path = tmp_path / "watcher_state.json" save_tx_state(state_path, TxWatcherState(running=True, pid=2_000_000_000)) assert request_stop(state_path, timeout_s=1.0) state = load_tx_state(state_path) assert not state.running assert state.pid is None assert not state_path.with_suffix(".json.stop").exists() def test_version_one_state_migrates_without_losing_safety_fields( tmp_path: Path, ) -> None: state_path = tmp_path / "legacy_state.json" state_path.write_text( """{ "running": false, "pid": null, "last_seen_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "last_prompted_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "last_authorized_digest": null, "channel_test_ready_digest": null, "channel_test_complete_digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" } """, encoding="utf-8", ) state = load_tx_state(state_path) assert state.state_version == 2 assert state.last_seen_commit is None assert state.last_processed_rx_node_commit is None assert state.last_prompted_digest == "b" * 64 assert state.channel_test_complete_digest == "c" * 64 def test_history_cycle_processes_ready_duplicate_and_complete_in_order( tmp_path: Path, ) -> None: _remote, work = git_fixture(tmp_path) channel = GitResultChannel(work) baseline = complete_result( channel_ready_result(), duration_seconds=1.0, file_size=4, sha256="c" * 64, ) baseline_outcome = channel.publish(baseline) state = TxWatcherState(last_seen_digest=result_digest(baseline)) assert migrate_state_cursor(channel, state) assert state.last_processed_rx_node_commit == baseline_outcome.commit ready = channel_ready_result(published_at=NOW.isoformat()) ready_outcome = channel.publish(ready) duplicate_commit = commit_on_result_branch( work, tmp_path, "Duplicate READY result", allow_empty=True, ) complete = complete_result( ready, published_at=(NOW + timedelta(seconds=1)).isoformat(), duration_seconds=1.0, file_size=4, sha256="d" * 64, ) complete_outcome = channel.publish(complete) prompts: list[str] = [] executor = FakeExecutor(tmp_path / "forbidden.txt") saved_commits: list[str | None] = [] outcomes = process_history_cycle( channel, state, executor, now_function=lambda: NOW + timedelta(seconds=2), input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX", save_function=lambda current: saved_commits.append( current.last_processed_rx_node_commit ), output_function=lambda _text: None, expected_source_head=HEAD, ) assert outcomes == [ (ready_outcome.commit, "CHANNEL_TEST_READY"), (duplicate_commit, "DUPLICATE_SUPPRESSED"), (complete_outcome.commit, "CHANNEL_TEST_COMPLETE"), ] assert saved_commits == [ ready_outcome.commit, duplicate_commit, complete_outcome.commit, ] assert state.channel_test_ready_digest == result_digest(ready) assert state.channel_test_complete_digest == result_digest(complete) assert state.last_seen_commit == complete_outcome.commit assert state.last_processed_rx_node_commit == complete_outcome.commit assert prompts == [] assert executor.calls == 0 def test_malformed_intermediate_commit_blocks_later_history( tmp_path: Path, ) -> None: _remote, work = git_fixture(tmp_path) channel = GitResultChannel(work) baseline = complete_result( channel_ready_result(), duration_seconds=1.0, file_size=4, sha256="c" * 64, ) baseline_outcome = channel.publish(baseline) state = TxWatcherState( last_seen_digest=result_digest(baseline), last_seen_commit=baseline_outcome.commit, last_processed_rx_node_commit=baseline_outcome.commit, ) ready = channel_ready_result() ready_outcome = channel.publish(ready) malformed_commit = commit_on_result_branch( work, tmp_path, "Malformed result", json_text="{not-json\n", ) complete = complete_result( ready, duration_seconds=1.0, file_size=4, sha256="d" * 64, ) complete_outcome = channel.publish(complete) saved_commits: list[str | None] = [] with pytest.raises(ValueError): process_history_cycle( channel, state, FakeExecutor(tmp_path / "forbidden.txt"), now_function=lambda: NOW, input_function=lambda _prompt: "DENY", save_function=lambda current: saved_commits.append( current.last_processed_rx_node_commit ), output_function=lambda _text: None, expected_source_head=HEAD, ) assert saved_commits == [ready_outcome.commit] assert state.last_processed_rx_node_commit == ready_outcome.commit assert state.last_processed_rx_node_commit != malformed_commit assert state.last_processed_rx_node_commit != complete_outcome.commit def test_result_history_rewrite_is_rejected(tmp_path: Path) -> None: _remote, work = git_fixture(tmp_path) channel = GitResultChannel(work) ready_outcome = channel.publish(channel_ready_result()) original_branch_head = _git(["rev-parse", "HEAD"], work) _git( [ "push", "--force", "origin", f"{original_branch_head}:refs/heads/rx-node", ], work, ) with pytest.raises(ResultHistoryRewritten): channel.commits_since(ready_outcome.commit) def test_expired_historical_ready_is_observed_without_authorization( tmp_path: Path, ) -> None: _remote, work = git_fixture(tmp_path) channel = GitResultChannel(work) started = NOW - timedelta(seconds=200) expired = ready_result( capture_started_at=started.isoformat(), expected_capture_end=(started + timedelta(seconds=60)).isoformat(), published_at=(NOW - timedelta(seconds=100)).isoformat(), ) expired_outcome = channel.publish(expired) state = TxWatcherState() prompts: list[str] = [] executor = FakeExecutor(tmp_path / "forbidden.txt") outcomes = process_history_cycle( channel, state, executor, now_function=lambda: NOW, input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX", save_function=lambda _state: None, output_function=lambda _text: None, dry_run=True, expected_source_head=HEAD, ) assert outcomes == [(expired_outcome.commit, "STALE_READY")] assert state.last_processed_rx_node_commit == expired_outcome.commit assert prompts == [] assert executor.calls == 0 def test_real_origin_detection_and_worktree_publication(tmp_path: Path) -> None: remote, work = git_fixture(tmp_path) branch_before = _git(["branch", "--show-current"], work) channel = GitResultChannel(work) assert channel.branch_exists() remote_before = channel.fetch() outcome = channel.publish(ready_result()) assert outcome.status == "PUBLISHED" assert outcome.commit == outcome.remote_head assert outcome.commit != remote_before assert _git(["branch", "--show-current"], work) == branch_before assert not (work / RESULT_JSON_PATH).exists() assert not (work / RESULT_MARKDOWN_PATH).exists() remote_head = _git( ["--git-dir", str(remote), "rev-parse", "refs/heads/rx-node"], tmp_path, ) assert remote_head == outcome.commit worktrees = _git(["worktree", "list", "--porcelain"], work) assert worktrees.count("worktree ") == 1 def test_ready_then_complete_reaches_main_watcher_without_red_prompt( tmp_path: Path, ) -> None: _remote, work = git_fixture(tmp_path) channel = GitResultChannel(work) ready = channel_ready_result() ready_outcome = channel.publish(ready) observed_ready, ready_digest, ready_head = channel.read() prompts: list[str] = [] status, state = handle_result( observed_ready, ready_digest, ready_head, TxWatcherState(), channel, FakeExecutor(tmp_path / "forbidden.txt"), now_function=lambda: NOW, input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX", output_function=lambda _text: None, expected_source_head=HEAD, ) assert status == "CHANNEL_TEST_READY" assert state.channel_test_ready_digest == ready_digest assert prompts == [] complete = complete_result( ready, published_at=(NOW + timedelta(seconds=1)).isoformat(), file_size=4, duration_seconds=1.0, sha256="d" * 64, ) complete_outcome = channel.publish(complete) observed_complete, complete_digest, complete_head = channel.read() status, state = handle_result( observed_complete, complete_digest, complete_head, state, channel, FakeExecutor(tmp_path / "forbidden.txt"), now_function=lambda: NOW + timedelta(seconds=1), input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX", output_function=lambda _text: None, expected_source_head=HEAD, ) assert status == "CHANNEL_TEST_COMPLETE" assert state.channel_test_complete_digest == complete_digest assert ready_outcome.remote_head != complete_outcome.remote_head assert prompts == [] def test_smoke_utility_leaves_complete_not_ready(tmp_path: Path) -> None: _remote, work = git_fixture(tmp_path) ready, complete, expected = run_smoke( work, source_head=HEAD, hold_seconds=0, sleep_function=lambda _seconds: None, ) assert ready.status == "PUBLISHED" assert complete.status == "PUBLISHED" assert ready.remote_head != complete.remote_head observed, _digest, head = GitResultChannel(work).read() assert observed == expected assert observed.state == COMPLETE_STATE assert not observed.rx_ready_for_tx assert head == complete.remote_head 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) assert not channel.branch_exists() with pytest.raises(ResultBranchMissing): channel.publish(ready_result()) def test_push_failure_is_reported_and_remote_head_is_unchanged( 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": return subprocess.CompletedProcess( ["git", *arguments], 1, "", "simulated push failure", ) return super()._run(arguments, cwd=cwd, check=check) channel = FailingPushChannel(work) before = channel.remote_head() with pytest.raises(ResultChannelError, match="simulated push failure"): channel.publish(ready_result()) assert channel.remote_head() == before assert _git(["worktree", "list", "--porcelain"], work).count("worktree ") == 1 def test_rx_watcher_publishes_ready_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) fresh_start = datetime.now(timezone.utc) fresh_result = ready_result( source_head=source_head, published_at=fresh_start.isoformat(), capture_started_at=fresh_start.isoformat(), expected_capture_end=(fresh_start + timedelta(seconds=60)).isoformat(), ) result_path = tmp_path / "ready.json" result_path.write_text( canonical_result_json(fresh_result), 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 "RX RESULT REMOTE HEAD" in output assert "RF TX = 0" in output published, _digest, _head = GitResultChannel(work).read() assert published == fresh_result def test_rx_publisher_refuses_ready_after_capture_window() -> None: with pytest.raises(ValueError, match="window has already ended"): require_live_ready_window( ready_result(), now=NOW + timedelta(seconds=60), )