fix(observatory): refine catalog and replay UX
This commit is contained in:
@@ -241,7 +241,7 @@ export function CanonicalRecordedLabReplay<
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView}
|
||||
resizable={splitView && !unifiedContent}
|
||||
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||
/>
|
||||
{mediaMode === "none" && spatialMode === "none" ? (
|
||||
|
||||
+180
-46
@@ -1,11 +1,26 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Icon, SegmentedControl } from "@nodedc/ui-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunViewportStatus,
|
||||
} from "../RerunViewport";
|
||||
import {
|
||||
CanonicalRecordedLabReplay,
|
||||
@@ -31,6 +46,9 @@ interface CanonicalReplayLaunch {
|
||||
replay: CanonicalLabReplayDescriptor;
|
||||
}
|
||||
|
||||
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
||||
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
|
||||
|
||||
export function CanonicalVegetationRerunReplay({
|
||||
resultId,
|
||||
review,
|
||||
@@ -52,6 +70,7 @@ export function CanonicalVegetationRerunReplay({
|
||||
initialMediaMode: "video",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||
@@ -59,13 +78,92 @@ export function CanonicalVegetationRerunReplay({
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||
const viewerFrameRef = useRef<HTMLDivElement>(null);
|
||||
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
|
||||
const previousSplitViewRef = useRef(splitView);
|
||||
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
||||
|
||||
const stopNativeSplitTracking = useCallback(() => {
|
||||
nativeSplitTrackingCleanupRef.current?.();
|
||||
nativeSplitTrackingCleanupRef.current = null;
|
||||
}, []);
|
||||
|
||||
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!splitView
|
||||
|| event.button !== 0
|
||||
|| !(event.target instanceof HTMLCanvasElement)
|
||||
) return;
|
||||
const frame = viewerFrameRef.current;
|
||||
if (!frame) return;
|
||||
const bounds = frame.getBoundingClientRect();
|
||||
if (bounds.width <= 0) return;
|
||||
const dividerX = bounds.left
|
||||
+ bounds.width * nativeSplitPercentRef.current / 100;
|
||||
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
|
||||
|
||||
stopNativeSplitTracking();
|
||||
const pointerId = event.pointerId;
|
||||
const update = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
const currentBounds = frame.getBoundingClientRect();
|
||||
if (currentBounds.width <= 0) return;
|
||||
const next = Math.min(90, Math.max(
|
||||
10,
|
||||
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
|
||||
));
|
||||
nativeSplitPercentRef.current = next;
|
||||
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
|
||||
};
|
||||
const stop = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
stopNativeSplitTracking();
|
||||
};
|
||||
window.addEventListener("pointermove", update, true);
|
||||
window.addEventListener("pointerup", stop, true);
|
||||
window.addEventListener("pointercancel", stop, true);
|
||||
nativeSplitTrackingCleanupRef.current = () => {
|
||||
window.removeEventListener("pointermove", update, true);
|
||||
window.removeEventListener("pointerup", stop, true);
|
||||
window.removeEventListener("pointercancel", stop, true);
|
||||
};
|
||||
}, [splitView, stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitView) stopNativeSplitTracking();
|
||||
const launchSha256 = launch?.replay.sha256 ?? null;
|
||||
if (
|
||||
trackedLaunchSha256Ref.current !== launchSha256
|
||||
|| (splitView && !previousSplitViewRef.current)
|
||||
) {
|
||||
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
|
||||
}
|
||||
trackedLaunchSha256Ref.current = launchSha256;
|
||||
previousSplitViewRef.current = splitView;
|
||||
const cameraPanePercent = mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100;
|
||||
viewerFrameRef.current?.style.setProperty(
|
||||
"--canonical-rerun-camera-pane",
|
||||
`${cameraPanePercent}%`,
|
||||
);
|
||||
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLaunch(null);
|
||||
setLaunchError(null);
|
||||
setPlayback(null);
|
||||
setPlaybackController(null);
|
||||
setViewerStatus("idle");
|
||||
void resolveObservationSessionReplay(review.sessionId, {
|
||||
signal: controller.signal,
|
||||
maximumWaitMs: 30 * 60 * 1000,
|
||||
@@ -87,7 +185,13 @@ export function CanonicalVegetationRerunReplay({
|
||||
return () => controller.abort();
|
||||
}, [resultId, review.sessionId]);
|
||||
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const presentationReady = playbackController !== null
|
||||
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
||||
const presentationState = launchError || viewerStatus === "error"
|
||||
? "error"
|
||||
: presentationReady
|
||||
? "ready"
|
||||
: "loading";
|
||||
const sceneSettings = useMemo(() => ({
|
||||
...defaultSceneSettings,
|
||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
||||
@@ -123,7 +227,7 @@ export function CanonicalVegetationRerunReplay({
|
||||
cuboids3d: false,
|
||||
},
|
||||
perceptionRetryGeneration: 0,
|
||||
lockPerceptionCameraInteraction: false,
|
||||
lockPerceptionCameraInteraction: mediaMode !== null,
|
||||
}) : null;
|
||||
|
||||
const mediaLayerControls = (
|
||||
@@ -188,9 +292,9 @@ export function CanonicalVegetationRerunReplay({
|
||||
</Button>
|
||||
);
|
||||
|
||||
const transport = playback && playbackController ? (
|
||||
const transport = presentationReady && playback && playbackController ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline"
|
||||
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
@@ -205,45 +309,75 @@ export function CanonicalVegetationRerunReplay({
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<CanonicalRecordedLabReplay
|
||||
label="RAVNOVES004TREE · канонический повтор Rerun"
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "ВИДЕО" },
|
||||
{ value: "camera", label: "КАМЕРА" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "ПЛАН" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer
|
||||
unifiedContent={profile ? (
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
/>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role={launchError ? "alert" : "status"}>
|
||||
{launchError ?? "Готовим единый кэш канонического повтора RAV004…"}
|
||||
</div>
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
<div
|
||||
className="canonical-vegetation-rerun-replay"
|
||||
data-presentation-state={presentationState}
|
||||
aria-busy={presentationState === "loading"}
|
||||
>
|
||||
<CanonicalRecordedLabReplay
|
||||
label="RAVNOVES004TREE · канонический повтор Rerun"
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "ВИДЕО" },
|
||||
{ value: "camera", label: "КАМЕРА" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "ПЛАН" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer
|
||||
unifiedContent={profile ? (
|
||||
<div
|
||||
ref={viewerFrameRef}
|
||||
className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||
data-split-view={splitView ? "true" : undefined}
|
||||
style={{
|
||||
"--canonical-rerun-camera-pane": `${
|
||||
mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100
|
||||
}%`,
|
||||
} as CSSProperties}
|
||||
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
|
||||
>
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onStatusChange={setViewerStatus}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
/>
|
||||
</div>
|
||||
) : launchError ? (
|
||||
<div className="l3-visual-audit__state" role="alert">
|
||||
{launchError}
|
||||
</div>
|
||||
) : (
|
||||
<div aria-hidden="true" />
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
deckOverlays={presentationState === "loading" ? (
|
||||
<div className="canonical-vegetation-rerun-replay__loading">
|
||||
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||
</div>
|
||||
) : undefined}
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,27 @@ export interface ObservatoryCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
export type ObservatoryCatalogMutation =
|
||||
| {
|
||||
readonly kind: "rename";
|
||||
readonly displayName: string;
|
||||
readonly revision: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "delete";
|
||||
readonly revision: number;
|
||||
};
|
||||
|
||||
export type ObservatoryCatalogMutationOverlay = ReadonlyMap<
|
||||
string,
|
||||
ObservatoryCatalogMutation
|
||||
>;
|
||||
|
||||
export interface ObservatoryCatalogReconciliation {
|
||||
readonly catalog: ObservatoryCatalog;
|
||||
readonly overlay: ObservatoryCatalogMutationOverlay;
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -42,6 +63,95 @@ export class ObservatoryCatalogContractError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function findObservatoryEvidence(
|
||||
catalog: ObservatoryCatalog,
|
||||
sessionId: string,
|
||||
): ObservatoryEvidence | null {
|
||||
for (const item of catalog.items) {
|
||||
const evidence = item.evidence.find(
|
||||
(candidate) => candidate.sessionId === sessionId,
|
||||
);
|
||||
if (evidence) return evidence;
|
||||
}
|
||||
return catalog.unresolvedEvidence.find(
|
||||
(candidate) => candidate.sessionId === sessionId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function observatoryCatalogConfirmsEvidenceDeletion(
|
||||
catalog: ObservatoryCatalog,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
return !catalog.window.laboratoryLimitReached
|
||||
&& findObservatoryEvidence(catalog, sessionId) === null;
|
||||
}
|
||||
|
||||
export function applyObservatoryCatalogMutationOverlay(
|
||||
catalog: ObservatoryCatalog,
|
||||
overlay: ObservatoryCatalogMutationOverlay,
|
||||
): ObservatoryCatalog {
|
||||
if (overlay.size === 0) return catalog;
|
||||
|
||||
const removedSessionIds = new Set<string>();
|
||||
const projectEvidence = (
|
||||
evidence: ObservatoryEvidence,
|
||||
): ObservatoryEvidence | null => {
|
||||
const mutation = overlay.get(evidence.sessionId);
|
||||
if (!mutation) return evidence;
|
||||
if (mutation.kind === "delete") {
|
||||
removedSessionIds.add(evidence.sessionId);
|
||||
return null;
|
||||
}
|
||||
return evidence.label === mutation.displayName
|
||||
? evidence
|
||||
: { ...evidence, label: mutation.displayName };
|
||||
};
|
||||
const projectEvidenceList = (
|
||||
evidence: readonly ObservatoryEvidence[],
|
||||
): ObservatoryEvidence[] => evidence.flatMap((candidate) => {
|
||||
const projected = projectEvidence(candidate);
|
||||
return projected ? [projected] : [];
|
||||
});
|
||||
|
||||
const items = catalog.items.map((item) => ({
|
||||
...item,
|
||||
evidence: projectEvidenceList(item.evidence),
|
||||
}));
|
||||
const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence);
|
||||
return {
|
||||
...catalog,
|
||||
items,
|
||||
unresolvedEvidence,
|
||||
window: {
|
||||
...catalog.window,
|
||||
laboratoryCount: Math.max(
|
||||
0,
|
||||
catalog.window.laboratoryCount - removedSessionIds.size,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileObservatoryCatalogMutationOverlay(
|
||||
serverCatalog: ObservatoryCatalog,
|
||||
overlay: ObservatoryCatalogMutationOverlay,
|
||||
requestMutationRevision: number,
|
||||
): ObservatoryCatalogReconciliation {
|
||||
const remaining = new Map(overlay);
|
||||
for (const [sessionId, mutation] of overlay) {
|
||||
if (mutation.revision > requestMutationRevision) continue;
|
||||
const evidence = findObservatoryEvidence(serverCatalog, sessionId);
|
||||
const confirmed = mutation.kind === "rename"
|
||||
? evidence?.label === mutation.displayName
|
||||
: observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId);
|
||||
if (confirmed) remaining.delete(sessionId);
|
||||
}
|
||||
return {
|
||||
catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining),
|
||||
overlay: remaining,
|
||||
};
|
||||
}
|
||||
|
||||
function newestFirst(left: string, right: string): number {
|
||||
return Date.parse(right) - Date.parse(left);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ObservatoryRecordedRunBinding } from "./recordedRun";
|
||||
|
||||
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1";
|
||||
const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1";
|
||||
|
||||
export type ObservatoryCatalogMutationFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface ObservatoryLabProjectionMutationResult {
|
||||
readonly schemaVersion: typeof PROJECTION_SCHEMA;
|
||||
readonly sessionId: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogMutationError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryCatalogMutationError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameObservatoryLabProjection(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
displayName: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryCatalogMutationFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLabProjectionMutationResult> {
|
||||
const sessionId = admittedProjectionId(binding);
|
||||
const normalizedName = displayName.trim();
|
||||
if (normalizedName.length < 1 || normalizedName.length > 160) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Название лабораторного результата должно содержать от 1 до 160 символов.",
|
||||
);
|
||||
}
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schema_version: RENAME_SCHEMA,
|
||||
display_name: normalizedName,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
"Не удалось переименовать лабораторный результат.",
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 200) {
|
||||
throw apiError(
|
||||
body,
|
||||
`Переименование лабораторного результата вернуло HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const decoded = decodeProjection(body);
|
||||
if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер не подтвердил точное переименование выбранного результата.",
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export async function deleteObservatoryLabProjection(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryCatalogMutationFetch;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const sessionId = admittedProjectionId(binding);
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
"Не удалось удалить лабораторный результат из Обсерватории.",
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw apiError(
|
||||
body,
|
||||
`Удаление лабораторного результата вернуло HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул данные после подтверждённого удаления лабораторного результата.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
|
||||
const sessionId = binding.evidenceSessionId;
|
||||
if (
|
||||
binding.kind !== "canonical-recorded-rerun"
|
||||
|| binding.activation !== "explicit"
|
||||
|| binding.viewerProfile !== "recorded-session"
|
||||
|| binding.timeline !== "session_time"
|
||||
|| binding.resultId !== sessionId
|
||||
|| binding.sourceSessionId === sessionId
|
||||
|| !SAFE_SESSION_ID.test(sessionId)
|
||||
) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Выбранный результат не допущен к изменению каталога Обсерватории.",
|
||||
);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function request(
|
||||
fetcher: ObservatoryCatalogMutationFetch,
|
||||
input: string,
|
||||
init: RequestInit,
|
||||
fallback: string,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryCatalogMutationError(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(body: unknown, fallback: string, status: number): Error {
|
||||
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||
return new ObservatoryCatalogMutationError(body.detail.trim(), status);
|
||||
}
|
||||
if (typeof body === "string" && body.trim()) {
|
||||
return new ObservatoryCatalogMutationError(body.trim(), status);
|
||||
}
|
||||
return new ObservatoryCatalogMutationError(fallback, status);
|
||||
}
|
||||
|
||||
function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult {
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул некорректное подтверждение лабораторного результата.",
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const expected = ["display_name", "schema_version", "session_id"];
|
||||
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Подтверждение лабораторного результата содержит неизвестные поля.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.schema_version !== PROJECTION_SCHEMA
|
||||
|| typeof value.session_id !== "string"
|
||||
|| !SAFE_SESSION_ID.test(value.session_id)
|
||||
|| typeof value.display_name !== "string"
|
||||
|| value.display_name.trim() !== value.display_name
|
||||
|| value.display_name.length < 1
|
||||
|| value.display_name.length > 160
|
||||
) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул неподдерживаемый контракт лабораторного результата.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
schemaVersion: PROJECTION_SCHEMA,
|
||||
sessionId: value.session_id,
|
||||
displayName: value.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
applyObservatoryCatalogMutationOverlay,
|
||||
fetchObservatoryCatalog,
|
||||
reconcileObservatoryCatalogMutationOverlay,
|
||||
type ObservatoryCatalog,
|
||||
type ObservatoryCatalogMutationOverlay,
|
||||
} from "./catalog";
|
||||
|
||||
export type ObservatoryCatalogState =
|
||||
@@ -16,9 +19,23 @@ export interface ObservatoryCatalogController {
|
||||
readonly catalog: ObservatoryCatalog | null;
|
||||
readonly state: ObservatoryCatalogState;
|
||||
readonly error: string | null;
|
||||
readonly refresh: () => void;
|
||||
readonly refresh: () => Promise<ObservatoryCatalog | null>;
|
||||
readonly applyEvidenceRename: (
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
) => void;
|
||||
readonly applyEvidenceDeletion: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
interface ActiveCatalogRequest {
|
||||
readonly id: number;
|
||||
readonly controller: AbortController;
|
||||
}
|
||||
|
||||
type ObservatoryCatalogMutationDraft =
|
||||
| { readonly kind: "rename"; readonly displayName: string }
|
||||
| { readonly kind: "delete" };
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
@@ -33,31 +50,99 @@ export function useObservatoryCatalog(): ObservatoryCatalogController {
|
||||
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
|
||||
const [state, setState] = useState<ObservatoryCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const catalogRef = useRef<ObservatoryCatalog | null>(null);
|
||||
const overlayRef = useRef<ObservatoryCatalogMutationOverlay>(new Map());
|
||||
const mutationRevisionRef = useRef(0);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const activeRequestRef = useRef<ActiveCatalogRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCatalog = useCallback(async (): Promise<ObservatoryCatalog | null> => {
|
||||
const id = requestSequenceRef.current + 1;
|
||||
requestSequenceRef.current = id;
|
||||
activeRequestRef.current?.controller.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequestRef.current = { id, controller };
|
||||
const requestMutationRevision = mutationRevisionRef.current;
|
||||
|
||||
setState(catalogRef.current ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
void fetchObservatoryCatalog({ signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
catalogRef.current = next;
|
||||
setCatalog(next);
|
||||
setState("ready");
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (controller.signal.aborted || isAbortError(loadError)) return;
|
||||
setError(errorMessage(loadError));
|
||||
setState("error");
|
||||
try {
|
||||
const serverCatalog = await fetchObservatoryCatalog({
|
||||
signal: controller.signal,
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [generation]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setGeneration((current) => current + 1);
|
||||
if (controller.signal.aborted || requestSequenceRef.current !== id) {
|
||||
return null;
|
||||
}
|
||||
const reconciled = reconcileObservatoryCatalogMutationOverlay(
|
||||
serverCatalog,
|
||||
overlayRef.current,
|
||||
requestMutationRevision,
|
||||
);
|
||||
overlayRef.current = reconciled.overlay;
|
||||
catalogRef.current = reconciled.catalog;
|
||||
activeRequestRef.current = null;
|
||||
setCatalog(reconciled.catalog);
|
||||
setState("ready");
|
||||
return reconciled.catalog;
|
||||
} catch (loadError: unknown) {
|
||||
if (
|
||||
controller.signal.aborted
|
||||
|| requestSequenceRef.current !== id
|
||||
|| isAbortError(loadError)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
activeRequestRef.current = null;
|
||||
setError(errorMessage(loadError));
|
||||
setState("error");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { catalog, state, error, refresh };
|
||||
useEffect(() => {
|
||||
void loadCatalog();
|
||||
return () => {
|
||||
requestSequenceRef.current += 1;
|
||||
activeRequestRef.current?.controller.abort();
|
||||
activeRequestRef.current = null;
|
||||
};
|
||||
}, [loadCatalog]);
|
||||
|
||||
const applyMutation = useCallback((
|
||||
sessionId: string,
|
||||
mutation: ObservatoryCatalogMutationDraft,
|
||||
) => {
|
||||
const revision = mutationRevisionRef.current + 1;
|
||||
mutationRevisionRef.current = revision;
|
||||
const overlay = new Map(overlayRef.current);
|
||||
overlay.set(sessionId, { ...mutation, revision });
|
||||
overlayRef.current = overlay;
|
||||
if (!catalogRef.current) return;
|
||||
const projected = applyObservatoryCatalogMutationOverlay(
|
||||
catalogRef.current,
|
||||
overlay,
|
||||
);
|
||||
catalogRef.current = projected;
|
||||
setCatalog(projected);
|
||||
}, []);
|
||||
|
||||
const applyEvidenceRename = useCallback((
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
) => {
|
||||
applyMutation(sessionId, { kind: "rename", displayName });
|
||||
}, [applyMutation]);
|
||||
|
||||
const applyEvidenceDeletion = useCallback((sessionId: string) => {
|
||||
applyMutation(sessionId, { kind: "delete" });
|
||||
}, [applyMutation]);
|
||||
|
||||
return {
|
||||
catalog,
|
||||
state,
|
||||
error,
|
||||
refresh: loadCatalog,
|
||||
applyEvidenceRename,
|
||||
applyEvidenceDeletion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,53 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.m4-replay-threat-visual__pane-toolbar,
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.laboratory-evidence-viewer__controls,
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.laboratory-evidence-viewer__transport {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay[data-presentation-state="loading"]
|
||||
.m4-replay-threat-visual__unified-content {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock
|
||||
.rerun-viewport__camera-lock {
|
||||
width: var(--canonical-rerun-camera-pane, 100%);
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
|
||||
.rerun-viewport__camera-lock {
|
||||
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__loading {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--nodedc-canvas);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -282,6 +329,10 @@
|
||||
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__timeline .observation-timeline__playback {
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__overlay {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice,
|
||||
.observatory-session-summary > header,
|
||||
.observatory-evidence > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -27,7 +26,6 @@
|
||||
|
||||
.observatory-lead > div,
|
||||
.observatory-catalog-bar__copy,
|
||||
.observatory-session-summary header > div,
|
||||
.observatory-evidence header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -77,64 +75,91 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-session-grid {
|
||||
.observatory-session-stack {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
gap: 0.9rem;
|
||||
container-name: observatory-session;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.observatory-session-summary,
|
||||
.observatory-evidence {
|
||||
.observatory-session-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1.1fr) minmax(22rem, 1.6fr) minmax(14rem, auto);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary dl,
|
||||
.observatory-evidence-list dl {
|
||||
.observatory-session-summary__identity {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary__identity h3 {
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-session-summary__identity code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-session-summary dl > div,
|
||||
.observatory-evidence-list dl > div {
|
||||
.observatory-session-summary__facts > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7.5rem, 0.42fr) minmax(0, 1fr);
|
||||
gap: 0.85rem;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
gap: 0.24rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary dt,
|
||||
.observatory-evidence-list dt {
|
||||
.observatory-session-summary__facts dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-session-summary dd,
|
||||
.observatory-evidence-list dd {
|
||||
.observatory-session-summary__facts dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-session-summary code,
|
||||
.observatory-evidence-list code {
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
.observatory-session-summary__end {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
justify-items: end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.observatory-modalities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observatory-evidence {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.observatory-evidence > header {
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.observatory-evidence-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -142,11 +167,51 @@
|
||||
|
||||
.observatory-evidence-card {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-height: 4.25rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__heading,
|
||||
.observatory-evidence-card__action,
|
||||
.observatory-evidence-card__icon {
|
||||
display: grid;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
place-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-icon-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.16rem;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy strong,
|
||||
.observatory-evidence-card__copy span,
|
||||
.observatory-evidence-card__copy small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__actions,
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state,
|
||||
.observatory-replay-state__actions {
|
||||
@@ -156,28 +221,17 @@
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__heading,
|
||||
.observatory-evidence-card__action,
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__heading > div,
|
||||
.observatory-evidence-card__action > span,
|
||||
.observatory-replay__header > div,
|
||||
.observatory-replay-state > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__action {
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid rgba(var(--nodedc-accent-rgb), 0.18);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__action > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
.observatory-evidence-card__actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observatory-replay,
|
||||
@@ -205,17 +259,6 @@
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observatory-evidence-list strong,
|
||||
.observatory-evidence-list span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observatory-evidence-list span {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.observatory-evidence-empty {
|
||||
display: grid;
|
||||
min-height: 13rem;
|
||||
@@ -242,6 +285,56 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.observatory-rename-form,
|
||||
.observatory-delete-confirmation {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.observatory-delete-confirmation p,
|
||||
.observatory-mutation-error {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.observatory-mutation-error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 56rem) {
|
||||
.observatory-session-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.observatory-session-summary__end,
|
||||
.observatory-modalities {
|
||||
justify-items: start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 48rem) {
|
||||
.observatory-evidence-card {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observatory-evidence-card > .nodedc-status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 38rem) {
|
||||
.observatory-session-summary__facts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy small {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
@@ -251,7 +344,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__action,
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state {
|
||||
align-items: stretch;
|
||||
@@ -268,7 +360,4 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-session-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
ConfirmationModal,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
@@ -15,6 +21,15 @@ import {
|
||||
fetchObservatoryRecordedRunReview,
|
||||
type ObservatoryRecordedRunBinding,
|
||||
} from "../../core/observatory/recordedRun";
|
||||
import {
|
||||
findObservatoryEvidence,
|
||||
observatoryCatalogConfirmsEvidenceDeletion,
|
||||
type ObservatoryEvidence,
|
||||
} from "../../core/observatory/catalog";
|
||||
import {
|
||||
deleteObservatoryLabProjection,
|
||||
renameObservatoryLabProjection,
|
||||
} from "../../core/observatory/catalogMutations";
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
|
||||
@@ -24,6 +39,17 @@ type ObservatoryRecordedRunReview = Awaited<
|
||||
ReturnType<typeof fetchObservatoryRecordedRunReview>
|
||||
>;
|
||||
|
||||
type ObservatoryMutationReconciliation =
|
||||
| {
|
||||
readonly kind: "rename";
|
||||
readonly sessionId: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
| {
|
||||
readonly kind: "delete";
|
||||
readonly sessionId: string;
|
||||
};
|
||||
|
||||
type ObservatoryReplayState =
|
||||
| { readonly kind: "closed" }
|
||||
| {
|
||||
@@ -94,6 +120,12 @@ function catalogStateLabel(
|
||||
return "Читаем каталог";
|
||||
}
|
||||
|
||||
function mutationErrorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Не удалось изменить лабораторный результат в Обсерватории.";
|
||||
}
|
||||
|
||||
export function ObservatoryWorkspace({
|
||||
definition,
|
||||
}: {
|
||||
@@ -102,6 +134,14 @@ export function ObservatoryWorkspace({
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [mutationReconciliation, setMutationReconciliation] = useState<
|
||||
ObservatoryMutationReconciliation | null
|
||||
>(null);
|
||||
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||
|
||||
@@ -136,16 +176,42 @@ export function ObservatoryWorkspace({
|
||||
const replayEvidenceId = replay.kind === "closed"
|
||||
? null
|
||||
: replay.binding.evidenceSessionId;
|
||||
const replayEvidence = replayEvidenceId === null
|
||||
? null
|
||||
: selectedSession?.evidence.find(
|
||||
(evidence) => evidence.sessionId === replayEvidenceId,
|
||||
) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
replayEvidenceId === null
|
||||
|| selectedSession?.evidence.some(
|
||||
(evidence) => evidence.sessionId === replayEvidenceId,
|
||||
)
|
||||
|| replayEvidence
|
||||
) return;
|
||||
closeReplay();
|
||||
}, [closeReplay, replayEvidenceId, selectedSession]);
|
||||
}, [closeReplay, replayEvidence, replayEvidenceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
mutationPending !== null
|
||||
|| mutationReconciliation === null
|
||||
|| controller.catalog === null
|
||||
) return;
|
||||
const evidence = findObservatoryEvidence(
|
||||
controller.catalog,
|
||||
mutationReconciliation.sessionId,
|
||||
);
|
||||
const confirmed = mutationReconciliation.kind === "rename"
|
||||
? evidence?.label === mutationReconciliation.displayName
|
||||
: observatoryCatalogConfirmsEvidenceDeletion(
|
||||
controller.catalog,
|
||||
mutationReconciliation.sessionId,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setRenameTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}, [controller.catalog, mutationPending, mutationReconciliation]);
|
||||
|
||||
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||
const attempt = replayCoordinatorRef.current.begin();
|
||||
@@ -173,6 +239,101 @@ export function ObservatoryWorkspace({
|
||||
setSelectedSessionId(sessionId);
|
||||
}, [closeReplay]);
|
||||
|
||||
const openRename = useCallback((evidence: ObservatoryEvidence) => {
|
||||
if (!evidence.recordedRun) return;
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setRenameValue(evidence.label);
|
||||
setRenameTarget(evidence);
|
||||
}, []);
|
||||
|
||||
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
|
||||
if (!evidence.recordedRun) return;
|
||||
setRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setDeleteTarget(evidence);
|
||||
}, []);
|
||||
|
||||
const submitRename = useCallback(async () => {
|
||||
if (!renameTarget?.recordedRun || mutationPending !== null) return;
|
||||
const requestedDisplayName = renameValue.trim();
|
||||
const reconciliation: ObservatoryMutationReconciliation = {
|
||||
kind: "rename",
|
||||
sessionId: renameTarget.sessionId,
|
||||
displayName: requestedDisplayName,
|
||||
};
|
||||
setMutationPending("rename");
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(reconciliation);
|
||||
try {
|
||||
const result = await renameObservatoryLabProjection(
|
||||
renameTarget.recordedRun,
|
||||
renameValue,
|
||||
);
|
||||
controller.applyEvidenceRename(result.sessionId, result.displayName);
|
||||
setRenameTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
void controller.refresh();
|
||||
} catch (error) {
|
||||
const reconciled = await controller.refresh();
|
||||
const evidence = reconciled
|
||||
? findObservatoryEvidence(reconciled, reconciliation.sessionId)
|
||||
: null;
|
||||
if (reconciled && evidence?.label === reconciliation.displayName) {
|
||||
setRenameTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
} else {
|
||||
setMutationError(mutationErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
}, [controller, mutationPending, renameTarget, renameValue]);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
|
||||
const reconciliation: ObservatoryMutationReconciliation = {
|
||||
kind: "delete",
|
||||
sessionId: deleteTarget.sessionId,
|
||||
};
|
||||
setMutationPending("delete");
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(reconciliation);
|
||||
try {
|
||||
if (
|
||||
replay.kind !== "closed"
|
||||
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
|
||||
) {
|
||||
flushSync(() => {
|
||||
closeReplay();
|
||||
});
|
||||
}
|
||||
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
||||
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
||||
setDeleteTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
void controller.refresh();
|
||||
} catch (error) {
|
||||
const reconciled = await controller.refresh();
|
||||
if (
|
||||
reconciled
|
||||
&& observatoryCatalogConfirmsEvidenceDeletion(
|
||||
reconciled,
|
||||
reconciliation.sessionId,
|
||||
)
|
||||
) {
|
||||
setDeleteTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
} else {
|
||||
setMutationError(mutationErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
}, [closeReplay, controller, deleteTarget, mutationPending, replay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="observatory-workspace"
|
||||
@@ -268,22 +429,14 @@ export function ObservatoryWorkspace({
|
||||
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
|
||||
</GlassSurface>
|
||||
) : selectedSession ? (
|
||||
<section className="observatory-session-grid" aria-label="Выбранная сессия и связанные результаты">
|
||||
<GlassSurface className="observatory-session-summary" padding="lg">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
|
||||
<h3>{selectedSession.source.label}</h3>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone(selectedSession.source.status)}>
|
||||
{statusLabel[selectedSession.source.status]}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Идентификатор</dt>
|
||||
<dd><code>{selectedSession.source.id}</code></dd>
|
||||
</div>
|
||||
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
|
||||
<GlassSurface className="observatory-session-summary" padding="md">
|
||||
<div className="observatory-session-summary__identity">
|
||||
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
|
||||
<h3>{selectedSession.source.label}</h3>
|
||||
<code>{selectedSession.source.id}</code>
|
||||
</div>
|
||||
<dl className="observatory-session-summary__facts">
|
||||
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
|
||||
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
|
||||
<div>
|
||||
@@ -291,18 +444,23 @@ export function ObservatoryWorkspace({
|
||||
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="observatory-modalities" aria-label="Каналы сессии">
|
||||
{selectedSession.source.modalities.length > 0
|
||||
? selectedSession.source.modalities.map((modality) => (
|
||||
<StatusBadge key={modality} tone="neutral">
|
||||
{modalityLabel[modality] ?? modality}
|
||||
</StatusBadge>
|
||||
))
|
||||
: <span>Каналы не зафиксированы</span>}
|
||||
<div className="observatory-session-summary__end">
|
||||
<StatusBadge tone={statusTone(selectedSession.source.status)}>
|
||||
{statusLabel[selectedSession.source.status]}
|
||||
</StatusBadge>
|
||||
<div className="observatory-modalities" aria-label="Каналы сессии">
|
||||
{selectedSession.source.modalities.length > 0
|
||||
? selectedSession.source.modalities.map((modality) => (
|
||||
<StatusBadge key={modality} tone="neutral">
|
||||
{modalityLabel[modality] ?? modality}
|
||||
</StatusBadge>
|
||||
))
|
||||
: <span>Каналы не зафиксированы</span>}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="observatory-evidence" padding="lg">
|
||||
<section className="observatory-evidence">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
||||
@@ -316,53 +474,52 @@ export function ObservatoryWorkspace({
|
||||
<ol className="observatory-evidence-list">
|
||||
{presentedEvidence.map((evidence) => (
|
||||
<li key={evidence.sessionId}>
|
||||
<GlassSurface className="observatory-evidence-card" padding="md" tone="soft">
|
||||
<div className="observatory-evidence-card__heading">
|
||||
<div>
|
||||
<strong>{evidence.lab.labId}</strong>
|
||||
<span>{evidence.label}</span>
|
||||
</div>
|
||||
<div className="observatory-evidence-card">
|
||||
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||
<Icon name="clipboard" size={18} />
|
||||
</span>
|
||||
<div className="observatory-evidence-card__copy">
|
||||
<strong>{evidence.lab.labId}</strong>
|
||||
<span>{evidence.label}</span>
|
||||
<small>
|
||||
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
||||
</small>
|
||||
</div>
|
||||
{evidence.recordedRun ? (
|
||||
<StatusBadge tone="accent">Записанный разбор</StatusBadge>
|
||||
) : null}
|
||||
<div className="observatory-evidence-card__actions">
|
||||
{evidence.recordedRun ? (
|
||||
<StatusBadge tone="accent">Записанный разбор</StatusBadge>
|
||||
<>
|
||||
<IconButton
|
||||
label={`Удалить ${evidence.lab.labId} из Обсерватории`}
|
||||
onClick={() => openDelete(evidence)}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={`Переименовать ${evidence.lab.labId}`}
|
||||
onClick={() => openRename(evidence)}
|
||||
>
|
||||
<Icon name="edit" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={`Открыть визуальный разбор: ${evidence.lab.labId}`}
|
||||
disabled={
|
||||
replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
}
|
||||
onClick={() => openReplay(evidence.recordedRun!)}
|
||||
>
|
||||
{replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? <ActivityIndicator size="compact" />
|
||||
: <Icon name="eye" size={16} />}
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Тип результата</dt><dd>{evidence.lab.resultKind}</dd></div>
|
||||
<div>
|
||||
<dt>Result ID</dt>
|
||||
<dd><code>{evidence.lab.resultId}</code></dd>
|
||||
</div>
|
||||
<div><dt>Опубликован</dt><dd>{formatTimestamp(evidence.publishedAtUtc)}</dd></div>
|
||||
</dl>
|
||||
{evidence.recordedRun ? (
|
||||
<div className="observatory-evidence-card__action">
|
||||
<span>
|
||||
Записанный маршрут · единая временная шкала · только наблюдение
|
||||
</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="play" size={14} />}
|
||||
disabled={
|
||||
replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
}
|
||||
onClick={() => openReplay(evidence.recordedRun!)}
|
||||
>
|
||||
{replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Проверяем результат"
|
||||
: replay.kind === "ready"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Открыть заново"
|
||||
: replay.kind === "error"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Повторить открытие"
|
||||
: "Открыть визуальный разбор"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -382,7 +539,7 @@ export function ObservatoryWorkspace({
|
||||
{" "}связанных результатов. Полный архив остаётся в legacy LAB.
|
||||
</p>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -418,7 +575,7 @@ export function ObservatoryWorkspace({
|
||||
<header className="observatory-replay__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ</span>
|
||||
<h3>RAVNOVES004TREE · полный маршрут восприятия</h3>
|
||||
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||
<p>Записанный маршрут синхронизирован по общей временной шкале.</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -446,6 +603,95 @@ export function ObservatoryWorkspace({
|
||||
</span>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<Window
|
||||
open={renameTarget !== null}
|
||||
title="Переименовать лабораторный результат"
|
||||
subtitle="Меняется только отображаемое название в Обсерватории"
|
||||
size="sm"
|
||||
closeOnBackdrop={mutationPending !== "rename"}
|
||||
closeOnEscape={mutationPending !== "rename"}
|
||||
onClose={() => {
|
||||
if (mutationPending === "rename") return;
|
||||
setRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
disabled={mutationPending === "rename"}
|
||||
onClick={() => {
|
||||
setRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="observatory-rename-form"
|
||||
variant="primary"
|
||||
disabled={mutationPending === "rename" || renameValue.trim().length === 0}
|
||||
>
|
||||
{mutationPending === "rename" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<form
|
||||
id="observatory-rename-form"
|
||||
className="observatory-rename-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitRename();
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Название"
|
||||
value={renameValue}
|
||||
maxLength={160}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
disabled={mutationPending === "rename"}
|
||||
onChange={(event) => setRenameValue(event.currentTarget.value)}
|
||||
/>
|
||||
{mutationError && renameTarget ? (
|
||||
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Window>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
title="Удалить результат из Обсерватории?"
|
||||
description={deleteTarget ? (
|
||||
<div className="observatory-delete-confirmation">
|
||||
<p>
|
||||
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
|
||||
{" "}и её отображаемое название в Обсерватории.
|
||||
</p>
|
||||
<p>
|
||||
Исходная сессия, запечатанный лабораторный результат и файлы доказательств
|
||||
останутся неизменными.
|
||||
</p>
|
||||
{mutationError ? (
|
||||
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
confirmLabel="Удалить из Обсерватории"
|
||||
pendingLabel="Удаляем…"
|
||||
danger
|
||||
onClose={() => {
|
||||
if (mutationPending === "delete") return;
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -118,6 +118,7 @@ CREATE TABLE IF NOT EXISTS observation_lab_instances (
|
||||
published_at_utc TEXT NOT NULL,
|
||||
include_recorded_media INTEGER CHECK (include_recorded_media IN (0, 1)),
|
||||
replay_capability_json TEXT,
|
||||
operator_display_name TEXT,
|
||||
provenance_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -298,6 +299,13 @@ class SessionStore:
|
||||
if row["session_id"] in lab_rows
|
||||
else None
|
||||
),
|
||||
operator_display_name=(
|
||||
_operator_display_name_from_row(
|
||||
lab_rows[row["session_id"]]["operator_display_name"]
|
||||
)
|
||||
if row["session_id"] in lab_rows
|
||||
else None
|
||||
),
|
||||
)
|
||||
for row in selected
|
||||
)
|
||||
@@ -364,6 +372,13 @@ class SessionStore:
|
||||
summary=_summary_from_row(
|
||||
row,
|
||||
lab=None if lab_row is None else _lab_binding_from_row(lab_row),
|
||||
operator_display_name=(
|
||||
None
|
||||
if lab_row is None
|
||||
else _operator_display_name_from_row(
|
||||
lab_row["operator_display_name"]
|
||||
)
|
||||
),
|
||||
),
|
||||
sources=sources,
|
||||
artifacts=artifacts,
|
||||
@@ -627,6 +642,62 @@ class SessionStore:
|
||||
raise SessionIntegrityError("LAB session publication was not durable")
|
||||
return binding
|
||||
|
||||
def rename_capability_lab_projection(
|
||||
self,
|
||||
session_id: str,
|
||||
display_name: str,
|
||||
) -> str:
|
||||
"""Set only an operator-facing alias on one typed LAB projection.
|
||||
|
||||
The immutable observation summary keeps the canonical publication name
|
||||
so a strict publisher retry can still validate the exact sealed
|
||||
projection. The alias affects only catalog presentation.
|
||||
"""
|
||||
|
||||
_validate_identifier(session_id, "LAB session id")
|
||||
normalized_name = _normalize_operator_display_name(display_name)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
summary, _lab = _require_capability_owned_lab_projection(
|
||||
connection,
|
||||
session_id,
|
||||
)
|
||||
override = (
|
||||
None
|
||||
if normalized_name == summary["display_name"]
|
||||
else normalized_name
|
||||
)
|
||||
updated = connection.execute(
|
||||
"UPDATE observation_lab_instances SET operator_display_name = ? "
|
||||
"WHERE session_id = ?",
|
||||
(override, session_id),
|
||||
).rowcount
|
||||
if updated != 1:
|
||||
connection.rollback()
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
connection.commit()
|
||||
return normalized_name
|
||||
|
||||
def delete_capability_lab_projection(self, session_id: str) -> None:
|
||||
"""Delete only one typed LAB catalog projection and its copied rows.
|
||||
|
||||
No evidence locator, source session, prepared recording, or viewer
|
||||
cache is inspected or removed by this operation.
|
||||
"""
|
||||
|
||||
_validate_identifier(session_id, "LAB session id")
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
_require_capability_owned_lab_projection(connection, session_id)
|
||||
deleted = connection.execute(
|
||||
"DELETE FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).rowcount
|
||||
if deleted != 1:
|
||||
connection.rollback()
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
connection.commit()
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
"""Permanently delete one exact catalogued evidence directory and row."""
|
||||
|
||||
@@ -931,6 +1002,11 @@ class SessionStore:
|
||||
"ADD COLUMN include_recorded_media INTEGER "
|
||||
"CHECK (include_recorded_media IN (0, 1))"
|
||||
)
|
||||
if "operator_display_name" not in lab_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE observation_lab_instances "
|
||||
"ADD COLUMN operator_display_name TEXT"
|
||||
)
|
||||
_migrate_canonical_replay_capabilities(connection)
|
||||
connection.commit()
|
||||
with _ignore_os_error():
|
||||
@@ -1583,16 +1659,87 @@ def _canonical_rolling_capability(
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_operator_display_name(value: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("LAB display name must be text")
|
||||
normalized = value.strip()
|
||||
if not 1 <= len(normalized) <= 160 or any(
|
||||
ord(character) < 32 for character in normalized
|
||||
):
|
||||
raise ValueError("LAB display name must contain 1..160 printable characters")
|
||||
return normalized
|
||||
|
||||
|
||||
def _operator_display_name_from_row(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise SessionIntegrityError("stored LAB operator display name is invalid")
|
||||
try:
|
||||
return _normalize_operator_display_name(value)
|
||||
except ValueError as exc:
|
||||
raise SessionIntegrityError("stored LAB operator display name is invalid") from exc
|
||||
|
||||
|
||||
def _require_capability_owned_lab_projection(
|
||||
connection: sqlite3.Connection,
|
||||
session_id: str,
|
||||
) -> tuple[sqlite3.Row, sqlite3.Row]:
|
||||
summary = connection.execute(
|
||||
"SELECT * FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if summary is None:
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
lab = connection.execute(
|
||||
"SELECT * FROM observation_lab_instances WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if lab is None:
|
||||
raise SessionIntegrityError(
|
||||
"observation session is not a capability-owned LAB projection"
|
||||
)
|
||||
binding = _lab_binding_from_row(lab)
|
||||
if (
|
||||
summary["archive_id"] != LAB_ARCHIVE_ID
|
||||
or summary["origin"] != LAB_ORIGIN
|
||||
or binding.replay_capability is None
|
||||
or binding.session_id != session_id
|
||||
or binding.source_session_id == session_id
|
||||
or lab["include_recorded_media"] not in {0, 1}
|
||||
or connection.execute(
|
||||
"SELECT 1 FROM observation_lab_instances WHERE session_id = ?",
|
||||
(binding.source_session_id,),
|
||||
).fetchone()
|
||||
is not None
|
||||
):
|
||||
raise SessionIntegrityError(
|
||||
"observation session is not a capability-owned LAB projection"
|
||||
)
|
||||
_operator_display_name_from_row(lab["operator_display_name"])
|
||||
_validate_existing_lab_projection(
|
||||
connection,
|
||||
session_id=session_id,
|
||||
source_session_id=binding.source_session_id,
|
||||
display_name=summary["display_name"],
|
||||
run_created_at_utc=binding.run_created_at_utc,
|
||||
duration_seconds=summary["duration_seconds"],
|
||||
include_recorded_media=bool(lab["include_recorded_media"]),
|
||||
)
|
||||
return summary, lab
|
||||
|
||||
|
||||
def _summary_from_row(
|
||||
row: sqlite3.Row,
|
||||
*,
|
||||
lab: LabSessionBinding | None = None,
|
||||
operator_display_name: str | None = None,
|
||||
) -> SessionSummary:
|
||||
raw_modalities = json.loads(row["modalities_json"])
|
||||
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
|
||||
return SessionSummary(
|
||||
session_id=row["session_id"],
|
||||
display_name=row["display_name"],
|
||||
display_name=operator_display_name or row["display_name"],
|
||||
status=cast(SessionStatus, row["status"]),
|
||||
started_at_utc=row["started_at_utc"],
|
||||
completed_at_utc=row["completed_at_utc"],
|
||||
@@ -1646,6 +1793,8 @@ def _serialize_replay_capability(value: LabReplayCapability | None) -> str | Non
|
||||
def _replay_capability_from_row(value: object) -> LabReplayCapability | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str | bytes | bytearray):
|
||||
raise SessionIntegrityError("stored LAB replay capability is invalid")
|
||||
try:
|
||||
document = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
|
||||
@@ -144,6 +144,7 @@ from k1link.web.map_api import (
|
||||
build_map_router,
|
||||
)
|
||||
from k1link.web.map_view_api import build_map_view_router
|
||||
from k1link.web.observatory_api import build_observatory_router
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
STATE_READ_ACTION_ID,
|
||||
@@ -691,6 +692,7 @@ app.include_router(
|
||||
point_color_renderers=plugin_environment.point_color_renderers,
|
||||
)
|
||||
)
|
||||
app.include_router(build_observatory_router(session_store))
|
||||
app.include_router(
|
||||
build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
|
||||
|
||||
OBSERVATORY_PROJECTION_SCHEMA: Literal[
|
||||
"missioncore.observatory-lab-projection/v1"
|
||||
] = "missioncore.observatory-lab-projection/v1"
|
||||
OBSERVATORY_RENAME_SCHEMA: Literal[
|
||||
"missioncore.observatory-lab-projection-rename/v1"
|
||||
] = "missioncore.observatory-lab-projection-rename/v1"
|
||||
|
||||
|
||||
class _StrictApiModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ObservatoryProjectionRenameRequest(_StrictApiModel):
|
||||
schema_version: Literal[
|
||||
"missioncore.observatory-lab-projection-rename/v1"
|
||||
]
|
||||
display_name: str = Field(min_length=1, max_length=160)
|
||||
|
||||
|
||||
class ObservatoryProjectionDocument(_StrictApiModel):
|
||||
schema_version: Literal["missioncore.observatory-lab-projection/v1"]
|
||||
session_id: str
|
||||
display_name: str
|
||||
|
||||
|
||||
def build_observatory_router(store: SessionStore) -> APIRouter:
|
||||
"""Build bounded catalog-only mutations for typed Observatory projections."""
|
||||
|
||||
router = APIRouter(tags=["observatory"])
|
||||
|
||||
@router.patch(
|
||||
"/api/v1/observatory/lab-projections/{session_id}",
|
||||
response_model=ObservatoryProjectionDocument,
|
||||
)
|
||||
def rename_observatory_lab_projection(
|
||||
session_id: str,
|
||||
request: ObservatoryProjectionRenameRequest,
|
||||
) -> ObservatoryProjectionDocument:
|
||||
try:
|
||||
display_name = store.rename_capability_lab_projection(
|
||||
session_id,
|
||||
request.display_name,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Проекция Обсерватории не найдена.",
|
||||
) from exc
|
||||
except SessionIntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Запись не является управляемой проекцией Обсерватории.",
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректные параметры проекции Обсерватории.",
|
||||
) from exc
|
||||
return ObservatoryProjectionDocument(
|
||||
schema_version=OBSERVATORY_PROJECTION_SCHEMA,
|
||||
session_id=session_id,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/observatory/lab-projections/{session_id}",
|
||||
status_code=204,
|
||||
)
|
||||
def delete_observatory_lab_projection(session_id: str) -> Response:
|
||||
try:
|
||||
store.delete_capability_lab_projection(session_id)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Проекция Обсерватории не найдена.",
|
||||
) from exc
|
||||
except SessionIntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Запись не является управляемой проекцией Обсерватории.",
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректный идентификатор проекции Обсерватории.",
|
||||
) from exc
|
||||
return Response(status_code=204)
|
||||
|
||||
return router
|
||||
@@ -38,6 +38,7 @@ from k1link.sessions.media import _manifest_generation_sha256
|
||||
from k1link.viewer.recorded import recorded_blueprint_rrd
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.web.camera_archive import CameraArchiveWriter
|
||||
from k1link.web.observatory_api import build_observatory_router
|
||||
from k1link.web.session_api import (
|
||||
LayoutPutRequest,
|
||||
RecordedBlueprintRequest,
|
||||
@@ -422,6 +423,141 @@ def test_session_router_rolls_capability_projections_out_only_in_v2(
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_observatory_projection_api_renames_alias_and_deletes_only_projection(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest()
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
source_snapshot = store.get_session_with_catalog_snapshot(source.name)[1]
|
||||
capability = LabReplayCapability(
|
||||
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||
kind="canonical-recorded-rerun",
|
||||
viewer_profile="recorded-session",
|
||||
timeline="session_time",
|
||||
activation="explicit",
|
||||
commands_enabled=False,
|
||||
)
|
||||
projection = store.publish_lab_instance(
|
||||
session_id="lab-observatory-api-projection",
|
||||
source_session_id=source.name,
|
||||
display_name="LAB V1 · canonical API projection",
|
||||
lab_id="LAB V1",
|
||||
result_kind="recorded-perception-qualification",
|
||||
result_id="lab-v1-vegetation-shadow-" + "8" * 64,
|
||||
source_result_id="lab-v1-vegetation-shadow-" + "9" * 64,
|
||||
run_created_at_utc="2026-08-29T18:05:11.329061+00:00",
|
||||
include_recorded_media=False,
|
||||
replay_capability=capability,
|
||||
provenance={
|
||||
"replay_capability": capability.as_dict(),
|
||||
"method": lab_method(),
|
||||
},
|
||||
)
|
||||
cache_sentinel = store.data_dir / "recordings" / projection.session_id / "cached.rrd"
|
||||
cache_sentinel.parent.mkdir(parents=True)
|
||||
cache_sentinel.write_bytes(b"must-not-be-touched")
|
||||
monkeypatch.setattr(
|
||||
store,
|
||||
"delete_session",
|
||||
lambda _session_id: pytest.fail(
|
||||
"Observatory router must not enter generic evidence/cache deletion"
|
||||
),
|
||||
)
|
||||
application = FastAPI()
|
||||
application.include_router(build_observatory_router(store))
|
||||
client = TestClient(application)
|
||||
url = f"/api/v1/observatory/lab-projections/{projection.session_id}"
|
||||
|
||||
assert client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v0",
|
||||
"display_name": "Неверная версия",
|
||||
},
|
||||
).status_code == 422
|
||||
assert client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||
"display_name": "Новый разбор",
|
||||
"unexpected": True,
|
||||
},
|
||||
).status_code == 422
|
||||
assert client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||
"display_name": " ",
|
||||
},
|
||||
).status_code == 422
|
||||
|
||||
renamed = client.patch(
|
||||
url,
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||
"display_name": " Маршрут у школы ",
|
||||
},
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
assert renamed.json() == {
|
||||
"schema_version": "missioncore.observatory-lab-projection/v1",
|
||||
"session_id": projection.session_id,
|
||||
"display_name": "Маршрут у школы",
|
||||
}
|
||||
assert store.get_session(projection.session_id).summary.display_name == "Маршрут у школы"
|
||||
|
||||
deleted = client.delete(url)
|
||||
assert deleted.status_code == 204
|
||||
assert deleted.content == b""
|
||||
assert client.delete(url).status_code == 404
|
||||
assert store.get_session_with_catalog_snapshot(source.name)[1] == source_snapshot
|
||||
assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256
|
||||
assert source.is_dir()
|
||||
assert cache_sentinel.read_bytes() == b"must-not-be-touched"
|
||||
|
||||
|
||||
def test_observatory_projection_api_rejects_source_and_legacy_lab(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
legacy = store.publish_lab_instance(
|
||||
session_id="lab-observatory-api-legacy",
|
||||
source_session_id=source.name,
|
||||
display_name="LAB E21 · legacy API row",
|
||||
lab_id="LAB E21",
|
||||
result_kind="e21-realtime-envelope",
|
||||
result_id="e21-realtime-envelope-" + "a" * 64,
|
||||
run_created_at_utc="2026-07-23T15:55:15.548Z",
|
||||
provenance={"method": lab_method()},
|
||||
)
|
||||
application = FastAPI()
|
||||
application.include_router(build_observatory_router(store))
|
||||
client = TestClient(application)
|
||||
rename_document = {
|
||||
"schema_version": "missioncore.observatory-lab-projection-rename/v1",
|
||||
"display_name": "Нельзя изменить",
|
||||
}
|
||||
|
||||
for session_id in (source.name, legacy.session_id):
|
||||
url = f"/api/v1/observatory/lab-projections/{session_id}"
|
||||
assert client.patch(url, json=rename_document).status_code == 409
|
||||
assert client.delete(url).status_code == 409
|
||||
|
||||
assert source.is_dir()
|
||||
assert store.get_session(source.name).summary.session_id == source.name
|
||||
assert store.get_session(legacy.session_id).summary.display_name == "LAB E21 · legacy API row"
|
||||
|
||||
|
||||
def test_delete_session_removes_evidence_and_cache_but_refuses_an_open_recording(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -907,6 +907,9 @@ def test_lab_replay_capability_column_migrates_existing_catalog(
|
||||
connection.execute(
|
||||
"ALTER TABLE observation_lab_instances DROP COLUMN include_recorded_media"
|
||||
)
|
||||
connection.execute(
|
||||
"ALTER TABLE observation_lab_instances DROP COLUMN operator_display_name"
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
migrated = SessionStore(repository, data_dir=data_dir)
|
||||
@@ -919,6 +922,7 @@ def test_lab_replay_capability_column_migrates_existing_catalog(
|
||||
}
|
||||
assert "replay_capability_json" in columns
|
||||
assert "include_recorded_media" in columns
|
||||
assert "operator_display_name" in columns
|
||||
assert migrated.get_lab_instance(legacy.session_id).replay_capability is None
|
||||
|
||||
|
||||
@@ -1248,6 +1252,265 @@ def test_lab_instance_persists_typed_explicit_recorded_replay_capability(
|
||||
store.get_lab_instance(binding.session_id)
|
||||
|
||||
|
||||
def test_capability_projection_rename_is_only_an_operator_alias(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest()
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
_source_detail, source_snapshot = store.get_session_with_catalog_snapshot(source.name)
|
||||
capability = LabReplayCapability(
|
||||
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||
kind="canonical-recorded-rerun",
|
||||
viewer_profile="recorded-session",
|
||||
timeline="session_time",
|
||||
activation="explicit",
|
||||
commands_enabled=False,
|
||||
)
|
||||
parameters = {
|
||||
"session_id": "lab-recorded-operator-alias",
|
||||
"source_session_id": source.name,
|
||||
"display_name": "LAB V1 · canonical recorded replay",
|
||||
"lab_id": "LAB V1",
|
||||
"result_kind": "recorded-perception-qualification",
|
||||
"result_id": "lab-v1-vegetation-shadow-" + "a" * 64,
|
||||
"source_result_id": "lab-v1-vegetation-shadow-" + "b" * 64,
|
||||
"run_created_at_utc": "2026-08-29T18:05:11.329061+00:00",
|
||||
"include_recorded_media": False,
|
||||
"replay_capability": capability,
|
||||
"provenance": {
|
||||
"replay_capability": capability.as_dict(),
|
||||
"method": lab_method(),
|
||||
},
|
||||
}
|
||||
binding = store.publish_lab_instance(**parameters)
|
||||
immutable_binding = store.get_lab_instance(binding.session_id)
|
||||
_projection_detail, projection_snapshot = store.get_session_with_catalog_snapshot(
|
||||
binding.session_id
|
||||
)
|
||||
|
||||
assert store.rename_capability_lab_projection(
|
||||
binding.session_id,
|
||||
" Маршрут у школы ",
|
||||
) == "Маршрут у школы"
|
||||
|
||||
assert store.get_session(binding.session_id).summary.display_name == "Маршрут у школы"
|
||||
assert (
|
||||
store.list_recent(scope="laboratory").items[0].display_name
|
||||
== "Маршрут у школы"
|
||||
)
|
||||
assert store.get_lab_instance(binding.session_id) == immutable_binding
|
||||
assert store.get_session_with_catalog_snapshot(source.name)[1] == source_snapshot
|
||||
assert (
|
||||
store.get_session_with_catalog_snapshot(binding.session_id)[1]
|
||||
== projection_snapshot
|
||||
)
|
||||
assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT sessions.display_name, lab.operator_display_name "
|
||||
"FROM observation_sessions AS sessions "
|
||||
"JOIN observation_lab_instances AS lab USING (session_id) "
|
||||
"WHERE sessions.session_id = ?",
|
||||
(binding.session_id,),
|
||||
).fetchone()
|
||||
assert row == ("LAB V1 · canonical recorded replay", "Маршрут у школы")
|
||||
|
||||
# A strict publisher retry still sees the untouched canonical name and
|
||||
# immutable provenance, even while the catalog presents the alias.
|
||||
assert store.publish_lab_instance(**parameters) == binding
|
||||
assert store.get_session(binding.session_id).summary.display_name == "Маршрут у школы"
|
||||
assert store.rename_capability_lab_projection(
|
||||
binding.session_id,
|
||||
parameters["display_name"],
|
||||
) == parameters["display_name"]
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
override = connection.execute(
|
||||
"SELECT operator_display_name FROM observation_lab_instances "
|
||||
"WHERE session_id = ?",
|
||||
(binding.session_id,),
|
||||
).fetchone()[0]
|
||||
assert override is None
|
||||
|
||||
|
||||
def test_capability_projection_delete_removes_only_catalog_projection(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest()
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
source_detail, source_snapshot = store.get_session_with_catalog_snapshot(source.name)
|
||||
source_replay = store.prepare_replay(source.name)
|
||||
capability = LabReplayCapability(
|
||||
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||
kind="canonical-recorded-rerun",
|
||||
viewer_profile="recorded-session",
|
||||
timeline="session_time",
|
||||
activation="explicit",
|
||||
commands_enabled=False,
|
||||
)
|
||||
binding = store.publish_lab_instance(
|
||||
session_id="lab-recorded-delete-only-projection",
|
||||
source_session_id=source.name,
|
||||
display_name="LAB V1 · disposable catalog projection",
|
||||
lab_id="LAB V1",
|
||||
result_kind="recorded-perception-qualification",
|
||||
result_id="lab-v1-vegetation-shadow-" + "c" * 64,
|
||||
source_result_id="lab-v1-vegetation-shadow-" + "d" * 64,
|
||||
run_created_at_utc="2026-08-29T18:05:11.329061+00:00",
|
||||
include_recorded_media=False,
|
||||
replay_capability=capability,
|
||||
provenance={
|
||||
"replay_capability": capability.as_dict(),
|
||||
"method": lab_method(),
|
||||
},
|
||||
)
|
||||
projection_replay = store.prepare_replay(binding.session_id)
|
||||
cache_sentinel = store.data_dir / "recordings" / binding.session_id / "sentinel.rrd"
|
||||
cache_sentinel.parent.mkdir(parents=True)
|
||||
cache_sentinel.write_bytes(b"cache-owned-by-eviction-policy")
|
||||
assert projection_replay.primary_artifact.path == source_replay.primary_artifact.path
|
||||
|
||||
store.delete_capability_lab_projection(binding.session_id)
|
||||
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(binding.session_id)
|
||||
retained, retained_snapshot = store.get_session_with_catalog_snapshot(source.name)
|
||||
assert retained == source_detail
|
||||
assert retained_snapshot == source_snapshot
|
||||
assert source.is_dir()
|
||||
assert source_payload.is_file()
|
||||
assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256
|
||||
assert cache_sentinel.read_bytes() == b"cache-owned-by-eviction-policy"
|
||||
assert (
|
||||
store.prepare_replay(source.name).primary_artifact.path
|
||||
== source_replay.primary_artifact.path
|
||||
)
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM observation_lab_instances WHERE session_id = ?",
|
||||
(binding.session_id,),
|
||||
).fetchone()[0] == 0
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM observation_session_artifacts WHERE session_id = ?",
|
||||
(binding.session_id,),
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_capability_projection_mutations_reject_source_legacy_and_corrupt_rows(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
source = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
legacy = store.publish_lab_instance(
|
||||
session_id="lab-legacy-not-observatory-owned",
|
||||
source_session_id=source.name,
|
||||
display_name="LAB E21 · legacy",
|
||||
lab_id="LAB E21",
|
||||
result_kind="e21-realtime-envelope",
|
||||
result_id="e21-realtime-envelope-" + "e" * 64,
|
||||
run_created_at_utc="2026-07-23T15:55:15.548Z",
|
||||
provenance={"method": lab_method()},
|
||||
)
|
||||
capability = LabReplayCapability(
|
||||
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||
kind="canonical-recorded-rerun",
|
||||
viewer_profile="recorded-session",
|
||||
timeline="session_time",
|
||||
activation="explicit",
|
||||
commands_enabled=False,
|
||||
)
|
||||
corrupt = store.publish_lab_instance(
|
||||
session_id="lab-recorded-corrupt-owner",
|
||||
source_session_id=source.name,
|
||||
display_name="LAB V1 · corrupt owner",
|
||||
lab_id="LAB V1",
|
||||
result_kind="recorded-perception-qualification",
|
||||
result_id="lab-v1-vegetation-shadow-" + "f" * 64,
|
||||
run_created_at_utc="2026-08-29T18:05:11.329061+00:00",
|
||||
include_recorded_media=False,
|
||||
replay_capability=capability,
|
||||
provenance={
|
||||
"replay_capability": capability.as_dict(),
|
||||
"method": lab_method(),
|
||||
},
|
||||
)
|
||||
mismatch = store.publish_lab_instance(
|
||||
session_id="lab-recorded-provenance-mismatch",
|
||||
source_session_id=source.name,
|
||||
display_name="LAB V1 · provenance mismatch",
|
||||
lab_id="LAB V1",
|
||||
result_kind="recorded-perception-qualification",
|
||||
result_id="lab-v1-vegetation-shadow-" + "1" * 64,
|
||||
run_created_at_utc="2026-08-29T18:05:11.329061+00:00",
|
||||
include_recorded_media=False,
|
||||
replay_capability=capability,
|
||||
provenance={
|
||||
"replay_capability": capability.as_dict(),
|
||||
"method": lab_method(),
|
||||
},
|
||||
)
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE observation_sessions SET archive_id = ? WHERE session_id = ?",
|
||||
("not-observatory-owned", corrupt.session_id),
|
||||
)
|
||||
mismatched_provenance = {
|
||||
"replay_capability": {
|
||||
**capability.as_dict(),
|
||||
"commands_enabled": True,
|
||||
},
|
||||
"method": lab_method(),
|
||||
}
|
||||
connection.execute(
|
||||
"UPDATE observation_lab_instances SET provenance_json = ? "
|
||||
"WHERE session_id = ?",
|
||||
(
|
||||
json.dumps(mismatched_provenance, separators=(",", ":")),
|
||||
mismatch.session_id,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
for session_id in (source.name, legacy.session_id, corrupt.session_id):
|
||||
with pytest.raises(SessionIntegrityError, match="capability-owned"):
|
||||
store.rename_capability_lab_projection(session_id, "Нельзя изменить")
|
||||
with pytest.raises(SessionIntegrityError, match="capability-owned"):
|
||||
store.delete_capability_lab_projection(session_id)
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="does not match provenance"):
|
||||
store.rename_capability_lab_projection(mismatch.session_id, "Нельзя изменить")
|
||||
with pytest.raises(SessionIntegrityError, match="does not match provenance"):
|
||||
store.delete_capability_lab_projection(mismatch.session_id)
|
||||
|
||||
assert store.get_session(source.name).summary.session_id == source.name
|
||||
assert store.get_session(legacy.session_id).summary.display_name == "LAB E21 · legacy"
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
retained_ids = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT session_id FROM observation_sessions"
|
||||
).fetchall()
|
||||
}
|
||||
assert {
|
||||
source.name,
|
||||
legacy.session_id,
|
||||
corrupt.session_id,
|
||||
mismatch.session_id,
|
||||
} <= retained_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 1, None, True])
|
||||
def test_lab_replay_capability_rejects_non_literal_false(value: object) -> None:
|
||||
with pytest.raises(ValueError, match="replay capability"):
|
||||
|
||||
Reference in New Issue
Block a user