574 lines
17 KiB
Python
574 lines
17 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,
|
|
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,
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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_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)
|