fix(observatory): refine catalog and replay UX

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 20:36:12 +03:00
parent 023151c186
commit be83283ee0
18 changed files with 2137 additions and 201 deletions
@@ -886,8 +886,8 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(canonical, /primary=\{mediaPane\}/);
assert.match(canonical, /secondary=\{spatialPane/);
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
assert.match(canonical, /resizable=\{splitView\}/);
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonical, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/);
assert.match(visual, /clock: "external"/);
@@ -4,8 +4,12 @@ import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let applyObservatoryCatalogMutationOverlay;
let buildObservatoryCatalog;
let fetchObservatoryCatalog;
let findObservatoryEvidence;
let observatoryCatalogConfirmsEvidenceDeletion;
let reconcileObservatoryCatalogMutationOverlay;
let ObservatoryCatalogContractError;
before(async () => {
@@ -15,8 +19,12 @@ before(async () => {
server: { middlewareMode: true },
});
({
applyObservatoryCatalogMutationOverlay,
buildObservatoryCatalog,
fetchObservatoryCatalog,
findObservatoryEvidence,
observatoryCatalogConfirmsEvidenceDeletion,
reconcileObservatoryCatalogMutationOverlay,
ObservatoryCatalogContractError,
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
});
@@ -233,3 +241,134 @@ test("Observatory projects a typed canonical run only through its exact sourceSe
activation: "explicit",
});
});
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
const catalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("result", "source", "2026-08-29T11:00:00Z")],
);
const original = catalog.items[0].evidence[0];
const projected = applyObservatoryCatalogMutationOverlay(
catalog,
new Map([["result", {
kind: "rename",
displayName: "Операторское имя",
revision: 1,
}]]),
);
const renamed = findObservatoryEvidence(projected, "result");
assert.equal(renamed.label, "Операторское имя");
assert.equal(renamed.sessionId, original.sessionId);
assert.equal(renamed.lab, original.lab);
assert.equal(renamed.recordedRun, original.recordedRun);
assert.equal(projected.items[0].source, catalog.items[0].source);
assert.equal(projected.window.laboratoryCount, 1);
});
test("Observatory tombstone removes only its catalog evidence projection", () => {
const catalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[
evidence("remove", "source", "2026-08-29T12:00:00Z"),
evidence("keep", "source", "2026-08-29T11:00:00Z"),
],
);
const projected = applyObservatoryCatalogMutationOverlay(
catalog,
new Map([["remove", { kind: "delete", revision: 1 }]]),
);
assert.equal(projected.items.length, 1);
assert.equal(projected.items[0].source, catalog.items[0].source);
assert.deepEqual(
projected.items[0].evidence.map(({ sessionId }) => sessionId),
["keep"],
);
assert.equal(projected.window.sourceCount, 1);
assert.equal(projected.window.laboratoryCount, 1);
});
test("Observatory confirms an ambiguous delete only from a complete LAB window", () => {
const bounded = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("other", "source", "2026-08-29T11:00:00Z")],
1,
);
const complete = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[],
);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(bounded, "result"), false);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(complete, "result"), true);
});
test("Observatory reconciliation cannot let a stale in-flight fetch undo a local mutation", () => {
const oldCatalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("result", "source", "2026-08-29T11:00:00Z")],
);
const renameOverlay = new Map([["result", {
kind: "rename",
displayName: "Новое имя",
revision: 1,
}]]);
const staleRename = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
renameOverlay,
0,
);
assert.equal(findObservatoryEvidence(staleRename.catalog, "result").label, "Новое имя");
assert.equal(staleRename.overlay.has("result"), true);
const reconciledOldRename = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
renameOverlay,
1,
);
assert.equal(findObservatoryEvidence(reconciledOldRename.catalog, "result").label, "Новое имя");
assert.equal(reconciledOldRename.overlay.has("result"), true);
const renamedEvidence = evidence("result", "source", "2026-08-29T11:00:00Z");
renamedEvidence.label = "Новое имя";
const confirmedRename = reconcileObservatoryCatalogMutationOverlay(
buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[renamedEvidence],
),
staleRename.overlay,
1,
);
assert.equal(findObservatoryEvidence(confirmedRename.catalog, "result").label, "Новое имя");
assert.equal(confirmedRename.overlay.size, 0);
const deleteOverlay = new Map([["result", { kind: "delete", revision: 2 }]]);
const staleDelete = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
deleteOverlay,
1,
);
assert.equal(findObservatoryEvidence(staleDelete.catalog, "result"), null);
assert.equal(staleDelete.overlay.has("result"), true);
const reconciledOldDelete = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
deleteOverlay,
2,
);
assert.equal(findObservatoryEvidence(reconciledOldDelete.catalog, "result"), null);
assert.equal(reconciledOldDelete.overlay.has("result"), true);
const confirmedDelete = reconcileObservatoryCatalogMutationOverlay(
buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[],
),
staleDelete.overlay,
2,
);
assert.equal(findObservatoryEvidence(confirmedDelete.catalog, "result"), null);
assert.equal(confirmedDelete.overlay.size, 0);
});
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let deleteObservatoryLabProjection;
let renameObservatoryLabProjection;
let ObservatoryCatalogMutationError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
deleteObservatoryLabProjection,
renameObservatoryLabProjection,
ObservatoryCatalogMutationError,
} = await server.ssrLoadModule("/src/core/observatory/catalogMutations.ts"));
});
after(async () => {
await server?.close();
});
const sessionId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const binding = {
kind: "canonical-recorded-rerun",
evidenceSessionId: sessionId,
sourceSessionId: "20260828T130511Z_viewer_live",
resultId: sessionId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
};
test("Observatory rename sends the closed projection-only contract", async () => {
const calls = [];
const result = await renameObservatoryLabProjection(binding, " Новый разбор ", {
fetcher: async (input, init) => {
calls.push({ input, init });
return Response.json({
schema_version: "missioncore.observatory-lab-projection/v1",
session_id: sessionId,
display_name: "Новый разбор",
});
},
});
assert.deepEqual(result, {
schemaVersion: "missioncore.observatory-lab-projection/v1",
sessionId,
displayName: "Новый разбор",
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
assert.equal(calls[0].init.method, "PATCH");
assert.equal(calls[0].init.headers.Accept, "application/json");
assert.equal(calls[0].init.headers["Content-Type"], "application/json");
assert.deepEqual(JSON.parse(calls[0].init.body), {
schema_version: "missioncore.observatory-lab-projection-rename/v1",
display_name: "Новый разбор",
});
});
test("Observatory delete accepts only an empty 204 response", async () => {
const calls = [];
await deleteObservatoryLabProjection(binding, {
fetcher: async (input, init) => {
calls.push({ input, init });
return new Response(null, { status: 204 });
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
assert.equal(calls[0].init.method, "DELETE");
await assert.rejects(
deleteObservatoryLabProjection(binding, {
fetcher: async () => new Response("unexpected", { status: 200 }),
}),
ObservatoryCatalogMutationError,
);
});
test("Observatory mutations fail closed before network access", async () => {
let calls = 0;
const fetcher = async () => {
calls += 1;
return new Response(null, { status: 204 });
};
await assert.rejects(
renameObservatoryLabProjection(binding, " ", { fetcher }),
/от 1 до 160/,
);
await assert.rejects(
deleteObservatoryLabProjection({ ...binding, resultId: "different" }, { fetcher }),
/не допущен/,
);
assert.equal(calls, 0);
});
test("Observatory mutations reject response drift and preserve API detail", async () => {
await assert.rejects(
renameObservatoryLabProjection(binding, "Разбор", {
fetcher: async () => Response.json({
schema_version: "missioncore.observatory-lab-projection/v1",
session_id: sessionId,
display_name: "Разбор",
unexpected: true,
}),
}),
/неизвестные поля/,
);
await assert.rejects(
deleteObservatoryLabProjection(binding, {
fetcher: async () => Response.json({ detail: "Проекция не принадлежит Обсерватории." }, { status: 409 }),
}),
(error) => error instanceof ObservatoryCatalogMutationError
&& error.status === 409
&& error.message === "Проекция не принадлежит Обсерватории.",
);
});
@@ -81,12 +81,22 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.match(workspace, /вне текущего загруженного среза/);
assert.match(workspace, /observatory-notice__copy/);
assert.match(workspace, /observatory-evidence-card/);
assert.match(workspace, /observatory-session-stack/);
assert.match(workspace, /observatory-session-summary__facts/);
assert.match(workspace, /observatory-evidence-card__copy/);
assert.match(
workspace,
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
);
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
assert.match(workspace, /Открыть визуальный разбор/);
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(workspace, /Проверяем точную связь результата/);
assert.match(workspace, /role="alert"/);
assert.match(workspace, /Повторить/);
assert.match(workspace, /Закрыть разбор/);
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
assert.match(workspace, /data-observatory-authority="observation-only"/);
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
@@ -112,5 +122,70 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
styles,
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
);
assert.match(
styles,
/\.observatory-session-summary \{[\s\S]*grid-template-columns:[\s\S]*\.observatory-evidence-card \{[\s\S]*background: var\(--nodedc-glass-control-bg\);/,
);
assert.match(
styles,
/\.observatory-session-stack \{[\s\S]*container-name: observatory-session;[\s\S]*container-type: inline-size;/,
);
assert.match(
styles,
/@container observatory-session \(max-width: 56rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
);
assert.match(
styles,
/@container observatory-session \(max-width: 38rem\) \{[\s\S]*\.observatory-session-summary__facts \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
);
assert.doesNotMatch(styles, /\.observatory-evidence-card[\s\S]*background:\s*(?:#0{3,6}|black|rgb\(0[ ,])/i);
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
});
test("Observatory rename and delete use admitted projection mutations and canonical windows", async () => {
const [workspace, hook] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
]);
assert.match(
workspace,
/deleteObservatoryLabProjection,[\s\S]*renameObservatoryLabProjection,[\s\S]*from "\.\.\/\.\.\/core\/observatory\/catalogMutations"/,
);
assert.match(workspace, /<Window[\s\S]*title="Переименовать лабораторный результат"/);
assert.match(workspace, /<TextField[\s\S]*label="Название"[\s\S]*maxLength=\{160\}/);
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
assert.match(
workspace,
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
);
assert.match(
workspace,
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*evidence\?\.label === reconciliation\.displayName[\s\S]*setRenameTarget\(null\);/,
);
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const teardown = deleteFlow.indexOf("closeReplay();");
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
const refresh = deleteFlow.indexOf("void controller.refresh();");
assert.ok(teardown >= 0 && teardown < remove, "active replay must unmount before projection delete");
assert.ok(remove < tombstone, "local tombstone must follow the exact empty 204");
assert.ok(tombstone < refresh, "reconciliation refresh must follow the local tombstone");
assert.match(deleteFlow, /flushSync\(\(\) => \{[\s\S]*closeReplay\(\);[\s\S]*\}\);/);
assert.match(
deleteFlow,
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*observatoryCatalogConfirmsEvidenceDeletion\([\s\S]*setDeleteTarget\(null\);/,
);
assert.match(workspace, /mutationError[\s\S]*role="alert"/);
assert.match(hook, /activeRequestRef\.current\?\.controller\.abort\(\)/);
assert.match(hook, /requestSequenceRef\.current !== id/);
assert.match(hook, /const requestMutationRevision = mutationRevisionRef\.current/);
assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/);
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
});
@@ -452,7 +452,7 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity
});
test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => {
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource] = await Promise.all([
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource, replayStyles] = await Promise.all([
readFile(
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
"utf8",
@@ -473,6 +473,10 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
"utf8",
),
readFile(
new URL("../src/styles/m4-replay-threat.css", import.meta.url),
"utf8",
),
]);
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
assert.match(resultSource, /M49TgsFullShadowEvidence/);
@@ -493,6 +497,34 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
assert.match(rerunSource, /resolveCanonicalLabReplay/);
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
assert.match(rerunSource, /unifiedPerception: splitView/);
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
assert.match(rerunSource, /presentationReady && playback && playbackController/);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay:not\(\[data-presentation-state="ready"\]\)[\s\S]*laboratory-evidence-viewer__controls/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock[\s\S]*rerun-viewport__camera-lock \{[\s\S]*width: var\(--canonical-rerun-camera-pane, 100%\);/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock\[data-split-view="true"\][\s\S]*width: calc\(var\(--canonical-rerun-camera-pane, 46%\) - 0\.75rem\);/,
);
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
@@ -500,6 +532,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
assert.match(canonicalSource, /secondary=\{spatialPane/);
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/);