From 0ac6424c468e6958249565b9815b8e6e73fd6e56 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 18 Jul 2026 11:31:44 +0300 Subject: [PATCH] fix(ui): scope spatial tools to scene --- apps/control-station/src/App.tsx | 61 +++++++------------ .../src/core/observation/workspaceLayout.ts | 53 +--------------- .../test/workspaceLayout.test.mjs | 42 ++++++++----- docs/09_OBSERVATION_SESSIONS.md | 6 +- ...servation-sessions-and-workspace-layout.md | 15 +++-- src/k1link/sessions/store.py | 43 +++++++++---- src/k1link/web/session_api.py | 57 ++++++++++++++--- tests/test_session_api.py | 53 +++++++++++++--- tests/test_session_store.py | 28 +++++++++ 9 files changed, 216 insertions(+), 142 deletions(-) diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index e18b68c..1684e44 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -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() { /> ; @@ -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(["3d", "2d", "map"]); @@ -130,7 +112,6 @@ const PALETTES = new Set([ "grayscale", "custom", ]); -const TOOL_WINDOW_IDS = new Set(["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, diff --git a/apps/control-station/test/workspaceLayout.test.mjs b/apps/control-station/test/workspaceLayout.test.mjs index 248c944..fca7559 100644 --- a/apps/control-station/test/workspaceLayout.test.mjs +++ b/apps/control-station/test/workspaceLayout.test.mjs @@ -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, /Открыть слои/); +}); diff --git a/docs/09_OBSERVATION_SESSIONS.md b/docs/09_OBSERVATION_SESSIONS.md index 932c3e2..f6c4f5c 100644 --- a/docs/09_OBSERVATION_SESSIONS.md +++ b/docs/09_OBSERVATION_SESSIONS.md @@ -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, diff --git a/docs/adr/0008-durable-observation-sessions-and-workspace-layout.md b/docs/adr/0008-durable-observation-sessions-and-workspace-layout.md index 886ef48..a8c4baa 100644 --- a/docs/adr/0008-durable-observation-sessions-and-workspace-layout.md +++ b/docs/adr/0008-durable-observation-sessions-and-workspace-layout.md @@ -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 diff --git a/src/k1link/sessions/store.py b/src/k1link/sessions/store.py index 721fdf4..7affb1b 100644 --- a/src/k1link/sessions/store.py +++ b/src/k1link/sessions/store.py @@ -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, diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py index 0ae893c..59497bd 100644 --- a/src/k1link/web/session_api.py +++ b/src/k1link/web/session_api.py @@ -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") diff --git a/tests/test_session_api.py b/tests/test_session_api.py index ebaf049..9a7277e 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -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 diff --git a/tests/test_session_store.py b/tests/test_session_store.py index 82d0297..8df74fe 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -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