fix(ui): scope spatial tools to scene

This commit is contained in:
DCCONSTRUCTIONS 2026-07-18 11:31:44 +03:00
parent 574a494759
commit 0ac6424c46
9 changed files with 216 additions and 142 deletions

View File

@ -46,7 +46,6 @@ import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayout
import {
OBSERVATION_WORKSPACE_ID,
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
type ObservationToolWindowId,
type ObservationWorkspaceLayoutProfile,
} from "./core/observation/workspaceLayout";
import {
@ -67,9 +66,7 @@ import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import "./styles/scene-windows.css";
type SceneToolWindowId = ObservationToolWindowId;
const sceneToolWindowIds: readonly SceneToolWindowId[] = ["sources", "display", "layers"];
type SceneToolWindowId = "sources" | "display" | "layers";
const viewerSettingsQuietPeriodMs = 750;
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
@ -166,6 +163,9 @@ export default function App() {
const currentRoot = rootById(activeRoot);
const activeDefinition = workspaceById(workspace.activeView);
const spatialWorkspaceActive = Boolean(
workspace.contentOpen && activeDefinition?.kind === "spatial",
);
const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
@ -292,17 +292,19 @@ export default function App() {
confirmedSceneSettingsRef.current = profile.sceneSettings;
setSceneSettings(profile.sceneSettings);
setDisplayDraft(profile.sceneSettings);
setSourceWindowOpen(profile.toolWindows.sourcesOpen);
setDisplayWindowOpen(profile.toolWindows.displayOpen);
setLayerInspectorOpen(profile.toolWindows.layersOpen);
setSceneWindowOrder(profile.toolWindows.order.filter((windowId) => {
if (windowId === "sources") return profile.toolWindows.sourcesOpen;
if (windowId === "display") return profile.toolWindows.displayOpen;
return profile.toolWindows.layersOpen;
}));
observationLayout.restore(profile);
}, [observationLayout.restore, workspaceLayoutProfile.profile]);
useEffect(() => {
if (spatialWorkspaceActive) return;
// Scene tools are transient children of the spatial workspace. They must
// never survive a route/root/content close or appear over the landing page.
setSourceWindowOpen(false);
setDisplayWindowOpen(false);
setLayerInspectorOpen(false);
setSceneWindowOrder([]);
}, [spatialWorkspaceActive]);
useEffect(() => {
sceneSettingsCommitterActiveRef.current = true;
return () => {
@ -355,7 +357,7 @@ export default function App() {
}, []);
useEffect(() => {
if (!activeSceneWindow) return;
if (!spatialWorkspaceActive || !activeSceneWindow) return;
const closeActiveWindow = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
@ -367,7 +369,7 @@ export default function App() {
document.addEventListener("keydown", closeActiveWindow);
return () => document.removeEventListener("keydown", closeActiveWindow);
}, [activeSceneWindow, closeSceneWindow]);
}, [activeSceneWindow, closeSceneWindow, spatialWorkspaceActive]);
const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId);
@ -383,6 +385,7 @@ export default function App() {
};
const openSource = () => {
if (!spatialWorkspaceActive) return;
setSourceDraft(sourceUrl);
setSourceWindowOpen(true);
activateSceneWindow("sources");
@ -423,11 +426,13 @@ export default function App() {
}, [commitDisplaySettings]);
const openDisplay = () => {
if (!spatialWorkspaceActive) return;
setDisplayWindowOpen(true);
activateSceneWindow("display");
};
const openLayers = () => {
if (!spatialWorkspaceActive) return;
setLayerInspectorOpen(true);
activateSceneWindow("layers");
};
@ -494,37 +499,18 @@ export default function App() {
setLayoutSaveNotice("Сцена ещё не измерила рабочую область.");
return;
}
const openWindowOrder = sceneWindowOrder.filter((windowId) => {
if (windowId === "sources") return sourceWindowOpen;
if (windowId === "display") return displayWindowOpen;
return layerInspectorOpen;
});
const completeWindowOrder = [
...sceneToolWindowIds.filter((windowId) => !openWindowOrder.includes(windowId)),
...openWindowOrder,
];
const draft: ObservationWorkspaceLayoutProfile = {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: workspaceLayoutProfile.profile?.revision ?? 0,
workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: sceneSettingsRef.current,
toolWindows: {
sourcesOpen: sourceWindowOpen,
displayOpen: displayWindowOpen,
layersOpen: layerInspectorOpen,
order: completeWindowOrder,
},
...layout,
};
const saved = await workspaceLayoutProfile.save(draft);
setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error);
}, [
displayWindowOpen,
flushDisplaySettings,
layerInspectorOpen,
observationLayout.snapshot,
sceneWindowOrder,
sourceWindowOpen,
workspaceLayoutProfile,
]);
@ -555,9 +541,6 @@ export default function App() {
disabled: workspaceLayoutProfile.state === "saving",
onClick: () => void saveWorkspaceLayout(),
},
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource },
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
{ label: "Открыть слои", icon: "list", onClick: openLayers },
);
}
@ -731,7 +714,7 @@ export default function App() {
/>
<Window
open={sourceWindowOpen}
open={spatialWorkspaceActive && sourceWindowOpen}
title="Визуальный движок"
subtitle="Rerun gRPC и записи пространственной сцены"
placement="end"
@ -857,7 +840,7 @@ export default function App() {
</Window>
<Window
open={displayWindowOpen}
open={spatialWorkspaceActive && displayWindowOpen}
title="Отображение"
subtitle="Параметры пространственной сцены"
placement="end"
@ -1002,7 +985,7 @@ export default function App() {
</Window>
<Window
open={layerInspectorOpen}
open={spatialWorkspaceActive && layerInspectorOpen}
title="Слои сцены"
subtitle="Сущности пространственной сцены"
placement="end"

View File

@ -1,19 +1,10 @@
import type { SceneSettings } from "../../sceneSettings";
export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 1 as const;
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 2 as const;
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
"/api/v1/workspace-layouts/observation.spatial" as const;
export type ObservationToolWindowId = "sources" | "display" | "layers";
export interface ObservationToolWindows {
sourcesOpen: boolean;
displayOpen: boolean;
layersOpen: boolean;
order: readonly ObservationToolWindowId[];
}
export interface ObservationWindowRect {
x: number;
y: number;
@ -51,7 +42,6 @@ export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnap
revision: number;
workspaceId: typeof OBSERVATION_WORKSPACE_ID;
sceneSettings: SceneSettings;
toolWindows: ObservationToolWindows;
}
export type WorkspaceLayoutFetch = (
@ -76,12 +66,6 @@ type WireProfile = {
show_labels: boolean;
show_camera_frustums: boolean;
};
tool_windows: {
sources_open: boolean;
display_open: boolean;
layers_open: boolean;
order: ObservationToolWindowId[];
};
visible_source_ids: string[];
active_floating_source_id: string | null;
window_rects: Record<string, NormalizedObservationWindowRect>;
@ -93,7 +77,6 @@ const PROFILE_KEYS = new Set([
"revision",
"workspace_id",
"scene_settings",
"tool_windows",
"visible_source_ids",
"active_floating_source_id",
"window_rects",
@ -112,7 +95,6 @@ const SCENE_KEYS = new Set([
"show_labels",
"show_camera_frustums",
]);
const TOOL_WINDOW_KEYS = new Set(["sources_open", "display_open", "layers_open", "order"]);
const VIEWPORT_KEYS = new Set(["width", "height"]);
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
@ -130,7 +112,6 @@ const PALETTES = new Set<SceneSettings["palette"]>([
"grayscale",
"custom",
]);
const TOOL_WINDOW_IDS = new Set<ObservationToolWindowId>(["sources", "display", "layers"]);
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const MAX_SOURCES = 256;
@ -328,25 +309,6 @@ function decodeSceneSettings(value: unknown): SceneSettings {
};
}
function decodeToolWindows(value: unknown): ObservationToolWindows {
const record = requireRecord(value, "tool_windows");
assertExactKeys(record, TOOL_WINDOW_KEYS, "tool_windows");
if (!Array.isArray(record.order) || record.order.length !== TOOL_WINDOW_IDS.size) {
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно содержать три окна.");
}
const order = record.order.map((entry, index) =>
requireEnum(entry, TOOL_WINDOW_IDS, `tool_windows.order[${index}]`));
if (new Set(order).size !== TOOL_WINDOW_IDS.size) {
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно быть перестановкой окон.");
}
return {
sourcesOpen: requireBoolean(record.sources_open, "tool_windows.sources_open"),
displayOpen: requireBoolean(record.display_open, "tool_windows.display_open"),
layersOpen: requireBoolean(record.layers_open, "tool_windows.layers_open"),
order,
};
}
export function decodeObservationWorkspaceLayoutProfile(
value: unknown,
): ObservationWorkspaceLayoutProfile {
@ -376,7 +338,6 @@ export function decodeObservationWorkspaceLayoutProfile(
}),
workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: decodeSceneSettings(record.scene_settings),
toolWindows: decodeToolWindows(record.tool_windows),
visibleSourceIds,
activeFloatingSourceId,
windowRects: decodeWindowRects(record.window_rects),
@ -404,12 +365,6 @@ export function encodeObservationWorkspaceLayoutProfile(
show_labels: profile.sceneSettings.showLabels,
show_camera_frustums: profile.sceneSettings.showCameraFrustums,
},
tool_windows: {
sources_open: profile.toolWindows.sourcesOpen,
display_open: profile.toolWindows.displayOpen,
layers_open: profile.toolWindows.layersOpen,
order: [...profile.toolWindows.order],
},
visible_source_ids: [...profile.visibleSourceIds],
active_floating_source_id: profile.activeFloatingSourceId,
window_rects: Object.fromEntries(
@ -482,12 +437,6 @@ export function validateObservationLayoutSnapshot(
show_labels: false,
show_camera_frustums: true,
},
tool_windows: {
sources_open: false,
display_open: false,
layers_open: false,
order: ["sources", "display", "layers"],
},
visible_source_ids: [...snapshot.visibleSourceIds],
active_floating_source_id: snapshot.activeFloatingSourceId,
window_rects: snapshot.windowRects,

View File

@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
@ -41,7 +42,7 @@ after(async () => {
function wireProfile(overrides = {}) {
return {
version: 1,
version: 2,
revision: 7,
workspace_id: "observation.spatial",
scene_settings: {
@ -57,12 +58,6 @@ function wireProfile(overrides = {}) {
show_labels: false,
show_camera_frustums: true,
},
tool_windows: {
sources_open: true,
display_open: false,
layers_open: true,
order: ["sources", "layers", "display"],
},
visible_source_ids: ["spatial.point-cloud.live", "camera.left"],
active_floating_source_id: "camera.left",
window_rects: {
@ -74,14 +69,14 @@ function wireProfile(overrides = {}) {
};
}
test("workspace layout performs a lossless strict wire/camel/wire round trip", () => {
test("workspace layout v2 excludes transient tool-window state", () => {
const wire = wireProfile();
const profile = decodeObservationWorkspaceLayoutProfile(wire);
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
assert.deepEqual(profile.toolWindows.order, ["sources", "layers", "display"]);
assert.equal(profile.sceneSettings.pointSize, 2.5);
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
assert.equal("tool_windows" in encodeObservationWorkspaceLayoutProfile(profile), false);
});
test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => {
@ -93,7 +88,7 @@ test("workspace layout rejects unknown fields, transient transport data and inva
WorkspaceLayoutContractError,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 2 })),
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 1 })),
/Неподдерживаемая версия/,
);
assert.throws(
@ -114,15 +109,16 @@ test("workspace layout rejects unknown fields, transient transport data and inva
/выходит за нормализованные границы/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
() => decodeObservationWorkspaceLayoutProfile({
...wireProfile(),
tool_windows: {
sources_open: true,
display_open: true,
layers_open: true,
order: ["sources", "sources", "layers"],
order: ["sources", "display", "layers"],
},
})),
/перестановкой/,
}),
/неверная схема/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
@ -227,3 +223,21 @@ test("workspace layout API treats 404 as no profile and exposes revision conflic
error.conflict && error.status === 412 && error.message === "revision mismatch",
);
});
test("scene tool windows are route-scoped and toolbar actions are not duplicated", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const utilityStart = appSource.indexOf("const contentActions");
const utilityEnd = appSource.indexOf("const header", utilityStart);
const utilitySource = appSource.slice(utilityStart, utilityEnd);
assert.match(appSource, /open=\{spatialWorkspaceActive && sourceWindowOpen\}/);
assert.match(appSource, /open=\{spatialWorkspaceActive && displayWindowOpen\}/);
assert.match(appSource, /open=\{spatialWorkspaceActive && layerInspectorOpen\}/);
assert.match(
appSource,
/if \(spatialWorkspaceActive\) return;[\s\S]*setSceneWindowOrder\(\[\]\)/,
);
assert.doesNotMatch(utilitySource, /Настроить визуальный движок/);
assert.doesNotMatch(utilitySource, /Настроить отображение/);
assert.doesNotMatch(utilitySource, /Открыть слои/);
});

View File

@ -36,8 +36,10 @@ sessions contain no recoverable video.
the right half of the bottom timeline. The left half changes point-cloud
accumulation. The recorded timeline is zero-based `session_time`.
8. Use the first disk button to save the current workspace layout. The next
opening restores display settings, tool windows and dynamic source-window
positions. This action saves no sensor evidence.
opening restores display settings and dynamic sensor-source window
positions. The transient **Движок / Слои / Отображение** tool windows are
scoped to the mounted spatial workspace and are never persisted. This action
saves no sensor evidence.
Mission Core does not switch an active acquisition to another spatial source.
Every nonterminal or unknown acquisition state fail-closes saved-session replay,

View File

@ -167,16 +167,19 @@ The first disk action in the spatial workspace saves only the versioned
`observation.spatial` layout document:
- scene display settings;
- open tool windows and their z-order;
- visible dynamic source identifiers;
- active floating source;
- source-window rectangles normalized to the observed viewport.
The document uses optimistic revision control (`ETag` and `If-Match`) and is
restored automatically on the next opening. Unknown source identifiers remain
desired state so a temporarily disconnected camera can recover its saved
window when that source returns. The save action never starts, completes or
modifies an observation session.
Schema v2 deliberately excludes open/closed state and z-order of the transient
**Движок / Слои / Отображение** tool windows. They are children of the mounted
spatial workspace, close on every workspace/root/content exit and always start
closed. Startup migration removes the obsolete `tool_windows` object from a
stored schema-v1 row. The document uses optimistic revision control (`ETag` and
`If-Match`) and is restored automatically on the next opening. Unknown source
identifiers remain desired state so a temporarily disconnected camera can
recover its saved window when that source returns. The save action never starts,
completes or modifies an observation session.
### API boundary

View File

@ -175,10 +175,7 @@ class SessionStore:
).fetchone()
if cursor_row is None:
raise SessionNotFoundError("observation session cursor was not found")
where = (
"WHERE (COALESCE(started_at_utc, ''), session_id) < "
"(COALESCE(?, ''), ?)"
)
where = "WHERE (COALESCE(started_at_utc, ''), session_id) < (COALESCE(?, ''), ?)"
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
parameters.append(limit + 1)
rows = connection.execute(
@ -309,8 +306,7 @@ class SessionStore:
_validate_identifier(session_id, "session id")
with self._connect() as connection:
session = connection.execute(
"SELECT allowed_root, session_root FROM observation_sessions "
"WHERE session_id = ?",
"SELECT allowed_root, session_root FROM observation_sessions WHERE session_id = ?",
(session_id,),
).fetchone()
if session is None:
@ -372,8 +368,8 @@ class SessionStore:
layout: dict[str, Any],
) -> WorkspaceLayout:
_validate_identifier(workspace_id, "workspace id")
if schema_version != 1:
raise ValueError("only workspace layout schema version 1 is supported")
if schema_version not in {1, 2}:
raise ValueError("workspace layout schema version is unsupported")
if expected_revision < 0:
raise ValueError("expected revision must be non-negative")
normalized_name = name.strip()
@ -423,9 +419,9 @@ class SessionStore:
def _initialize(self) -> None:
with self._connect() as connection:
connection.executescript(SCHEMA_SQL)
self._migrate_transient_tool_windows(connection)
session_columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(observation_sessions)")
row["name"] for row in connection.execute("PRAGMA table_info(observation_sessions)")
}
for name, declaration in (
("plugin_id", "TEXT NOT NULL DEFAULT ''"),
@ -441,9 +437,7 @@ class SessionStore:
)
artifact_columns = {
row["name"]
for row in connection.execute(
"PRAGMA table_info(observation_session_artifacts)"
)
for row in connection.execute("PRAGMA table_info(observation_session_artifacts)")
}
if "replay_byte_length" not in artifact_columns:
connection.execute(
@ -454,6 +448,29 @@ class SessionStore:
with _ignore_os_error():
self.database_path.chmod(0o600)
def _migrate_transient_tool_windows(self, connection: sqlite3.Connection) -> None:
"""Remove v1 scene-tool visibility that never belonged in a layout."""
rows = connection.execute(
"SELECT workspace_id, layout_json FROM workspace_layouts "
"WHERE workspace_id = ? AND layout_schema_version = 1",
("observation.spatial",),
).fetchall()
for row in rows:
try:
layout = json.loads(row["layout_json"])
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(layout, dict) or "tool_windows" not in layout:
continue
layout.pop("tool_windows", None)
connection.execute(
"UPDATE workspace_layouts "
"SET layout_schema_version = 2, layout_json = ? "
"WHERE workspace_id = ? AND layout_schema_version = 1",
(_serialize_layout(layout), row["workspace_id"]),
)
def _upsert_candidate(
self,
source: ObservationArchiveSource,

View File

@ -118,7 +118,7 @@ class SceneSettingsDocument(StrictApiModel):
show_camera_frustums: bool
class ToolWindowsDocument(StrictApiModel):
class LegacyToolWindowsDocument(StrictApiModel):
sources_open: bool
display_open: bool
layers_open: bool
@ -151,12 +151,23 @@ class ViewportSize(StrictApiModel):
height: float = Field(gt=0.0, le=100_000.0)
class LayoutPutRequest(StrictApiModel):
class LegacyLayoutV1Document(StrictApiModel):
version: Literal[1]
revision: int = Field(ge=0, le=MAX_SAFE_INTEGER)
workspace_id: Literal["observation.spatial"]
scene_settings: SceneSettingsDocument
tool_windows: ToolWindowsDocument
tool_windows: LegacyToolWindowsDocument
visible_source_ids: list[str]
active_floating_source_id: str | None
window_rects: dict[str, NormalizedWindowRect]
viewport_size: ViewportSize
class LayoutPutRequest(StrictApiModel):
version: Literal[2]
revision: int = Field(ge=0, le=MAX_SAFE_INTEGER)
workspace_id: Literal["observation.spatial"]
scene_settings: SceneSettingsDocument
visible_source_ids: list[str]
active_floating_source_id: str | None
window_rects: dict[str, NormalizedWindowRect]
@ -791,7 +802,12 @@ def build_session_router(
def get_workspace_layout(workspace_id: str, response: Response) -> dict[str, Any]:
try:
stored = store.get_layout(workspace_id)
document = _layout_document(stored.workspace_id, stored.revision, stored.layout)
document = _layout_document(
stored.workspace_id,
stored.schema_version,
stored.revision,
stored.layout,
)
response.headers["ETag"] = f'"{stored.revision}"'
return document
except SessionNotFoundError as exc:
@ -824,7 +840,12 @@ def build_session_router(
layout=payload,
)
response.headers["ETag"] = f'"{stored.revision}"'
return _layout_document(stored.workspace_id, stored.revision, stored.layout)
return _layout_document(
stored.workspace_id,
stored.schema_version,
stored.revision,
stored.layout,
)
except LayoutConflictError as exc:
raise HTTPException(status_code=412, detail=str(exc)) from exc
except ValueError as exc:
@ -885,20 +906,42 @@ def _parse_if_match(value: str) -> int:
def _layout_document(
workspace_id: str,
schema_version: int,
revision: int,
layout: dict[str, Any],
) -> dict[str, Any]:
if schema_version == 1:
try:
legacy = LegacyLayoutV1Document.model_validate(
{
"version": 1,
"revision": revision,
"workspace_id": workspace_id,
**layout,
}
)
except ValueError as exc:
raise SessionIntegrityError(
"stored workspace layout violates schema version 1"
) from exc
layout = legacy.model_dump(
mode="json",
exclude={"version", "revision", "workspace_id", "tool_windows"},
)
schema_version = 2
if schema_version != 2:
raise SessionIntegrityError("stored workspace layout schema is unsupported")
try:
document = LayoutPutRequest.model_validate(
{
"version": 1,
"version": 2,
"revision": revision,
"workspace_id": workspace_id,
**layout,
}
)
except ValueError as exc:
raise SessionIntegrityError("stored workspace layout violates schema version 1") from exc
raise SessionIntegrityError("stored workspace layout violates schema version 2") from exc
return document.model_dump(mode="json")

View File

@ -1662,7 +1662,7 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
put_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "PUT")
get_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "GET")
request = LayoutPutRequest(
version=1,
version=2,
revision=0,
workspace_id="observation.spatial",
scene_settings={
@ -1678,12 +1678,6 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
"show_labels": False,
"show_camera_frustums": True,
},
tool_windows={
"sources_open": True,
"display_open": False,
"layers_open": False,
"order": ["sources", "display", "layers"],
},
visible_source_ids=["sensor.lidar.primary"],
active_floating_source_id=None,
window_rects={"sensor.camera.left": {"x": 0.65, "y": 0.65, "width": 0.3, "height": 0.3}},
@ -1706,13 +1700,12 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
"revision",
"workspace_id",
"scene_settings",
"tool_windows",
"visible_source_ids",
"active_floating_source_id",
"window_rects",
"viewport_size",
}
assert loaded["version"] == 1
assert loaded["version"] == 2
assert loaded["workspace_id"] == "observation.spatial"
assert loaded["revision"] == 1
assert put_response.headers["etag"] == '"1"'
@ -1727,3 +1720,45 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
if_match='"0"',
)
assert conflict.value.status_code == 412
def test_layout_get_migrates_legacy_tool_windows_out_of_the_contract(tmp_path: Path) -> None:
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.save_layout(
"observation.spatial",
schema_version=1,
expected_revision=0,
name="Пространственная сцена",
layout={
"scene_settings": {
"projection": "3d",
"point_size": 3.0,
"color_mode": "intensity",
"palette": "turbo",
"custom_color": "#35d7c1",
"accumulation_seconds": 12,
"show_points": True,
"show_trajectory": True,
"show_grid": True,
"show_labels": False,
"show_camera_frustums": True,
},
"tool_windows": {
"sources_open": True,
"display_open": True,
"layers_open": True,
"order": ["layers", "sources", "display"],
},
"visible_source_ids": [],
"active_floating_source_id": None,
"window_rects": {},
"viewport_size": {"width": 1440, "height": 900},
},
)
router = build_session_router(store)
get_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "GET")
loaded = get_route(workspace_id="observation.spatial", response=Response())
assert loaded["version"] == 2
assert "tool_windows" not in loaded

View File

@ -788,3 +788,31 @@ def test_layout_save_is_atomic_and_revision_checked(tmp_path: Path) -> None:
)
assert second.revision == 2
assert store.get_layout("observation.spatial").layout["visible_source_ids"] == []
def test_layout_startup_migration_removes_transient_tool_window_state(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
data_dir = tmp_path / "data"
initial = SessionStore(repository, data_dir=data_dir)
initial.save_layout(
"observation.spatial",
schema_version=1,
expected_revision=0,
name="Пространственная сцена",
layout={
"tool_windows": {
"sources_open": True,
"display_open": True,
"layers_open": True,
"order": ["sources", "display", "layers"],
},
"visible_source_ids": [],
},
)
migrated = SessionStore(repository, data_dir=data_dir).get_layout("observation.spatial")
assert migrated.schema_version == 2
assert "tool_windows" not in migrated.layout