diff --git a/README.md b/README.md index 9405dfe..2b29e62 100644 --- a/README.md +++ b/README.md @@ -156,9 +156,10 @@ physical-acceptance transport reproducing MQTT 3.1.1 `clean_session=false`, keepalive 60, exact response subscriptions, QoS2 completion and the five response-gated batches. It consumes operation keys before publish and poisons unknown outcomes without -retry. One background owner services that socket between separate connection, -workspace, project, START, STOP and steady-green UI actions; navigation cannot -emit a command. The first operator-present physical attempt on 2026-07-18 emitted only +retry. One background owner services that socket through response-gated +connection, workspace, project, START, STOP and steady-green stages. The normal +UI collapses only the pre-START controls into one explicit operator intent; +navigation cannot emit a command. The first operator-present physical attempt on 2026-07-18 emitted only the first six bootstrap requests, then failed closed while correlating the third response batch; START was not emitted. A second one-shot attempt correlated all ten pre-START requests and the START acknowledgement, but incorrectly collapsed @@ -172,11 +173,10 @@ immediate status read 12 and state-gated reads 13–14 onto one continuously own MQTT session. No captured operator delay is a protocol timer: the final reads wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits for live `READY` plus physical standby confirmation. Standalone START and STOP -are both rejected. The same socket is serviced during every operator/UI wait, -and the short START/STOP permits are created only at their respective button -confirmations rather than at connection time. This executor remains uninstalled and is not wired to the -facade/UI; physical command acceptance is still paused. The temporary laboratory -Keychain item was deleted. +are both rejected. The same socket is serviced throughout launch and STOP +confirmation, and no captured human delay is replayed as a protocol timer. +Plugin v0.5.0 wires this executor to the facade and plugin UI; physical command +acceptance remains an operator-run gate. This locked bootstrap is repeatable in the current workspace, not yet a standalone release install. The frontend consumes sibling `file:` packages from diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md index efc01a8..2bdc2bc 100644 --- a/docs/01_IMPLEMENTATION_PLAN.md +++ b/docs/01_IMPLEMENTATION_PLAN.md @@ -21,7 +21,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result. | Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented | | Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet | | Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process | -| K1 application control | OPERATOR ACCEPTANCE READY — the failed collapsed path and standalone START/STOP remain disabled. Plugin v0.5.0 installs one continuous control-session owner behind separate connection, workspace, project, START, STOP and steady-green UI actions. No wall-clock transition, automatic retry, navigation-triggered command or repository-test equipment I/O exists. A full operator-run physical cycle and native-project verification remain open | +| K1 application control | OPERATOR ACCEPTANCE READY — the failed protocol-collapsed path and standalone START/STOP remain disabled. Plugin v0.5.0 installs one continuous control-session owner; one explicit UI launch intent advances connection, workspace, project and START only across live response barriers, while STOP and steady-green confirmation remain separate. No wall-clock transition, automatic retry, navigation-triggered command or repository-test equipment I/O exists. A full operator-run physical cycle and native-project verification remain open | | Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates | USB project copying remains optional ground truth rather than a blocker for the diff --git a/docs/06_K1_LIVE_VIEWER.md b/docs/06_K1_LIVE_VIEWER.md index dd3d726..dad5fa6 100644 --- a/docs/06_K1_LIVE_VIEWER.md +++ b/docs/06_K1_LIVE_VIEWER.md @@ -115,23 +115,24 @@ directly and use their sibling metadata receive timestamps when present. complete visible-device list. 4. Enter the existing router SSID/password and explicitly authorize the reviewed provisioning write. The backend does not retry the write automatically. -5. Confirm operator presence, closed LixelGO, storage/power and steady green; - choose **Подключить управление K1**. This emits only connection operations - 1–6 and then waits indefinitely for the next UI action. -6. Choose **Открыть рабочее пространство K1** for operation 7. -7. Enter the required project name and choose **Сохранить проект и подготовить - локальный приём**. Mission Core normalizes it, prepares evidence reception - and releases only operations 8–10; the name itself is not sent yet. -8. After the project step is ready, choose **Запустить сканирование K1** once. - The project name is carried by canonical START. Mission Core opens +5. Enter the required project name, confirm operator presence, closed LixelGO, + storage/power and steady green, then choose **Запустить сканирование и + локальный приём** once. +6. Mission Core emits operations 1–6, waits for their correlated device + responses, then performs operation 7 and operations 8–10 behind the same + operator intent. No captured human pause is treated as a timer and no device + command is automatically retried. +7. Mission Core prepares evidence reception before emitting the single + canonical START. The project name is carried only by that START; there is no + separate K1 project-name write. Mission Core opens **Наблюдение → Пространственная сцена** and shows calibration while the same control socket waits for `SCANNING + project + init_ready`. -9. Real point frames and pose/trajectory updates then appear in the embedded +8. Real point frames and pose/trajectory updates then appear in the embedded viewport. Do not use the physical button or open LixelGO during this cycle. -10. When K1 emits `ModelingReport`, the same plugin block shows its current scan - time, route distance and speed. These are device reports, not values inferred - from a browser timer or accumulated across a device counter reset. -11. Choose **Остановить устройство и запись** in the scene. The +9. When K1 emits `ModelingReport`, the same plugin block shows its current scan + time, route distance and speed. These are device reports, not values inferred + from a browser timer or accumulated across a device counter reset. +10. Choose **Остановить устройство и запись** in the scene. The plugin-commanded path emits one canonical STOP on the original socket, waits for live unbound READY, and asks the operator to confirm the constant green indicator before capture, camera archive and summaries are finalized. @@ -142,7 +143,8 @@ per-message metadata and a hash summary. Repository-level `sessions/*_viewer_live` is legacy import-only evidence and is never selected by the current writer. The data connector remains subscribe-only. Application requests are isolated in the separate canonical control-session owner and can -be emitted only by explicit staged UI actions. +be emitted only after the explicit launch or STOP intent and the corresponding +live response gate. The recovered command substrate byte-matches the ten observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling @@ -151,8 +153,8 @@ private application-level OpenAPI authority is kept separate from the transient vendor ID/serial returned by the BLE-selected K1. The fixed Keychain loader and dormant shadow coordinator remain available for inspection. Plugin v0.5.0 additionally installs a continuous interactive MQTT -owner: operations 1–6 at connection, 7 at workspace entry, 8–10 at project -preparation, then separately permitted START and STOP. Physical START/STOP and +owner: one UI launch intent advances operations 1–6, 7, 8–10 and START through +their live response barriers; STOP remains a separate explicit action. Physical START/STOP and durable native-project completion still require operator acceptance. ## Automatic Rerun source and lifecycle diff --git a/docs/adr/0012-device-bound-k1-command-authority.md b/docs/adr/0012-device-bound-k1-command-authority.md index 6159123..d1b1ff7 100644 --- a/docs/adr/0012-device-bound-k1-command-authority.md +++ b/docs/adr/0012-device-bound-k1-command-authority.md @@ -138,9 +138,12 @@ connection time. Plugin v0.5.0 installs this boundary through `application_session.py`. One background owner is the only thread allowed to -touch the control MQTT client. REST/UI actions release exactly one checkpoint: -session open (1–6), workspace entry (7), project preparation (8–10), START, STOP -and steady-green confirmation. Preparing local reception happens before the +touch the control MQTT client. The standard UI turns one explicit launch intent +into response-gated REST actions for session open (1–6), workspace entry (7), +project preparation (8–10) and START. This is control compression, not protocol +compression: each next action waits for the exact server/device phase, and +local polling never retries or times a K1 write. STOP and steady-green +confirmation remain separate operator actions. Preparing local reception happens before the START event is released. After STOP, local capture remains owned until live unbound READY and explicit visual confirmation. Page close, model switching, polling and service state reads cannot emit START/STOP; model switching is diff --git a/plugins/xgrids-k1/README.md b/plugins/xgrids-k1/README.md index ec34594..90f3f01 100644 --- a/plugins/xgrids-k1/README.md +++ b/plugins/xgrids-k1/README.md @@ -32,9 +32,9 @@ The plugin owns: - K1-specific operator instructions and compatibility tests. - the scoped React `device.connection` contribution and its BLE/Wi-Fi and acquisition pipeline UI. -- the interactive canonical application-control session: one socket owner, - separate workspace/project/START/STOP UI checkpoints, live status gates, - single-action physical permits and no automatic retry. +- the interactive canonical application-control session: one socket owner, one + operator launch intent, response-gated workspace/project/START stages, a + separate STOP confirmation, live status gates and no automatic retry. The plugin does not own: @@ -85,11 +85,13 @@ uv run python plugins/xgrids-k1/profile_loader.py ``` Plugin v0.5.0 installs that reviewed transport behind explicit plugin actions. -Opening control performs only the connection-owned operations 1–6. Workspace -entry releases operation 7; saving the project and preparing local reception -releases operations 8–10; a separate START click carries the project name and -then waits for bound `SCANNING + project + init_ready` before operations 13–14. -STOP is separately permitted, never retried, and keeps the same socket until K1 +The normal UI accepts the project name and one explicit launch confirmation. +It then performs operations 1–6, operation 7 and operations 8–10 only after the +previous response barrier succeeds, prepares local reception, and emits one +START carrying the project name. Local state polling controls only when the +next reviewed action may be requested; it never schedules a device command by +elapsed time. START waits for bound `SCANNING + project + init_ready` before +operations 13–14. STOP is separately permitted, never retried, and keeps the same socket until K1 reports unbound READY and the operator confirms a steady green indicator. The admin CLI still provisions the fixed Keychain item through Apple's hidden prompt without accepting the private value as an argument. No physical command diff --git a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx index 21432b7..8d91d2d 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx @@ -53,11 +53,8 @@ export function K1AcquisitionPipeline({ const { state, pendingAction, - openApplicationControlSession, - enterApplicationWorkspace, closeApplicationControlSession, - prepareAcquisition, - startPreparedAcquisition, + startCanonicalAcquisition, startReplay, stop, confirmStoppedAtSteadyGreen, @@ -66,7 +63,6 @@ export function K1AcquisitionPipeline({ const [sessionIntent, setSessionIntent] = useState("live"); const [projectName, setProjectName] = useState(""); const [projectNameTouched, setProjectNameTouched] = useState(false); - const [liveHost, setLiveHost] = useState(""); const [replayPath, setReplayPath] = useState(""); const [replaySpeed, setReplaySpeed] = useState("1"); const [replayLoop, setReplayLoop] = useState(false); @@ -105,7 +101,6 @@ export function K1AcquisitionPipeline({ : activeAcquisition ? "live" : sessionIntent; - const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host); const sourceLabel = sourceStatusLabel(state); const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed"; const sourceTone: StatusTone = @@ -120,30 +115,37 @@ export function K1AcquisitionPipeline({ () => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })), [sessionLocked], ); - - const openControl = async () => { - if (!physicalAcceptanceConfirmed || !state?.k1_ip) return; - const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"; - await openApplicationControlSession({ - ...PHYSICAL_ACCEPTANCE, - timezone_name: timezoneName, - }); - }; - - const prepareProject = async () => { - setProjectNameTouched(true); - if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return; - const targetHost = liveHost.trim(); - await prepareAcquisition({ - project_name: projectNameValidation.value, - ...(targetHost ? { host: targetHost } : {}), - compatibility_attestation: EXACT_PROFILE_ATTESTATION, - }); - }; + const preparedCanonicalLaunch = + preparedAcquisition?.control_mode === "plugin-commanded"; + const launchBlockedByAcquisition = + activeAcquisition !== null && !preparedCanonicalLaunch; + const controlRetryBlocked = + controlPhase === "failed" && control?.can_open !== true; const startLive = async () => { + setProjectNameTouched(true); + if ( + !profileConfirmed || + !physicalAcceptanceConfirmed || + !state?.k1_ip || + sourceRuntimeBusy || + launchBlockedByAcquisition || + controlRetryBlocked || + projectNameValidation.error + ) return; + const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC"; await runAutomaticSpatialSourceStart( - () => startPreparedAcquisition(PHYSICAL_ACCEPTANCE), + () => startCanonicalAcquisition({ + control: { + ...PHYSICAL_ACCEPTANCE, + timezone_name: timezoneName, + }, + acquisition: { + project_name: projectNameValidation.value, + compatibility_attestation: EXACT_PROFILE_ATTESTATION, + }, + physicalAcceptance: PHYSICAL_ACCEPTANCE, + }), activateAutomaticSpatialSource, openSpatialScene, ); @@ -179,96 +181,64 @@ export function K1AcquisitionPipeline({ /> {effectiveSessionIntent === "live" ? (
- {(control?.can_open ?? controlPhase === "idle") ? ( - <> - - - - ) : null} - {controlPhase === "connection-ready" ? ( - - ) : null} - {["workspace-ready", "project-requested", "project-ready"].includes(controlPhase) || preparedAcquisition ? ( - <> - { - setProjectName(event.target.value); - setProjectNameTouched(true); - }} - onBlur={() => setProjectName((value) => normalizeProjectName(value))} - disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null} - autoComplete="off" - aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined} - description={projectNameTouched && projectNameValidation.error - ? projectNameValidation.error - : "Сохранение имени само по себе не отправляет START на устройство."} - placeholder="Например, TEST001" - /> - setLiveHost(event.target.value)} - disabled={controlPhase !== "workspace-ready" || preparedAcquisition !== null} - spellCheck={false} - placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"} - /> - - ) : null} - {controlPhase === "workspace-ready" && !preparedAcquisition ? ( - - ) : null} - {controlPhase === "project-ready" && preparedAcquisition ? ( - - ) : null} - {["connection-ready", "workspace-ready", "project-ready"].includes(controlPhase) && !activeAcquisition ? ( + { + setProjectName(event.target.value); + setProjectNameTouched(true); + }} + onBlur={() => setProjectName((value) => normalizeProjectName(value))} + disabled={isBusy || preparedAcquisition !== null || sourceRuntimeBusy} + autoComplete="off" + aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined} + description={projectNameTouched && projectNameValidation.error + ? projectNameValidation.error + : "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."} + placeholder="Например, TEST001" + /> + + + {control?.control_socket_open && !activeAcquisition && !isBusy ? ( ) : null}

