feat(observatory): add laboratory setup preflight
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchObservatoryLaboratorySetups;
|
||||
let preflightObservatoryLaboratorySetup;
|
||||
let ObservatoryLaboratorySetupContractError;
|
||||
|
||||
const authority = {
|
||||
commands_enabled: false,
|
||||
actuation_allowed: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
production_accepted: false,
|
||||
};
|
||||
|
||||
function definition() {
|
||||
return {
|
||||
schema_version: "missioncore.observatory-run-definition/v1",
|
||||
definition_id: "m49-tgs-full-shadow",
|
||||
version: 1,
|
||||
work_id: "m49-tgs-full-shadow",
|
||||
source: {
|
||||
session_id: "source-a",
|
||||
label: "RAVNOVES00",
|
||||
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||
},
|
||||
configuration: [{ role: "primary-profile", sha256: "a".repeat(64) }],
|
||||
authority,
|
||||
definition_sha256: "b".repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return {
|
||||
setup_id: "m49-tgs-full-shadow-v1",
|
||||
display_name: "M4.9T5",
|
||||
description: "Сохранённый полный source-paced shadow.",
|
||||
origin: "archived-definition",
|
||||
source: {
|
||||
session_id: "source-a",
|
||||
label: "RAVNOVES00",
|
||||
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||
},
|
||||
run_definition: definition(),
|
||||
compatibility: { compatible: true, reasons: [] },
|
||||
executor: {
|
||||
contour_id: "worker-006",
|
||||
state: "not-installed",
|
||||
reason_code: "laboratory-runner-adapter-not-installed",
|
||||
reason: "Адаптер не установлен.",
|
||||
},
|
||||
preserved_results: [{
|
||||
result_id: `m49-tgs-full-shadow-${"c".repeat(64)}`,
|
||||
result_kind: "recorded-source-paced-tgs-shadow",
|
||||
relation: "primary-visual",
|
||||
access: "legacy-lab",
|
||||
created_at_utc: "2026-08-26T20:27:19Z",
|
||||
observatory_projection_available: false,
|
||||
}],
|
||||
preflight: {
|
||||
outcome: "blocked",
|
||||
action: "open-legacy",
|
||||
reason: "Точный результат сохранён в legacy LAB.",
|
||||
submission_allowed: false,
|
||||
existing_result_ids: [],
|
||||
},
|
||||
authority,
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchObservatoryLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("Observatory setup catalog keeps definition identity separate from executor availability", async () => {
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
};
|
||||
|
||||
const catalog = await fetchObservatoryLaboratorySetups("source-a", { fetcher });
|
||||
assert.equal(catalog.setups[0].runDefinition.definitionSha256, "b".repeat(64));
|
||||
assert.equal(catalog.setups[0].executor.state, "not-installed");
|
||||
assert.equal(catalog.setups[0].preflight.submissionAllowed, false);
|
||||
assert.equal(catalog.setups[0].preservedResults[0].access, "legacy-lab");
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observatory/laboratory-setups?source_session_id=source-a",
|
||||
);
|
||||
assert.equal(calls[0].init.method, "GET");
|
||||
});
|
||||
|
||||
test("Observatory preflight sends the exact selected definition and never submits a run", async () => {
|
||||
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
})).setups[0];
|
||||
let request;
|
||||
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async (input, init) => {
|
||||
request = { input: String(input), init };
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: selected.setupId,
|
||||
definition_sha256: "b".repeat(64),
|
||||
outcome: "blocked",
|
||||
submission_allowed: false,
|
||||
checks: [{
|
||||
check_id: "executor",
|
||||
outcome: "fail",
|
||||
reason_code: "laboratory-runner-adapter-not-installed",
|
||||
message: "Адаптер не установлен.",
|
||||
}],
|
||||
existing_result_ids: [],
|
||||
executor: setup().executor,
|
||||
authority,
|
||||
}), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(request.input, "/api/v1/observatory/run-preflights");
|
||||
assert.equal(request.init.method, "POST");
|
||||
assert.deepEqual(JSON.parse(request.init.body), {
|
||||
schema_version: "missioncore.observatory-run-preflight-request/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: "m49-tgs-full-shadow-v1",
|
||||
definition_sha256: "b".repeat(64),
|
||||
});
|
||||
assert.equal(preflight.outcome, "blocked");
|
||||
assert.equal(preflight.submissionAllowed, false);
|
||||
});
|
||||
|
||||
test("Observatory setup contract rejects authority escalation and response drift", async () => {
|
||||
const escalated = setup();
|
||||
escalated.authority = { ...authority, commands_enabled: true };
|
||||
await assert.rejects(
|
||||
fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [escalated],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
|
||||
const drifted = setup();
|
||||
drifted.unexpected = true;
|
||||
await assert.rejects(
|
||||
fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [drifted],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
|
||||
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
})).setups[0];
|
||||
await assert.rejects(
|
||||
preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: selected.setupId,
|
||||
definition_sha256: "c".repeat(64),
|
||||
outcome: "blocked",
|
||||
submission_allowed: false,
|
||||
checks: [],
|
||||
existing_result_ids: [],
|
||||
executor: setup().executor,
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
});
|
||||
@@ -189,3 +189,24 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
||||
assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/);
|
||||
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
|
||||
});
|
||||
|
||||
test("Observatory configurator keeps selection and preflight read-only", async () => {
|
||||
const [workspace, setupDetail, setupHook] = await Promise.all([
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("workspaces/observatory/ObservatorySetupDetail.tsx"),
|
||||
read("core/observatory/useObservatoryLaboratorySetups.ts"),
|
||||
]);
|
||||
|
||||
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
||||
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
||||
assert.match(workspace, /Показан последний каталог сетапов/);
|
||||
assert.match(setupDetail, /Проверить совместимость/);
|
||||
assert.doesNotMatch(
|
||||
setupDetail,
|
||||
/Рассчитать лабораторию|definitionSha256|<small>\{result\.resultId\}/,
|
||||
);
|
||||
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
|
||||
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
|
||||
assert.match(setupHook, /preflightRequest\.current !== request/);
|
||||
assert.doesNotMatch(`${workspace}\n${setupDetail}\n${setupHook}`, /\/api\/v1\/observatory\/runs/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user