Стабилизация переключения Bridge и Quick Connect
Безопасно восстанавливает управляющее подключение и локальные checkpoint без повторных команд сканеру. Добавляет PCAP/Bridge guardrails и регрессионные проверки одношагового переподключения. Известный дефект: после второго подключения интерфейс не присоединяется к новой генерации preview правой камеры. В живой Quick Connect-сессии STOP был принят, но READY не подтвердился до таймаута; автоматический повтор STOP запрещён.
This commit is contained in:
@@ -17,6 +17,7 @@ let physicalRecoveryConnectionDetail;
|
||||
let shouldRenderK1OperationalPanels;
|
||||
let K1ProvisioningPipeline;
|
||||
let RuntimeActionFenceTestContext;
|
||||
let LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS;
|
||||
let emptySearchPresentation;
|
||||
let emptyProvisioningAttemptPresentation;
|
||||
let emptyReadOnlyReconnectPresentation;
|
||||
@@ -127,6 +128,7 @@ before(async () => {
|
||||
({
|
||||
K1ProvisioningPipeline,
|
||||
RuntimeActionFenceTestContext,
|
||||
LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
|
||||
emptySearchPresentation,
|
||||
emptyProvisioningAttemptPresentation,
|
||||
emptyReadOnlyReconnectPresentation,
|
||||
@@ -3245,6 +3247,7 @@ test("replay and local-only stop branches use bounded process copy", () => {
|
||||
|
||||
const stopState = runtimeState();
|
||||
stopState.compatibility.vendor_writes_enabled = false;
|
||||
stopState.acquisition.control_mode = "operator-manual";
|
||||
stopState.connection_policy = {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
facts: { retained_context_is_presence: false },
|
||||
@@ -3272,7 +3275,68 @@ test("replay and local-only stop branches use bounded process copy", () => {
|
||||
assertCanonicalConnectionCopy(stopMarkup);
|
||||
});
|
||||
|
||||
test("physical STOP is fail-closed and both acquisition surfaces fall back to local cleanup", () => {
|
||||
test("software-commanded calibration exposes no teardown until canonical STOP is safe", () => {
|
||||
const state = runtimeState();
|
||||
state.snapshot_runtime_id = "runtime-calibration-001";
|
||||
state.snapshot_revision = 31;
|
||||
state.phase = "starting_live";
|
||||
state.source_mode = "live";
|
||||
state.acquisition.state = "awaiting_external_start";
|
||||
state.acquisition.state_revision = 3;
|
||||
state.application_control_session = {
|
||||
session_generation: 5,
|
||||
state_revision: 7,
|
||||
state: "device-initializing",
|
||||
can_stop: false,
|
||||
control_socket_open: true,
|
||||
};
|
||||
state.connection_policy = {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
facts: { retained_context_is_presence: false },
|
||||
allowed_actions: ["stop-local-receiver"],
|
||||
actions: {
|
||||
"stop-acquisition": {
|
||||
allowed: false,
|
||||
reason_codes: ["supervisor-action-not-allowed"],
|
||||
target_source: "connection-supervisor",
|
||||
required_transport_ref: null,
|
||||
required_connection_mode: null,
|
||||
requires_live_gatt_validation: false,
|
||||
automatic_retry: false,
|
||||
},
|
||||
"stop-local-receiver": {
|
||||
allowed: true,
|
||||
reason_codes: [],
|
||||
target_source: "local-runtime",
|
||||
required_transport_ref: null,
|
||||
required_connection_mode: null,
|
||||
requires_live_gatt_validation: false,
|
||||
automatic_retry: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pipelineMarkup = renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
|
||||
controller: acquisitionController(state),
|
||||
desiredConnectionMode: "bridge",
|
||||
openSpatialScene() {},
|
||||
activateAutomaticSpatialSource() {},
|
||||
}));
|
||||
const spatialMarkup = renderToStaticMarkup(createElement(K1SpatialControlsView, {
|
||||
controller: acquisitionController(state),
|
||||
}));
|
||||
|
||||
assert.match(pipelineMarkup, /Кнопка остановки появится только после подтверждённого SCANNING/);
|
||||
assert.match(spatialMarkup, /K1 калибруется и готовит облако точек/);
|
||||
for (const markup of [pipelineMarkup, spatialMarkup]) {
|
||||
assert.equal(buttonMarkupWithText(markup, "Завершить локальный приём").length, 0);
|
||||
assert.equal(buttonMarkupWithText(markup, "Аварийно завершить локальный приём").length, 0);
|
||||
assert.equal(buttonMarkupWithText(markup, "Остановить устройство и запись").length, 0);
|
||||
assert.equal(buttonMarkupWithText(markup, "Отменить запуск до START").length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("physical STOP is fail-closed across both acquisition surfaces", () => {
|
||||
const state = runtimeState();
|
||||
state.snapshot_runtime_id = "runtime-stop-001";
|
||||
state.snapshot_revision = 40;
|
||||
@@ -3442,13 +3506,15 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo
|
||||
},
|
||||
));
|
||||
assert.doesNotMatch(dismissedFailureMarkup, /Остановить устройство и запись/);
|
||||
assert.match(dismissedFailureMarkup, /Повторная команда устройству не отправляется/);
|
||||
const localButtons = buttonMarkupWithText(
|
||||
dismissedFailureMarkup,
|
||||
"Завершить локальный приём",
|
||||
);
|
||||
assert.equal(localButtons.length, 1);
|
||||
assert.doesNotMatch(localButtons[0], /\bdisabled(?:=|\s|>)/);
|
||||
assert.equal(localButtons.length, 0);
|
||||
assert.match(
|
||||
dismissedFailureMarkup,
|
||||
/Повторная команда и локальное завершение заблокированы/,
|
||||
);
|
||||
assertCanonicalConnectionCopy(dismissedFailureMarkup);
|
||||
|
||||
const classifiedStopState = structuredClone(state);
|
||||
@@ -3485,7 +3551,11 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo
|
||||
activateAutomaticSpatialSource() {},
|
||||
},
|
||||
));
|
||||
assert.match(localCleanupPendingMarkup, /Завершение локального приёма…/);
|
||||
assert.doesNotMatch(localCleanupPendingMarkup, /Завершение локального приёма…/);
|
||||
assert.match(
|
||||
localCleanupPendingMarkup,
|
||||
/Повторная команда и локальное завершение заблокированы/,
|
||||
);
|
||||
assert.doesNotMatch(localCleanupPendingMarkup, /Остановка устройства…/);
|
||||
|
||||
const physicalStopPendingMarkup = renderToStaticMarkup(createElement(
|
||||
@@ -5802,6 +5872,78 @@ test("physical recovery new-device action performs only an explicit scenario res
|
||||
assert.equal(reopenCalls, 0);
|
||||
});
|
||||
|
||||
test("topology switch stays silent for two seconds and then uses only the inline loader", async () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.connection_attempt = null;
|
||||
let resolveReset;
|
||||
const resetSettlement = new Promise((resolve) => {
|
||||
resolveReset = resolve;
|
||||
});
|
||||
const resetRequests = [];
|
||||
const controller = {
|
||||
...provisioningController(state),
|
||||
selectConnectionMode: (request) => {
|
||||
resetRequests.push(request);
|
||||
return resetSettlement;
|
||||
},
|
||||
};
|
||||
const props = {
|
||||
controller,
|
||||
desiredMode: "bridge",
|
||||
onDesiredModeChange: () => undefined,
|
||||
};
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
const timers = [];
|
||||
globalThis.setTimeout = (callback, delay, ...args) => {
|
||||
const timer = { callback, delay, args, cleared: false };
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
};
|
||||
globalThis.clearTimeout = (timer) => {
|
||||
timer.cleared = true;
|
||||
};
|
||||
const harness = createStatefulProvisioningHarness(props);
|
||||
try {
|
||||
let tree = harness.render();
|
||||
harness.flushEffects();
|
||||
const modeSelect = elementByProp(tree, "label", "Способ подключения");
|
||||
assert.ok(modeSelect);
|
||||
modeSelect.props.onChange("quick-connect");
|
||||
assert.equal(resetRequests.length, 1);
|
||||
|
||||
tree = harness.render();
|
||||
harness.flushEffects();
|
||||
let markup = renderToStaticMarkup(tree);
|
||||
assert.doesNotMatch(markup, /Переключаем способ подключения/);
|
||||
assert.doesNotMatch(markup, /Завершение прежней локальной границы/);
|
||||
const progressTimer = timers.find(
|
||||
(timer) => timer.delay === LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
|
||||
);
|
||||
assert.ok(progressTimer);
|
||||
assert.equal(progressTimer.cleared, false);
|
||||
|
||||
progressTimer.callback(...progressTimer.args);
|
||||
tree = harness.render();
|
||||
markup = renderToStaticMarkup(tree);
|
||||
assert.match(markup, /Переключаем способ подключения/);
|
||||
assert.match(markup, /connection-action-progress/);
|
||||
assert.doesNotMatch(markup, /dialog|modal|window/i);
|
||||
|
||||
resolveReset(true);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
tree = harness.render();
|
||||
harness.flushEffects();
|
||||
markup = renderToStaticMarkup(tree);
|
||||
assert.doesNotMatch(markup, /Переключаем способ подключения/);
|
||||
} finally {
|
||||
harness.dispose();
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("physical retirement dispatch refuses a rendered runtime A after runtime B is current", async () => {
|
||||
let currentRuntimeId = "runtime-B";
|
||||
let mutationRequests = 0;
|
||||
@@ -6360,7 +6502,7 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
||||
...provisioningController(coldQuickState),
|
||||
getConnectionRecoveryObservationTarget: () => coldQuickTarget,
|
||||
},
|
||||
desiredMode: "bridge",
|
||||
desiredMode: "quick-connect",
|
||||
});
|
||||
const coldQuickReconnect = buttonMarkupWithText(
|
||||
coldQuickMarkup,
|
||||
@@ -6374,6 +6516,32 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
||||
);
|
||||
assert.match(coldQuickMarkup, /Quick Connect/);
|
||||
|
||||
const quickTargetInsideBridgeMarkup = renderProvisioning({
|
||||
controller: {
|
||||
...provisioningController(coldQuickState),
|
||||
getConnectionRecoveryObservationTarget: () => coldQuickTarget,
|
||||
},
|
||||
desiredMode: "bridge",
|
||||
});
|
||||
assert.equal(
|
||||
buttonMarkupWithText(
|
||||
quickTargetInsideBridgeMarkup,
|
||||
"Переподключиться",
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
buttonMarkupWithText(
|
||||
quickTargetInsideBridgeMarkup,
|
||||
"Найти по Bluetooth",
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
quickTargetInsideBridgeMarkup,
|
||||
/Сохранённое подключение/,
|
||||
);
|
||||
|
||||
const unrelatedMarkup = renderProvisioningWithAttempt({
|
||||
controller: {
|
||||
...controller,
|
||||
@@ -6385,6 +6553,41 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
||||
assert.match(unrelatedMarkup, /Сохранённое подключение/);
|
||||
});
|
||||
|
||||
test("saved Bridge reconnect stays hidden in a fresh Quick Connect draft", () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.connection_attempt = null;
|
||||
const bridgeTarget = recommendedConnectionRecoveryObservationTarget(state);
|
||||
assert.equal(bridgeTarget?.connectionMode, "bridge");
|
||||
const controller = {
|
||||
...provisioningController(state),
|
||||
getConnectionRecoveryObservationTarget: () => bridgeTarget,
|
||||
};
|
||||
|
||||
const quickConnectMarkup = renderProvisioning({
|
||||
controller,
|
||||
desiredMode: "quick-connect",
|
||||
});
|
||||
assert.equal(
|
||||
buttonMarkupWithText(quickConnectMarkup, "Переподключиться").length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
buttonMarkupWithText(quickConnectMarkup, "Найти по Bluetooth").length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(quickConnectMarkup, /Сохранённое подключение/);
|
||||
|
||||
const bridgeMarkup = renderProvisioning({
|
||||
controller,
|
||||
desiredMode: "bridge",
|
||||
});
|
||||
assert.equal(
|
||||
buttonMarkupWithText(bridgeMarkup, "Переподключиться").length,
|
||||
1,
|
||||
);
|
||||
assert.match(bridgeMarkup, /Сохранённое подключение/);
|
||||
});
|
||||
|
||||
test("read-only reconnect keeps its card and loader across a transient projection", async () => {
|
||||
const initialState = terminalConnectionRecoveryState();
|
||||
initialState.connection_attempt = null;
|
||||
|
||||
Reference in New Issue
Block a user