165 lines
5.8 KiB
Python
165 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from k1link.web.device_lifecycle import (
|
|
AcquisitionRecord,
|
|
OperationJournal,
|
|
new_acquisition_id,
|
|
)
|
|
|
|
|
|
def test_operation_journal_reuses_an_idempotent_request_without_storing_input() -> None:
|
|
journal = OperationJournal(clock=lambda: datetime(2026, 7, 16, 12, 0, tzinfo=UTC))
|
|
|
|
first, created = journal.begin(
|
|
"network.provision",
|
|
idempotency_key="provision-owned-k1-once",
|
|
device_id="device-test",
|
|
device_session_id="device-session-test",
|
|
deadline_seconds=45,
|
|
)
|
|
repeated, repeated_created = journal.begin(
|
|
"network.provision",
|
|
idempotency_key="provision-owned-k1-once",
|
|
device_id="device-test",
|
|
device_session_id="device-session-test",
|
|
deadline_seconds=45,
|
|
)
|
|
|
|
assert created is True
|
|
assert repeated_created is False
|
|
assert repeated is first
|
|
assert "password" not in str(journal.snapshot()).lower()
|
|
|
|
|
|
def test_operation_journal_records_ack_progress_and_terminal_result() -> None:
|
|
journal = OperationJournal()
|
|
operation, _ = journal.begin("acquisition.prepare", cancellable=True)
|
|
|
|
journal.transition(
|
|
operation.operation_id,
|
|
"running",
|
|
stage_code="compatibility-check",
|
|
message_code="acquisition.compatibility_check",
|
|
)
|
|
journal.transition(
|
|
operation.operation_id,
|
|
"succeeded",
|
|
stage_code="prepared",
|
|
message_code="acquisition.prepared",
|
|
result={"acquisition_id": "acq-test"},
|
|
evidence_refs=("evidence-manifest-test",),
|
|
)
|
|
|
|
document = journal.get(operation.operation_id).as_dict()
|
|
assert document["schema_version"] == "missioncore.operation-snapshot/v1alpha2"
|
|
assert document["status"] == "succeeded"
|
|
assert document["sequence"] == 3
|
|
assert document["result"] == {"acquisition_id": "acq-test"}
|
|
assert document["evidence_refs"] == ["evidence-manifest-test"]
|
|
assert document["completed_at"] is not None
|
|
|
|
with pytest.raises(ValueError, match="already terminal"):
|
|
journal.transition(
|
|
operation.operation_id,
|
|
"failed",
|
|
stage_code="late-failure",
|
|
message_code="operation.failed",
|
|
)
|
|
|
|
|
|
def test_operation_id_and_idempotency_key_cannot_be_rebound() -> None:
|
|
journal = OperationJournal()
|
|
operation, _ = journal.begin("device.inspect", idempotency_key="same-key")
|
|
|
|
with pytest.raises(ValueError, match="another action"):
|
|
journal.begin("network.provision", idempotency_key="same-key")
|
|
with pytest.raises(ValueError, match="another action"):
|
|
journal.begin("sensor.catalog.read", operation_id=operation.operation_id)
|
|
|
|
|
|
def test_idempotency_identity_cannot_be_reused_for_different_request_fingerprint() -> None:
|
|
journal = OperationJournal()
|
|
operation, _ = journal.begin(
|
|
"network.provision",
|
|
idempotency_key="same-request-only",
|
|
request_fingerprint="fingerprint-a",
|
|
)
|
|
|
|
repeated, created = journal.begin(
|
|
"network.provision",
|
|
idempotency_key="same-request-only",
|
|
request_fingerprint="fingerprint-a",
|
|
)
|
|
assert created is False
|
|
assert repeated is operation
|
|
|
|
with pytest.raises(ValueError, match="different request"):
|
|
journal.begin(
|
|
"network.provision",
|
|
idempotency_key="same-request-only",
|
|
request_fingerprint="fingerprint-b",
|
|
)
|
|
with pytest.raises(ValueError, match="different request"):
|
|
journal.begin(
|
|
"network.provision",
|
|
operation_id=operation.operation_id,
|
|
request_fingerprint="fingerprint-b",
|
|
)
|
|
|
|
assert "fingerprint-a" not in str(journal.snapshot())
|
|
|
|
|
|
def test_bounded_journal_never_evicts_an_operation_that_is_still_active() -> None:
|
|
journal = OperationJournal(max_records=1)
|
|
active, _ = journal.begin("acquisition.start", request_fingerprint="start")
|
|
completed, _ = journal.begin("device.inspect", request_fingerprint="inspect")
|
|
journal.transition(
|
|
completed.operation_id,
|
|
"succeeded",
|
|
stage_code="completed",
|
|
message_code="device.inspect.completed",
|
|
)
|
|
|
|
assert journal.get(active.operation_id).status == "accepted"
|
|
assert [item["operation_id"] for item in journal.snapshot(limit=10)] == [active.operation_id]
|
|
|
|
|
|
def test_non_cancellable_operation_rejects_cancel_request() -> None:
|
|
journal = OperationJournal()
|
|
operation, _ = journal.begin("network.provision", cancellable=False)
|
|
|
|
with pytest.raises(ValueError, match="not cancellable"):
|
|
journal.request_cancel(operation.operation_id)
|
|
|
|
|
|
def test_acquisition_record_keeps_device_session_profile_and_manual_control_separate() -> None:
|
|
acquisition = AcquisitionRecord(
|
|
acquisition_id=new_acquisition_id(),
|
|
device_id="device-test",
|
|
device_session_id="device-session-test",
|
|
compatibility_profile_id="xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
|
control_mode="operator-manual",
|
|
requested_streams=("spatial.point-cloud.live", "spatial.pose.live"),
|
|
target_host="192.168.1.20",
|
|
duration_seconds=60,
|
|
evidence_policy="required",
|
|
)
|
|
|
|
acquisition.transition("prepared", message_code="acquisition.prepared")
|
|
acquisition.transition(
|
|
"awaiting_external_start",
|
|
message_code="acquisition.operator_start_required",
|
|
operator_instructions=("Дважды нажмите физическую кнопку устройства.",),
|
|
)
|
|
|
|
document = acquisition.as_dict()
|
|
assert document["schema_version"] == "missioncore.acquisition-snapshot/v1alpha2"
|
|
assert document["control_mode"] == "operator-manual"
|
|
assert document["state"] == "awaiting_external_start"
|
|
assert document["device_id"] != document["device_session_id"]
|
|
assert document["operator_instructions"]
|