feat(observatory): add bounded M5.1 session review

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 17:29:39 +03:00
parent 81fdf6904a
commit e6a9846167
14 changed files with 1099 additions and 2 deletions
+2
View File
@@ -806,6 +806,8 @@ export default function App() {
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
) : activeDefinition.kind === "observatory" ? (
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
) : activeDefinition.root === "system" ? (
<SystemWorkspaceSelector
value={activeDefinition.id}
@@ -0,0 +1,145 @@
import {
fetchObservationSessionCatalog,
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
export interface ObservatoryEvidence {
readonly sessionId: string;
readonly label: string;
readonly status: ObservationSessionSummary["status"];
readonly publishedAtUtc: string;
readonly lab: ObservationLabInstance;
}
export interface ObservatorySession {
readonly source: ObservationSessionSummary;
readonly evidence: readonly ObservatoryEvidence[];
}
export interface ObservatoryCatalog {
readonly items: readonly ObservatorySession[];
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
readonly window: {
readonly limit: number;
readonly sourceCount: number;
readonly laboratoryCount: number;
readonly sourceLimitReached: boolean;
readonly laboratoryLimitReached: boolean;
};
}
export class ObservatoryCatalogContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryCatalogContractError";
}
}
function newestFirst(left: string, right: string): number {
return Date.parse(right) - Date.parse(left);
}
function boundedLimit(limit: number): number {
return Number.isFinite(limit)
? Math.min(100, Math.max(1, Math.floor(limit)))
: 100;
}
function evidenceFromSession(
session: ObservationSessionSummary,
): ObservatoryEvidence {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
};
}
function sortEvidence(
evidence: readonly ObservatoryEvidence[],
): ObservatoryEvidence[] {
return [...evidence].sort((left, right) =>
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|| left.sessionId.localeCompare(right.sessionId));
}
export function buildObservatoryCatalog(
sourceSessions: readonly ObservationSessionSummary[],
laboratorySessions: readonly ObservationSessionSummary[],
limit = 100,
): ObservatoryCatalog {
const safeLimit = boundedLimit(limit);
const sources = new Map<string, ObservationSessionSummary>();
for (const source of sourceSessions) {
if (source.lab !== null) {
throw new ObservatoryCatalogContractError(
`Каталог источников вернул LAB-сессию ${source.id}.`,
);
}
sources.set(source.id, source);
}
const linked = new Map<string, ObservatoryEvidence[]>();
const unresolvedEvidence: ObservatoryEvidence[] = [];
for (const laboratorySession of laboratorySessions) {
const evidence = evidenceFromSession(laboratorySession);
const sourceId = evidence.lab.sourceSessionId;
if (!sources.has(sourceId)) {
// Both API projections are bounded newest-first windows without a
// cursor. Absence from the source window is not proof of a broken
// relationship and must not be presented as an integrity fault.
unresolvedEvidence.push(evidence);
continue;
}
const current = linked.get(sourceId) ?? [];
current.push(evidence);
linked.set(sourceId, current);
}
const items = [...sources.values()]
.sort((left, right) =>
newestFirst(left.startedAtUtc, right.startedAtUtc)
|| left.id.localeCompare(right.id))
.map((source) => ({
source,
evidence: sortEvidence(linked.get(source.id) ?? []),
}));
return {
items,
unresolvedEvidence: sortEvidence(unresolvedEvidence),
window: {
limit: safeLimit,
sourceCount: sourceSessions.length,
laboratoryCount: laboratorySessions.length,
sourceLimitReached: sourceSessions.length >= safeLimit,
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
},
};
}
export async function fetchObservatoryCatalog({
signal,
limit = 100,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
limit?: number;
fetcher?: ObservationSessionFetch;
} = {}): Promise<ObservatoryCatalog> {
const safeLimit = boundedLimit(limit);
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
]);
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
}
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservatoryCatalog,
type ObservatoryCatalog,
} from "./catalog";
export type ObservatoryCatalogState =
| "idle"
| "loading"
| "ready"
| "refreshing"
| "error";
export interface ObservatoryCatalogController {
readonly catalog: ObservatoryCatalog | null;
readonly state: ObservatoryCatalogState;
readonly error: string | null;
readonly refresh: () => void;
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Каталог Обсерватории недоступен.";
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
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);
useEffect(() => {
const controller = new AbortController();
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");
});
return () => controller.abort();
}, [generation]);
const refresh = useCallback(() => {
setGeneration((current) => current + 1);
}, []);
return { catalog, state, error, refresh };
}
+13 -1
View File
@@ -23,7 +23,8 @@ export type WorkspaceKind =
| "datasets"
| "artifact-health"
| "lab-archive"
| "simulations";
| "simulations"
| "observatory";
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@@ -166,6 +167,17 @@ export const workspaces: WorkspaceDefinition[] = [
kind: "simulations",
groups: [],
},
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Обсерватория восприятия",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
},
{
id: "contour-health",
root: "fleet",
+1
View File
@@ -21,6 +21,7 @@
@import "./styles/device.css";
@import "./styles/responsive.css";
@import "./styles/observation.css";
@import "./styles/observatory.css";
@import "./styles/environment-settings.css";
@import "./styles/system-telemetry.css";
@import "./styles/artifact-health.css";
@@ -0,0 +1,207 @@
.observatory-workspace {
display: grid;
gap: 1rem;
min-width: 0;
min-height: 100%;
padding: 1rem;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice,
.observatory-session-summary > header,
.observatory-evidence > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
flex-wrap: wrap;
}
.observatory-lead > div,
.observatory-catalog-bar__copy,
.observatory-session-summary header > div,
.observatory-evidence header > div {
min-width: 0;
}
.observatory-lead h2,
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
margin: 0.3rem 0 0;
}
.observatory-lead p,
.observatory-catalog-bar p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
line-height: 1.5;
}
.observatory-catalog-bar__copy {
flex: 1 1 auto;
}
.observatory-catalog-bar__controls {
flex: 1 1 32rem;
justify-content: flex-end;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 1 1 22rem;
min-width: min(28rem, 100%);
}
.observatory-state {
display: grid;
min-height: 19rem;
place-items: center;
align-content: center;
gap: 0.7rem;
text-align: center;
}
.observatory-state h3 {
max-width: 42rem;
margin: 0;
}
.observatory-session-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
gap: 1rem;
align-items: start;
}
.observatory-session-summary,
.observatory-evidence {
display: grid;
gap: 1rem;
}
.observatory-session-summary dl,
.observatory-evidence-list dl {
display: grid;
gap: 0.75rem;
margin: 0;
}
.observatory-session-summary dl > div,
.observatory-evidence-list dl > div {
display: grid;
grid-template-columns: minmax(7.5rem, 0.42fr) minmax(0, 1fr);
gap: 0.85rem;
align-items: baseline;
}
.observatory-session-summary dt,
.observatory-evidence-list dt {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-session-summary dd,
.observatory-evidence-list 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-modalities {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
color: var(--nodedc-text-muted);
}
.observatory-evidence-list {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.observatory-evidence-card {
display: grid;
gap: 0.85rem;
}
.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;
place-items: center;
align-content: center;
gap: 0.55rem;
text-align: center;
}
.observatory-evidence__bounded-note {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.45;
}
.observatory-notice {
justify-content: flex-start;
color: var(--nodedc-text-secondary);
}
.observatory-notice__copy {
min-width: 0;
flex: 1 1 auto;
}
@media (max-width: 920px) {
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
align-items: stretch;
flex-direction: column;
}
.observatory-catalog-bar__controls {
flex-basis: auto;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 0 0 auto;
width: 100%;
min-width: 0;
}
.observatory-session-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -41,6 +41,7 @@ import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
import { NetworkWorkspace } from "./system/NetworkWorkspace";
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
import { ObservatoryWorkspace } from "./observatory/ObservatoryWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
if (status === "ready") return "accent";
@@ -1191,6 +1192,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
);
case "simulations":
return <SimulationWorkspace />;
case "observatory":
return <ObservatoryWorkspace definition={props.definition} />;
case "device":
return null;
}
@@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Button,
GlassSurface,
Icon,
Select,
StatusBadge,
} from "@nodedc/ui-react";
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import type { WorkspaceDefinition } from "../../productModel";
const MAX_PRESENTED_EVIDENCE = 6;
const statusLabel: Record<ObservationSessionStatus, string> = {
recording: "Запись идёт",
ready: "Готова",
degraded: "С деградацией",
interrupted: "Прервана",
failed: "Ошибка",
};
const modalityLabel: Record<string, string> = {
"point-cloud": "Облако точек",
pose: "Поза",
trajectory: "Траектория",
video: "Видео",
image: "Изображения",
depth: "Глубина",
telemetry: "Телеметрия",
};
function statusTone(
status: ObservationSessionStatus,
): "success" | "accent" | "warning" | "danger" | "neutral" {
if (status === "ready") return "success";
if (status === "recording") return "accent";
if (status === "degraded" || status === "interrupted") return "warning";
return "danger";
}
function formatTimestamp(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function catalogStateLabel(
state: ReturnType<typeof useObservatoryCatalog>["state"],
): string {
if (state === "ready") return "Срез актуален";
if (state === "refreshing") return "Обновляем каталог";
if (state === "error") return "Каталог недоступен";
return "Читаем каталог";
}
export function ObservatoryWorkspace({
definition,
}: {
definition: WorkspaceDefinition;
}) {
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const items = controller.catalog?.items ?? [];
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
setSelectedSessionId(items[0]?.source.id ?? "");
}, [items, selectedSessionId]);
const selectedSession = items.find(
(item) => item.source.id === selectedSessionId,
) ?? null;
const presentedEvidence = selectedSession?.evidence.slice(
0,
MAX_PRESENTED_EVIDENCE,
) ?? [];
const options = useMemo(() => items.map(({ source, evidence }) => ({
value: source.id,
label: source.label,
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
})), [items]);
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
return (
<div
className="observatory-workspace"
data-observatory-authority="observation-only"
data-observatory-viewer="detached"
>
<section className="observatory-lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>
Записанные источники и строго связанные результаты без запуска тяжёлого
визуализатора и без доступа к управлению аппаратом.
</p>
</div>
<StatusBadge
tone={controller.state === "error" ? "danger" : controller.state === "ready" ? "success" : "neutral"}
>
{catalogStateLabel(controller.state)}
</StatusBadge>
</section>
<GlassSurface className="observatory-catalog-bar" padding="lg">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
<h3>Сохранённая сессия</h3>
<p>Выбор меняет только читаемую карточку и не готовит Rerun-запись в фоне.</p>
</div>
<div className="observatory-catalog-bar__controls">
<Select
label="Выбрать сохранённую сессию"
value={selectedSessionId}
options={options}
disabled={items.length === 0}
searchable
searchPlaceholder="Поиск по сессиям"
emptyLabel="Сессия не найдена"
minMenuWidth={360}
menuWidth={460}
onChange={setSelectedSessionId}
/>
<Button
size="compact"
variant="secondary"
disabled={controller.state === "loading" || controller.state === "refreshing"}
icon={<Icon name="refresh" />}
onClick={controller.refresh}
>
Обновить
</Button>
</div>
</GlassSurface>
{controller.error && controller.catalog ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
<span className="observatory-notice__copy">{controller.error}</span>
<Button size="compact" variant="ghost" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{controller.catalog
&& (controller.catalog.window.sourceLimitReached
|| controller.catalog.window.laboratoryLimitReached) ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Срез ограничен</StatusBadge>
<span className="observatory-notice__copy">
Загружены последние {controller.catalog.window.sourceCount} исходных сессий и{
" "
}{controller.catalog.window.laboratoryCount} LAB-результатов. Полнота исторических
связей не подтверждена.
</span>
</GlassSurface>
) : null}
{initialLoading ? (
<GlassSurface className="observatory-state" padding="lg">
<ActivityIndicator label="Читаем каталог сессий" />
<h3>Читаем источники и связи результатов</h3>
<p>Визуализатор и лабораторные сцены при этом не запускаются.</p>
</GlassSurface>
) : unavailable ? (
<GlassSurface className="observatory-state" padding="lg" role="alert">
<Icon name="alert" size={20} />
<StatusBadge tone="danger">Каталог недоступен</StatusBadge>
<h3>{controller.error}</h3>
<Button size="compact" variant="secondary" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : items.length === 0 ? (
<GlassSurface className="observatory-state" padding="lg">
<Icon name="database" size={20} />
<h3>Сохранённых сессий пока нет</h3>
<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>
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
<div>
<dt>Чтение</dt>
<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>
</GlassSurface>
<GlassSurface className="observatory-evidence" padding="lg">
<header>
<div>
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
<h3>Лабораторные доказательства</h3>
</div>
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
{selectedSession.evidence.length}
</StatusBadge>
</header>
{selectedSession.evidence.length > 0 ? (
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
<li key={evidence.sessionId}>
<GlassSurface className="observatory-evidence-card" padding="md" tone="soft">
<div>
<strong>{evidence.lab.labId}</strong>
<span>{evidence.label}</span>
</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>
</GlassSurface>
</li>
))}
</ol>
) : (
<div className="observatory-evidence-empty">
<Icon name="clipboard" size={18} />
<strong>Связанных результатов нет</strong>
<p>
Наличие исходной записи само по себе не является выводом о качестве
компьютерного зрения или безопасности прохождения.
</p>
</div>
)}
{selectedSession.evidence.length > presentedEvidence.length ? (
<p className="observatory-evidence__bounded-note">
Показаны {presentedEvidence.length} последних из {selectedSession.evidence.length}
{" "}связанных результатов. Полный архив остаётся в legacy LAB.
</p>
) : null}
</GlassSurface>
</section>
) : null}
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Вне среза</StatusBadge>
<span className="observatory-notice__copy">
{controller.catalog?.unresolvedEvidence.length} результатов ссылаются на источники
вне текущего загруженного среза и не приписаны к квалификационным доказательствам.
</span>
</GlassSurface>
) : null}
</div>
);
}
@@ -104,3 +104,27 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
assert.doesNotMatch(laboratoryCss, /\.e30-human-review/);
assert.match(e30HumanReviewCss, /\.e30-human-review/);
});
test("Observatory is a bounded read-only slice outside legacy LAB and viewer lifecycles", async () => {
const workspaceHub = await read("workspaces/Workspaces.tsx");
const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
const observatoryCore = await read("core/observatory/catalog.ts");
const workspaceCss = await read("styles/workspaces.css");
const observatoryCss = await read("styles/observatory.css");
assert.match(
workspaceHub,
/case "observatory":[\s\S]*<ObservatoryWorkspace definition=\{props\.definition\} \/>/,
);
assert.match(observatory, /export function ObservatoryWorkspace/);
assert.match(observatory, /useObservatoryCatalog/);
assert.match(observatory, /data-observatory-authority="observation-only"/);
assert.match(observatory, /data-observatory-viewer="detached"/);
assert.doesNotMatch(
`${observatory}\n${observatoryCore}`,
/(?:core|components|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
);
assert.doesNotMatch(observatory, /replayObservationSession|deleteObservationSession/);
assert.doesNotMatch(workspaceCss, /\.observatory-/);
assert.match(observatoryCss, /\.observatory-workspace/);
});
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let buildObservatoryCatalog;
let fetchObservatoryCatalog;
let ObservatoryCatalogContractError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
buildObservatoryCatalog,
fetchObservatoryCatalog,
ObservatoryCatalogContractError,
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
});
after(async () => {
await server?.close();
});
function source(id, startedAtUtc) {
return {
id,
label: `Источник ${id}`,
startedAtUtc,
completedAtUtc: startedAtUtc,
status: "ready",
modalities: ["point-cloud", "video"],
durationSeconds: 42,
replayable: true,
preparation: null,
lab: null,
};
}
function evidence(id, sourceSessionId, publishedAtUtc) {
return {
id,
label: `Результат ${id}`,
startedAtUtc: publishedAtUtc,
completedAtUtc: publishedAtUtc,
status: "ready",
modalities: ["video"],
durationSeconds: 12,
replayable: true,
preparation: null,
lab: {
labId: `LAB-${id}`,
sourceSessionId,
resultKind: "recorded-evidence",
resultId: `result-${id}`,
sourceResultId: null,
configSha256: "a".repeat(64),
runCreatedAtUtc: publishedAtUtc,
publishedAtUtc,
provenance: { verdict: "must-not-be-inferred" },
},
};
}
test("Observatory joins evidence only by sourceSessionId and keeps deterministic order", () => {
const catalog = buildObservatoryCatalog(
[
source("older", "2026-08-28T10:00:00Z"),
source("newer", "2026-08-29T10:00:00Z"),
],
[
evidence("old-result", "newer", "2026-08-29T11:00:00Z"),
evidence("new-result", "newer", "2026-08-29T12:00:00Z"),
evidence("orphan", "missing", "2026-08-29T13:00:00Z"),
],
);
assert.deepEqual(catalog.items.map(({ source: item }) => item.id), ["newer", "older"]);
assert.deepEqual(
catalog.items[0].evidence.map((item) => item.sessionId),
["new-result", "old-result"],
);
assert.deepEqual(catalog.unresolvedEvidence.map((item) => item.sessionId), ["orphan"]);
assert.deepEqual(catalog.window, {
limit: 100,
sourceCount: 2,
laboratoryCount: 3,
sourceLimitReached: false,
laboratoryLimitReached: false,
});
assert.equal("verdict" in catalog.items[0].evidence[0], false);
assert.equal(catalog.items[1].evidence.length, 0);
});
test("Observatory fails visibly when the laboratory projection loses its typed link", () => {
assert.throws(
() => buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[source("not-lab", "2026-08-29T11:00:00Z")],
),
ObservatoryCatalogContractError,
);
});
test("Observatory fetches disjoint read-only source and laboratory projections", async () => {
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const catalog = await fetchObservatoryCatalog({ fetcher, limit: 50 });
assert.deepEqual(catalog, {
items: [],
unresolvedEvidence: [],
window: {
limit: 50,
sourceCount: 0,
laboratoryCount: 0,
sourceLimitReached: false,
laboratoryLimitReached: false,
},
});
assert.deepEqual(
calls.map(({ input }) => input).sort(),
[
"/api/v1/observation-sessions?limit=50&scope=laboratory",
"/api/v1/observation-sessions?limit=50&scope=source",
],
);
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
});
test("Observatory exposes bounded-window uncertainty without inventing a broken link", () => {
const catalog = buildObservatoryCatalog(
[
source("newer", "2026-08-29T10:00:00Z"),
source("older", "2026-08-28T10:00:00Z"),
],
[
evidence("linked", "newer", "2026-08-29T11:00:00Z"),
evidence("outside-window", "older-than-window", "2026-08-29T12:00:00Z"),
],
2,
);
assert.equal(catalog.window.sourceLimitReached, true);
assert.equal(catalog.window.laboratoryLimitReached, true);
assert.deepEqual(
catalog.unresolvedEvidence.map((item) => item.sessionId),
["outside-window"],
);
});
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let productModel;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
productModel = await server.ssrLoadModule("/src/productModel.ts");
});
after(async () => {
await server?.close();
});
async function read(relativePath) {
return readFile(new URL(`../src/${relativePath}`, import.meta.url), "utf8");
}
test("Observatory is the third independent Polygon workspace", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
);
assert.deepEqual(
productModel.workspaceById("observatory"),
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Обсерватория восприятия",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
},
);
assert.equal(productModel.workspaceById("lab-archive").kind, "lab-archive");
});
test("Observatory reads catalog evidence without inheriting replay or LAB composition", async () => {
const [app, workspaceHub, workspace, hook, viewerProfiles, styles] = await Promise.all([
read("App.tsx"),
read("workspaces/Workspaces.tsx"),
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
read("core/observation/viewerProfile.ts"),
read("styles/observatory.css"),
]);
assert.match(app, /activeDefinition\.kind === "observatory"[\s\S]*Только наблюдение/);
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
assert.match(workspace, /useObservatoryCatalog/);
assert.match(workspace, /Связанных результатов нет/);
assert.match(workspace, /не является выводом о качестве/);
assert.match(workspace, /\.evidence\.slice\([\s\S]*MAX_PRESENTED_EVIDENCE/);
assert.match(workspace, /Полный архив остаётся в legacy LAB/);
assert.match(workspace, /Полнота исторических/);
assert.match(workspace, /вне текущего загруженного среза/);
assert.match(workspace, /observatory-notice__copy/);
assert.match(workspace, /observatory-evidence-card/);
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
assert.doesNotMatch(
`${workspace}\n${hook}`,
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|deleteObservationSession|setInterval|setTimeout/,
);
assert.match(viewerProfiles, /kind: "live-acquisition"/);
assert.match(viewerProfiles, /kind: "recorded-session"/);
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
assert.match(
styles,
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
);
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
});
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations"],
["lab-archive", "simulations", "observatory"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -27,6 +27,19 @@ test("top navigation has no Center and Park owns contour health first", () => {
);
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
);
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
assert.deepEqual(
{
root: productModel.workspaceById("observatory")?.root,
kind: productModel.workspaceById("observatory")?.kind,
icon: productModel.workspaceById("observatory")?.icon,
},
{ root: "polygon", kind: "observatory", icon: "eye" },
);
});
test("every laboratory result uses the shared evidence template", async () => {