Files
SDR-Rover/tests/test_tx_handoff_acknowledgement.py
2026-09-22 17:43:58 +03:00

584 lines
18 KiB
Python

"""Model-free tests for the MAIN-to-RX acknowledgement channel."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
import subprocess
import pytest
import tools.rx_agent_watcher as rx_agent_watcher
from tools.rf_handoff_channel import (
CHANNEL_TEST_TASK_ID,
COMPLETE_STATE,
GitResultChannel,
READY_STATE,
ResultBranchMissing,
ResultChannelError,
ResultHistoryRewritten,
RxHandoffResult,
result_digest,
)
from tools.tx_agent_watcher import TxWatcherState, process_history_cycle
from tools.tx_handoff_channel import (
CHANNEL_COMPLETE_OBSERVED,
CHANNEL_READY_OBSERVED,
TX_ACK_SCHEMA_VERSION,
TX_AUTHORIZED,
TX_COMPLETE,
TX_STARTED,
TX_RESULT_PATHS,
GitAcknowledgementChannel,
TxHandoffAcknowledgement,
acknowledgement_from_mapping,
build_tx_execution_event,
channel_test_acknowledgements,
)
NOW = datetime(2026, 9, 22, 12, 0, tzinfo=timezone.utc)
SOURCE_HEAD = "a" * 40
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 repository_fixture(
tmp_path: Path,
*,
with_tx_node: bool = True,
) -> tuple[Path, Path, str | None]:
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("fixture\n", encoding="utf-8")
_git(["add", "README.md"], work)
_git(["commit", "-m", "Seed RX result branch"], work)
initial_branch = _git(["branch", "--show-current"], work)
_git(["remote", "add", "origin", str(remote)], work)
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
tx_root: str | None = None
if with_tx_node:
_git(["switch", "--orphan", "tx-node"], work)
_git(["rm", "-rf", "--ignore-unmatch", "."], work)
_git(["commit", "--allow-empty", "-m", "Initialize tx-node"], work)
tx_root = _git(["rev-parse", "HEAD"], work)
_git(["push", "origin", "HEAD:refs/heads/tx-node"], work)
_git(["switch", initial_branch], work)
return remote, work, tx_root
def channel_ready(**changes: object) -> RxHandoffResult:
result = RxHandoffResult(
schema_version=1,
task_id=CHANNEL_TEST_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="MODEL_FREE_FAKE_CAPTURE",
common_clock_verified=False,
source_head=SOURCE_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_complete(
ready: RxHandoffResult,
**changes: object,
) -> RxHandoffResult:
result = replace(
ready,
state=COMPLETE_STATE,
rx_ready_for_tx=False,
process_alive=False,
file_growing=False,
published_at=(NOW + timedelta(seconds=1)).isoformat(),
exit_code=0,
file_size=4,
duration_seconds=1.0,
overruns=0,
usb_errors=0,
file_errors=0,
sha256="b" * 64,
)
return replace(result, **changes)
def acknowledgement(
state: str,
source_commit: str,
source_digest: str,
**changes: object,
) -> TxHandoffAcknowledgement:
result = TxHandoffAcknowledgement(
schema_version=1,
task_id=CHANNEL_TEST_TASK_ID,
state=state,
source_rx_commit=source_commit,
source_rx_digest=source_digest,
source_head=SOURCE_HEAD,
observed_at=NOW.isoformat(),
main_watcher_pid=4321,
rf_tx_authorized=False,
rf_tx_started=False,
rf_tx=0,
)
return replace(result, **changes)
class FakeExecutor:
def __init__(self) -> None:
self.calls = 0
def prepare(self, *_args: object, **_kwargs: object) -> Path:
self.calls += 1
raise AssertionError("channel test must not prepare RF TX")
def test_acknowledgement_schema_binds_digest_and_source_commit() -> None:
valid = acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
raw = valid.__dict__.copy()
assert acknowledgement_from_mapping(raw) == valid
with pytest.raises(ValueError, match="source_rx_commit"):
acknowledgement_from_mapping({**raw, "source_rx_commit": "1" * 39})
with pytest.raises(ValueError, match="source_rx_digest"):
acknowledgement_from_mapping({**raw, "source_rx_digest": "2" * 63})
with pytest.raises(ValueError, match="cannot claim RF TX"):
acknowledgement_from_mapping({**raw, "rf_tx_authorized": True})
def test_tx_events_preserve_authorization_binding_and_timestamps() -> None:
authorization = TxHandoffAcknowledgement(
schema_version=TX_ACK_SCHEMA_VERSION,
task_id="RF001A_OTA_CAPTURE_004",
state=TX_AUTHORIZED,
source_rx_commit="1" * 40,
source_rx_digest="2" * 64,
source_head=SOURCE_HEAD,
observed_at=NOW.isoformat(),
main_watcher_pid=4321,
rf_tx_authorized=True,
rf_tx_started=False,
rf_tx=0,
tx_task_id="RF001A_OTA_TX_004",
)
started_at = (NOW + timedelta(seconds=1)).isoformat()
started = build_tx_execution_event(
authorization,
state=TX_STARTED,
observed_at=started_at,
tx_started_at=started_at,
)
finished_at = (NOW + timedelta(seconds=2.2)).isoformat()
complete = build_tx_execution_event(
authorization,
state=TX_COMPLETE,
observed_at=finished_at,
tx_started_at=started_at,
tx_finished_at=finished_at,
process_exit_code=0,
underruns=0,
errors=0,
)
assert started.source_rx_commit == authorization.source_rx_commit
assert started.source_rx_digest == authorization.source_rx_digest
assert started.rf_tx_started and started.rf_tx == 1
assert complete.tx_finished_at == finished_at
assert complete.tx_process_exit_code == 0
def test_execution_event_publication_requires_authorization_and_start(
tmp_path: Path,
) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path)
channel = GitAcknowledgementChannel(work)
authorization = TxHandoffAcknowledgement(
schema_version=TX_ACK_SCHEMA_VERSION,
task_id="RF001A_OTA_CAPTURE_004",
state=TX_AUTHORIZED,
source_rx_commit="1" * 40,
source_rx_digest="2" * 64,
source_head=SOURCE_HEAD,
observed_at=NOW.isoformat(),
main_watcher_pid=4321,
rf_tx_authorized=True,
rf_tx_started=False,
rf_tx=0,
tx_task_id="RF001A_OTA_TX_004",
)
started_at = (NOW + timedelta(seconds=1)).isoformat()
finished_at = (NOW + timedelta(seconds=2.2)).isoformat()
started = build_tx_execution_event(
authorization,
state=TX_STARTED,
observed_at=started_at,
tx_started_at=started_at,
)
complete = build_tx_execution_event(
authorization,
state=TX_COMPLETE,
observed_at=finished_at,
tx_started_at=started_at,
tx_finished_at=finished_at,
process_exit_code=0,
underruns=0,
errors=0,
)
with pytest.raises(ResultChannelError, match="TX_AUTHORIZED"):
channel.publish_execution_event(started)
channel.publish(authorization)
with pytest.raises(ResultChannelError, match="TX_STARTED"):
channel.publish_execution_event(complete)
started_outcome = channel.publish_execution_event(started)
complete_outcome = channel.publish_execution_event(complete)
assert started_outcome.status == "PUBLISHED"
assert complete_outcome.status == "PUBLISHED"
def test_main_publish_uses_exact_allowlist_and_suppresses_duplicate(
tmp_path: Path,
) -> None:
remote, work, tx_root = repository_fixture(tmp_path)
channel = GitAcknowledgementChannel(work)
item = acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
first = channel.publish(item)
second = channel.publish(item)
assert first.status == "PUBLISHED"
assert second.status == "UNCHANGED"
assert second.commit == first.commit
assert tx_root is not None
assert _git(
["--git-dir", str(remote), "rev-list", "--count", "refs/heads/tx-node"],
tmp_path,
) == "2"
tree = set(
_git(
[
"--git-dir",
str(remote),
"ls-tree",
"-r",
"--name-only",
"refs/heads/tx-node",
],
tmp_path,
).splitlines()
)
assert tree == TX_RESULT_PATHS
def test_two_acknowledgements_between_polls_are_both_confirmed(
tmp_path: Path,
) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path)
channel = GitAcknowledgementChannel(work)
ready_commit = "1" * 40
ready_digest = "2" * 64
complete_commit = "3" * 40
complete_digest = "4" * 64
ready_outcome = channel.publish(
acknowledgement(CHANNEL_READY_OBSERVED, ready_commit, ready_digest)
)
complete_outcome = channel.publish(
acknowledgement(
CHANNEL_COMPLETE_OBSERVED,
complete_commit,
complete_digest,
observed_at=(NOW + timedelta(seconds=1)).isoformat(),
)
)
status = channel_test_acknowledgements(
channel,
expected_source_head=SOURCE_HEAD,
ready_source_commit=ready_commit,
ready_source_digest=ready_digest,
complete_source_commit=complete_commit,
complete_source_digest=complete_digest,
)
assert status.ready_confirmed
assert status.complete_confirmed
assert status.ready_ack_commit == ready_outcome.commit
assert status.complete_ack_commit == complete_outcome.commit
@pytest.mark.parametrize("wrong_field", ["digest", "commit"])
def test_wrong_binding_does_not_confirm_ready(
tmp_path: Path,
wrong_field: str,
) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path)
channel = GitAcknowledgementChannel(work)
expected_commit = "1" * 40
expected_digest = "2" * 64
channel.publish(
acknowledgement(
CHANNEL_READY_OBSERVED,
"9" * 40 if wrong_field == "commit" else expected_commit,
"8" * 64 if wrong_field == "digest" else expected_digest,
)
)
status = channel_test_acknowledgements(
channel,
expected_source_head=SOURCE_HEAD,
ready_source_commit=expected_commit,
ready_source_digest=expected_digest,
complete_source_commit="3" * 40,
complete_source_digest="4" * 64,
)
assert not status.ready_confirmed
assert not status.complete_confirmed
def test_stale_acknowledgement_with_old_source_head_is_rejected(
tmp_path: Path,
) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path)
channel = GitAcknowledgementChannel(work)
channel.publish(
acknowledgement(
CHANNEL_READY_OBSERVED,
"1" * 40,
"2" * 64,
source_head="f" * 40,
)
)
status = channel_test_acknowledgements(
channel,
expected_source_head=SOURCE_HEAD,
ready_source_commit="1" * 40,
ready_source_digest="2" * 64,
complete_source_commit="3" * 40,
complete_source_digest="4" * 64,
)
assert not status.ready_confirmed
def test_missing_tx_node_is_blocked(tmp_path: Path) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path, with_tx_node=False)
channel = GitAcknowledgementChannel(work)
with pytest.raises(ResultBranchMissing):
channel_test_acknowledgements(
channel,
expected_source_head=SOURCE_HEAD,
)
def test_acknowledgement_push_failure_preserves_remote_head(
tmp_path: Path,
) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path)
class FailingChannel(GitAcknowledgementChannel):
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 = FailingChannel(work)
before = channel.remote_head()
with pytest.raises(ResultChannelError, match="simulated push failure"):
channel.publish(
acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
)
assert channel.remote_head() == before
def test_tx_node_history_rewrite_is_rejected(tmp_path: Path) -> None:
_remote, work, tx_root = repository_fixture(tmp_path)
channel = GitAcknowledgementChannel(work)
outcome = channel.publish(
acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
)
assert tx_root is not None
_git(
["push", "--force", "origin", f"{tx_root}:refs/heads/tx-node"],
work,
)
with pytest.raises(ResultHistoryRewritten):
channel.commits_since(outcome.commit)
def test_fake_two_way_flow_is_event_safe_and_uses_no_tx(
tmp_path: Path,
) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path)
rx_channel = GitResultChannel(work)
tx_channel = GitAcknowledgementChannel(work)
ready = channel_ready()
ready_outcome = rx_channel.publish(ready)
complete = channel_complete(ready)
complete_outcome = rx_channel.publish(complete)
state = TxWatcherState()
executor = FakeExecutor()
outcomes = process_history_cycle(
rx_channel,
state,
executor,
now_function=lambda: NOW + timedelta(seconds=2),
input_function=lambda _prompt: "AUTHORIZE RF TX",
save_function=lambda _state: None,
output_function=lambda _text: None,
expected_source_head=SOURCE_HEAD,
acknowledgement_publisher=tx_channel,
main_watcher_pid=4321,
)
assert outcomes == [
(ready_outcome.commit, "CHANNEL_TEST_READY"),
(complete_outcome.commit, "CHANNEL_TEST_COMPLETE"),
]
confirmations = channel_test_acknowledgements(
tx_channel,
expected_source_head=SOURCE_HEAD,
ready_source_commit=ready_outcome.commit,
ready_source_digest=result_digest(ready),
complete_source_commit=complete_outcome.commit,
complete_source_digest=result_digest(complete),
)
assert confirmations.ready_confirmed
assert confirmations.complete_confirmed
assert executor.calls == 0
assert state.last_authorized_digest is None
def test_rx_watcher_confirms_both_main_acknowledgements(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path)
rx_channel = GitResultChannel(work)
tx_channel = GitAcknowledgementChannel(work)
ready = channel_ready()
ready_outcome = rx_channel.publish(ready)
complete = channel_complete(ready)
complete_outcome = rx_channel.publish(complete)
tx_channel.publish(
acknowledgement(
CHANNEL_READY_OBSERVED,
ready_outcome.commit,
result_digest(ready),
)
)
tx_channel.publish(
acknowledgement(
CHANNEL_COMPLETE_OBSERVED,
complete_outcome.commit,
result_digest(complete),
observed_at=(NOW + timedelta(seconds=1)).isoformat(),
)
)
monkeypatch.setattr(
rx_agent_watcher,
"CHANNEL_TEST_READY_COMMIT",
ready_outcome.commit,
)
monkeypatch.setattr(
rx_agent_watcher,
"CHANNEL_TEST_READY_DIGEST",
result_digest(ready),
)
monkeypatch.setattr(
rx_agent_watcher,
"CHANNEL_TEST_COMPLETE_COMMIT",
complete_outcome.commit,
)
monkeypatch.setattr(
rx_agent_watcher,
"CHANNEL_TEST_COMPLETE_DIGEST",
result_digest(complete),
)
state = rx_agent_watcher.WatcherState()
output: list[str] = []
rx_agent_watcher.confirm_main_channel_test(
work,
state,
output_function=output.append,
)
assert state.channel_test_ready_digest == result_digest(ready)
assert state.channel_test_complete_digest == result_digest(complete)
assert state.channel_test_ready_ack_commit is not None
assert state.channel_test_complete_ack_commit is not None
assert "MAIN READY RECEIVED = CONFIRMED" in output
assert "MAIN COMPLETE RECEIVED = CONFIRMED" in output
def test_ack_push_failure_does_not_advance_rx_cursor(tmp_path: Path) -> None:
_remote, work, _tx_root = repository_fixture(tmp_path)
rx_channel = GitResultChannel(work)
ready_outcome = rx_channel.publish(channel_ready())
class FailingPublisher:
def publish(self, acknowledgement: TxHandoffAcknowledgement) -> object:
del acknowledgement
raise ResultChannelError("simulated acknowledgement push failure")
state = TxWatcherState()
with pytest.raises(ResultChannelError, match="simulated acknowledgement"):
process_history_cycle(
rx_channel,
state,
FakeExecutor(),
now_function=lambda: NOW,
input_function=lambda _prompt: "DENY",
save_function=lambda _state: None,
output_function=lambda _text: None,
expected_source_head=SOURCE_HEAD,
acknowledgement_publisher=FailingPublisher(),
)
assert state.last_processed_rx_node_commit is None
assert state.channel_test_ready_digest is None
assert ready_outcome.commit != state.last_seen_commit