@@ -277,16 +247,16 @@ export function K1AcquisitionPipeline({ : controlPhase === "failed" ? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}` : controlPhase === "connecting" - ? "Выполняются только операции 1–6 записанного диалога. Следующий этап начнётся только по вашей кнопке." + ? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1." : controlPhase === "workspace-requested" - ? "Выполняется только операция входа в рабочее пространство." + ? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство." : controlPhase === "project-requested" - ? "Выполняются операции открытия проектного шага; имя ещё не отправляется на K1." + ? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется на K1." : controlPhase === "start-requested" || controlPhase === "initializing" - ? "Калибровка оборудования. Не перемещайте K1; никаких временных автопереходов и повторов нет." + ? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет." : controlPhase === "scanning" ? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене." - : "Каждый этап канонического диалога запускается отдельным действием оператора."} + : "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."}

) : ( diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts index e2bb3f9..e1592cb 100644 --- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts +++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts @@ -12,6 +12,7 @@ import { type OperatorPresenceConfirmation, type PrepareAcquisitionRequest, type ReplayRequest, + type XgridsApplicationControlPhase, type XgridsK1State, } from "./api"; import { @@ -35,6 +36,50 @@ export type PendingAction = | "camera" | "viewer"; +export interface CanonicalLiveStartRequest { + control: OpenApplicationControlSessionRequest; + acquisition: PrepareAcquisitionRequest; + physicalAcceptance: OperatorPresenceConfirmation; +} + +const CONTROL_STATE_READ_INTERVAL_MS = 250; + +function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase { + return state.application_control_session?.state ?? "idle"; +} + +function controlFailure(state: XgridsK1State): ApiError { + const failure = state.application_control_session?.failure; + return new ApiError( + failure?.message + ? `Канонический диалог K1 остановлен: ${failure.message}` + : "Канонический диалог K1 остановлен до запуска сканирования.", + ); +} + +async function waitForControlPhase( + expected: XgridsApplicationControlPhase, + acceptState: (state: XgridsK1State) => void, +): Promise { + for (;;) { + const nextState = await xgridsK1Api.getState(); + acceptState(nextState); + const phase = controlPhase(nextState); + if (phase === expected) return nextState; + if (phase === "failed") throw controlFailure(nextState); + if (["idle", "closed", "completed"].includes(phase)) { + throw new ApiError( + `Управляющая сессия K1 завершилась до ожидаемого этапа «${expected}».`, + ); + } + // This cadence only reads local server state. It never schedules, retries, + // or times a K1 command; every next write remains gated by device response. + await new Promise((resolve) => { + window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS); + }); + } +} + function messageFor(error: unknown): string { if (error instanceof ApiError) { const message = localizeRuntimeMessage(error.message) ?? error.message; @@ -189,6 +234,102 @@ export function useXgridsK1Runtime(enabled: boolean) { [run], ); + const startCanonicalAcquisition = useCallback( + (request: CanonicalLiveStartRequest) => + run("live", async () => { + let nextState = await xgridsK1Api.getState(); + acceptState(nextState); + const plan = liveStartPlan(nextState); + if (plan === "blocked") { + throw new ApiError("Сначала завершите текущий приём или повтор записи."); + } + if (plan === "already-running") return nextState; + + for (;;) { + const phase = controlPhase(nextState); + const acquisition = nextState.acquisition; + + if (["idle", "closed", "completed"].includes(phase)) { + if (acquisition && !isTerminalAcquisitionState(acquisition.state)) { + throw new ApiError( + "Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.", + ); + } + nextState = await xgridsK1Api.openApplicationControlSession(request.control); + acceptState(nextState); + continue; + } + + if (phase === "failed") { + if (nextState.application_control_session?.can_open !== true) { + throw controlFailure(nextState); + } + nextState = await xgridsK1Api.openApplicationControlSession(request.control); + acceptState(nextState); + continue; + } + + if (phase === "connecting") { + nextState = await waitForControlPhase("connection-ready", acceptState); + continue; + } + + if (phase === "connection-ready") { + nextState = await xgridsK1Api.enterApplicationWorkspace({ + operator_confirmed: true, + }); + acceptState(nextState); + continue; + } + + if (phase === "workspace-requested") { + nextState = await waitForControlPhase("workspace-ready", acceptState); + continue; + } + + if (phase === "workspace-ready") { + if (!acquisition || isTerminalAcquisitionState(acquisition.state)) { + nextState = await xgridsK1Api.prepareAcquisition(request.acquisition); + acceptState(nextState); + continue; + } + if (acquisition.state !== "prepared" || acquisition.control_mode !== "plugin-commanded") { + throw new ApiError( + "Текущая подготовка не принадлежит канонической control-сессии K1.", + ); + } + nextState = await waitForControlPhase("project-ready", acceptState); + continue; + } + + if (phase === "project-requested") { + nextState = await waitForControlPhase("project-ready", acceptState); + continue; + } + + if (phase === "project-ready") { + if (!acquisition || acquisition.state !== "prepared") { + throw new ApiError("Локальный приём не подготовлен к каноническому START."); + } + nextState = await xgridsK1Api.startAcquisition({ + acquisition_id: acquisition.acquisition_id, + expected_state_revision: acquisition.state_revision, + physical_acceptance: request.physicalAcceptance, + }); + acceptState(nextState); + return nextState; + } + + if (["start-requested", "initializing", "scanning"].includes(phase)) { + return nextState; + } + + throw new ApiError(`Запуск K1 недоступен из состояния «${phase}».`); + } + }), + [acceptState, run], + ); + const prepareAcquisition = useCallback( (request: PrepareAcquisitionRequest) => run("live", async () => { @@ -417,6 +558,7 @@ export function useXgridsK1Runtime(enabled: boolean) { openApplicationControlSession, enterApplicationWorkspace, closeApplicationControlSession, + startCanonicalAcquisition, prepareAcquisition, startPreparedAcquisition, startReplay, diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index 62dbd99..4af7b52 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -220,7 +220,16 @@ class OperatorPresenceRequest(StrictRequest): expected_physical_state_confirmed: Literal[True] def confirmation(self) -> OperatorPresenceConfirmation: - return OperatorPresenceConfirmation(**self.model_dump()) + # Build the wire-safety confirmation explicitly. Subclasses add + # transport metadata (for example ``timezone_name`` on control-session + # open), which must never leak into this narrower dataclass contract. + return OperatorPresenceConfirmation( + operator_present=self.operator_present, + owner_controlled_device=self.owner_controlled_device, + lixelgo_closed=self.lixelgo_closed, + battery_storage_confirmed=self.battery_storage_confirmed, + expected_physical_state_confirmed=self.expected_physical_state_confirmed, + ) class PrepareAcquisitionRequest(OperationContextRequest): diff --git a/tests/test_frontend_plugin_boundaries.py b/tests/test_frontend_plugin_boundaries.py index b0cf2ee..9679193 100644 --- a/tests/test_frontend_plugin_boundaries.py +++ b/tests/test_frontend_plugin_boundaries.py @@ -113,7 +113,7 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity() assert 'id: "xgrids-k1-rerun-live"' not in runtime_source -def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> None: +def test_xgrids_live_copy_exposes_one_response_gated_launch_intent() -> None: repository_root = Path(__file__).resolve().parents[1] connection_source = ( repository_root @@ -125,11 +125,14 @@ def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> Non / "K1AcquisitionPipeline.tsx" ).read_text("utf-8") - assert "Подключить управление K1" in connection_source - assert "Открыть рабочее пространство K1" in connection_source - assert "Сохранить проект и подготовить локальный приём" in connection_source - assert "Запустить сканирование K1" in connection_source - assert "никаких временных автопереходов и повторов нет" in connection_source + assert "Запустить сканирование и локальный приём" in connection_source + assert "Имя войдёт в единственный канонический START" in connection_source + assert "этапы идут строго по записанному порядку и только после ответов K1" in ( + connection_source + ) + assert "Подключить управление K1" not in connection_source + assert "Открыть рабочее пространство K1" not in connection_source + assert "Сохранить проект и подготовить локальный приём" not in connection_source assert "Остановить локальный приём" in connection_source assert "Физическое состояние сканера остаётся неизвестным" in connection_source diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index af7f27b..70ddb9d 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -16,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.facade import ( CameraPreviewSelectRequest, CompatibilityAttestationRequest, ConnectRequest, + OpenApplicationControlSessionRequest, OperatorPresenceRequest, PrepareAcquisitionRequest, ShadowApplicationControlArmRequest, @@ -169,6 +170,19 @@ PHYSICAL_ACCEPTANCE = OperatorPresenceRequest( ) +def test_control_session_transport_metadata_does_not_leak_into_confirmation() -> None: + request = OpenApplicationControlSessionRequest( + operator_present=True, + owner_controlled_device=True, + lixelgo_closed=True, + battery_storage_confirmed=True, + expected_physical_state_confirmed=True, + timezone_name="Europe/Moscow", + ) + + assert request.confirmation() == PHYSICAL_ACCEPTANCE.confirmation() + + def service_with_fake_runtime( tmp_path: Path, ) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]: