Files
SDR-Rover/tests/test_rf_handoff_coordination.py
2026-09-22 11:46:53 +03:00

828 lines
25 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 (
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,
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=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 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=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" 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=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"),
(
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(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,
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=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,
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=120)).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)
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 "RX RESULT REMOTE HEAD" in output
assert "RF TX = 0" in output
published, _digest, _head = GitResultChannel(work).read()
assert published == ready_result(source_head=source_head)