584 lines
18 KiB
Python
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
|