@weasel-js/labkit 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +6 -6
  2. package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
  3. package/dist/_dts/{useExperimentState-D7EQnnwJ.d.ts → useTrialState-DYe2vUwN.d.ts} +60 -36
  4. package/dist/canvas/index.d.ts +1 -1
  5. package/dist/chunk-3TYUJR7Z.js +488 -0
  6. package/dist/chunk-3TYUJR7Z.js.map +1 -0
  7. package/dist/{chunk-BAPZPDDA.js → chunk-C6GJKPUI.js} +365 -340
  8. package/dist/chunk-C6GJKPUI.js.map +1 -0
  9. package/dist/{chunk-DWV7SFKR.js → chunk-DJLDIRFN.js} +28 -12
  10. package/dist/chunk-DJLDIRFN.js.map +1 -0
  11. package/dist/{chunk-DEWXYFEU.js → chunk-NRKWVTVT.js} +3 -3
  12. package/dist/{chunk-DEWXYFEU.js.map → chunk-NRKWVTVT.js.map} +1 -1
  13. package/dist/dragdrop/index.d.ts +1 -1
  14. package/dist/index.d.ts +149 -149
  15. package/dist/index.js +164 -159
  16. package/dist/index.js.map +1 -1
  17. package/dist/layers/index.d.ts +2 -2
  18. package/dist/passthrough/weasel-canvas.js +1 -1
  19. package/dist/passthrough/weasel-ui.d.ts +9 -2
  20. package/dist/passthrough/weasel-ui.js +2 -2
  21. package/dist/state/index.d.ts +12 -12
  22. package/dist/state/index.js +5 -5
  23. package/dist/state/index.js.map +1 -1
  24. package/dist/styles.css +21 -13
  25. package/dist/ui/layers/index.js +3 -3
  26. package/dist/undo/index.d.ts +1 -1
  27. package/package.json +1 -1
  28. package/src/canvas/AGENTS.md +6 -2
  29. package/src/dragdrop/dragDrop.test.tsx +4 -7
  30. package/src/index.test.ts +52 -0
  31. package/src/index.ts +11 -7
  32. package/src/instrument/SineWave.smoke.test.tsx +1 -1
  33. package/src/instrument/capabilityDetector.ts +1 -1
  34. package/src/instrument/types.ts +18 -6
  35. package/src/lab/Lab.less +16 -0
  36. package/src/lab/Lab.stories.tsx +5 -5
  37. package/src/lab/Lab.test.tsx +25 -25
  38. package/src/lab/Lab.tsx +50 -50
  39. package/src/lab/LabContext.ts +12 -12
  40. package/src/lab/LabShell.tsx +1 -1
  41. package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
  42. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
  43. package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
  44. package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +18 -18
  45. package/src/lab/index.ts +2 -2
  46. package/src/layers/AGENTS.md +3 -3
  47. package/src/primitives/Toolbar.stories.tsx +2 -2
  48. package/src/state/SingletonExperiment.test.tsx +8 -8
  49. package/src/state/SingletonExperiment.tsx +9 -9
  50. package/src/state/context.tsx +12 -12
  51. package/src/state/document.test.ts +369 -0
  52. package/src/state/document.ts +194 -0
  53. package/src/state/helpers.test.ts +74 -46
  54. package/src/state/helpers.ts +21 -29
  55. package/src/state/index.ts +16 -8
  56. package/src/state/store.test.ts +440 -41
  57. package/src/state/store.ts +159 -102
  58. package/src/state/types.ts +29 -12
  59. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  60. package/src/state/useTrialState.ts +29 -0
  61. package/src/styles.less +2 -2
  62. package/src/theme/base.less +1 -1
  63. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  64. package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
  65. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  66. package/src/trial/Trial.canvas.test.tsx +80 -0
  67. package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
  68. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -18
  69. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
  70. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
  71. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
  72. package/src/{workspace → trial}/index.ts +13 -13
  73. package/src/{workspace → trial}/slotTypes.ts +16 -16
  74. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
  75. package/src/trial/trialOps.ts +99 -0
  76. package/src/ui/format.test.ts +33 -0
  77. package/src/ui/format.ts +11 -0
  78. package/src/ui/properties/CurveField.tsx +6 -6
  79. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  80. package/dist/chunk-BAPZPDDA.js.map +0 -1
  81. package/dist/chunk-DWV7SFKR.js.map +0 -1
  82. package/dist/chunk-KSTEW2AF.js +0 -367
  83. package/dist/chunk-KSTEW2AF.js.map +0 -1
  84. package/src/state/useExperimentState.ts +0 -31
  85. package/src/workspace/workspaceOps.ts +0 -102
@@ -31,9 +31,9 @@ The instrument declares layer ids:
31
31
  layers: { ids: ['grid', 'plants'] }
32
32
  ```
33
33
 
34
- `Workspace.tsx` converts each id into a `LayerDescriptor` (with `label === id` by default) and tracks `visibility` and a derived `layerOrder` in local state. The order produced by `onReorder` is then applied to `instrument.canvas.layers` before they're passed to `<CanvasStack>`.
34
+ `Trial.tsx` converts each id into a `LayerDescriptor` (with `label === id` by default) and tracks `visibility` and a derived `layerOrder` in local state. The order produced by `onReorder` is then applied to `instrument.canvas.layers` before they're passed to `<CanvasStack>`.
35
35
 
36
- There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the workspace assumes the ids match. If you declare a layer in `canvas` that isn't in `layers.ids`, it stays in default order and is always visible.
36
+ There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the trial assumes the ids match. If you declare a layer in `canvas` that isn't in `layers.ids`, it stays in default order and is always visible.
37
37
 
38
38
  ## `alwaysOn` semantics
39
39
 
@@ -52,7 +52,7 @@ Pointer capture is acquired on `pointerdown` and released on `pointerup`, so dra
52
52
 
53
53
  ## Empty state
54
54
 
55
- If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers". Workspace already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
55
+ If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers". Trial already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
56
56
 
57
57
  ## When to fork
58
58
 
@@ -12,7 +12,7 @@ type Story = StoryObj<typeof Toolbar>;
12
12
  export const Default: Story = {
13
13
  render: () => (
14
14
  <Toolbar>
15
- <Toolbar.Title>My Workspace</Toolbar.Title>
15
+ <Toolbar.Title>My Trial</Toolbar.Title>
16
16
  <Toolbar.Button onClick={() => {}}>Undo</Toolbar.Button>
17
17
  <Toolbar.Button onClick={() => {}}>Redo</Toolbar.Button>
18
18
  <Toolbar.Spacer />
@@ -24,7 +24,7 @@ export const Default: Story = {
24
24
  export const WithDisabled: Story = {
25
25
  render: () => (
26
26
  <Toolbar>
27
- <Toolbar.Title>Empty workspace</Toolbar.Title>
27
+ <Toolbar.Title>Empty trial</Toolbar.Title>
28
28
  <Toolbar.Button onClick={() => {}} disabled>
29
29
  Undo
30
30
  </Toolbar.Button>
@@ -1,8 +1,9 @@
1
1
  import { act, render, renderHook } from '@testing-library/react';
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import { createMemoryAdapter } from './adapters';
4
+ import { labDocumentKey } from './document';
4
5
  import { SingletonExperimentProvider } from './SingletonExperiment';
5
- import { useExperimentState } from './useExperimentState';
6
+ import { useTrialState } from './useTrialState';
6
7
 
7
8
  interface Config {
8
9
  width: number;
@@ -17,7 +18,7 @@ describe('SingletonExperimentProvider', () => {
17
18
  vi.useRealTimers();
18
19
  });
19
20
 
20
- it('exposes config and state via useExperimentState', () => {
21
+ it('exposes config and state via useTrialState', () => {
21
22
  const wrapper = ({ children }: { children: React.ReactNode }) => (
22
23
  <SingletonExperimentProvider<State, Config>
23
24
  id="test"
@@ -29,7 +30,7 @@ describe('SingletonExperimentProvider', () => {
29
30
  {children}
30
31
  </SingletonExperimentProvider>
31
32
  );
32
- const { result } = renderHook(() => useExperimentState<State, Config>(), { wrapper });
33
+ const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
33
34
  expect(result.current.config).toEqual({ width: 100, bg: '#000' });
34
35
  expect(result.current.state).toEqual({ zoom: 1 });
35
36
  });
@@ -38,7 +39,7 @@ describe('SingletonExperimentProvider', () => {
38
39
  vi.useFakeTimers();
39
40
  const storage = createMemoryAdapter();
40
41
  const Probe = () => {
41
- const h = useExperimentState<State, Config>();
42
+ const h = useTrialState<State, Config>();
42
43
  return (
43
44
  <button type="button" onClick={() => h.setConfig('width', 200)}>
44
45
  go
@@ -62,15 +63,14 @@ describe('SingletonExperimentProvider', () => {
62
63
  act(() => {
63
64
  vi.advanceTimersByTime(400);
64
65
  });
65
- // createLabStore uses labStorageKey which prefixes with "lk:"
66
- const raw = storage.read('lk:test:workspaces');
66
+ const raw = storage.read(labDocumentKey('test'));
67
67
  expect(raw).toBeTruthy();
68
68
  expect(raw).toContain('"width":200');
69
69
  });
70
70
 
71
71
  it('rehydrates from storage on mount', () => {
72
72
  const storage = createMemoryAdapter();
73
- // createLabStore uses labStorageKey which prefixes with "lk:"
73
+ // A pre-document lab, which createLabStore folds forward on hydration.
74
74
  storage.write(
75
75
  'lk:test:workspaces',
76
76
  JSON.stringify([
@@ -94,7 +94,7 @@ describe('SingletonExperimentProvider', () => {
94
94
  {children}
95
95
  </SingletonExperimentProvider>
96
96
  );
97
- const { result } = renderHook(() => useExperimentState<State, Config>(), { wrapper });
97
+ const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
98
98
  expect(result.current.config).toEqual({ width: 999, bg: '#fff' });
99
99
  expect(result.current.state).toEqual({ zoom: 2 });
100
100
  });
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode, useRef } from 'react';
2
- import { LabStoreProvider, WorkspaceIdProvider } from './context';
2
+ import { LabStoreProvider, TrialIdProvider } from './context';
3
3
  import { createLabStore, type LabStore } from './store';
4
4
  import type { StorageAdapter } from './types';
5
5
 
@@ -7,8 +7,8 @@ const SINGLETON_INSTRUMENT = '__singleton__';
7
7
 
8
8
  /** Props for `<SingletonExperimentProvider>`. */
9
9
  export interface SingletonExperimentProviderProps<TS, TC> {
10
- /** Stable id for the synthetic workspace; also doubles as the
11
- * WorkspaceIdContext value. */
10
+ /** Stable id for the synthetic trial; also doubles as the
11
+ * TrialIdContext value. */
12
12
  id: string;
13
13
  initialConfig: TC;
14
14
  initialState: TS;
@@ -18,9 +18,9 @@ export interface SingletonExperimentProviderProps<TS, TC> {
18
18
  }
19
19
 
20
20
  /**
21
- * One-workspace `<Lab>` substitute for single-screen experiments. Mounts
22
- * a `LabStoreProvider` + `WorkspaceIdProvider` with one synthetic
23
- * workspace, so `useExperimentState` works without going through the
21
+ * One-trial `<Lab>` substitute for single-screen experiments. Mounts
22
+ * a `LabStoreProvider` + `TrialIdProvider` with one synthetic
23
+ * trial, so `useTrialState` works without going through the
24
24
  * full `<Lab instruments={...}>` runtime.
25
25
  */
26
26
  export function SingletonExperimentProvider<TS, TC>({
@@ -34,8 +34,8 @@ export function SingletonExperimentProvider<TS, TC>({
34
34
  const storeRef = useRef<LabStore | null>(null);
35
35
  if (storeRef.current === null) {
36
36
  const store = createLabStore({ storageKey, storage });
37
- if (!store.getState().workspaces.some((w) => w.id === id)) {
38
- store.getState().addWorkspace({
37
+ if (!store.getState().trials.some((w) => w.id === id)) {
38
+ store.getState().addTrial({
39
39
  id,
40
40
  instrumentName: SINGLETON_INSTRUMENT,
41
41
  config: initialConfig,
@@ -47,7 +47,7 @@ export function SingletonExperimentProvider<TS, TC>({
47
47
  }
48
48
  return (
49
49
  <LabStoreProvider store={storeRef.current}>
50
- <WorkspaceIdProvider workspaceId={id}>{children}</WorkspaceIdProvider>
50
+ <TrialIdProvider trialId={id}>{children}</TrialIdProvider>
51
51
  </LabStoreProvider>
52
52
  );
53
53
  }
@@ -27,25 +27,25 @@ export function useLabStore(): LabStoreState & ReturnType<LabStore['getState']>
27
27
  return useStore(ctx.store);
28
28
  }
29
29
 
30
- /** Context carrying which workspace the subtree belongs to. */
31
- export const WorkspaceIdContext = createContext<string | null>(null);
30
+ /** Context carrying which trial the subtree belongs to. */
31
+ export const TrialIdContext = createContext<string | null>(null);
32
32
 
33
- /** Names the workspace its subtree belongs to, so an instrument's hooks can
33
+ /** Names the trial its subtree belongs to, so an instrument's hooks can
34
34
  * find their own record in the store without being passed an id. */
35
- export function WorkspaceIdProvider({
36
- workspaceId,
35
+ export function TrialIdProvider({
36
+ trialId,
37
37
  children,
38
38
  }: {
39
- workspaceId: string;
39
+ trialId: string;
40
40
  children: ReactNode;
41
41
  }): ReactElement {
42
- return <WorkspaceIdContext.Provider value={workspaceId}>{children}</WorkspaceIdContext.Provider>;
42
+ return <TrialIdContext.Provider value={trialId}>{children}</TrialIdContext.Provider>;
43
43
  }
44
44
 
45
- /** The id of the workspace this component is inside. Throws outside a
46
- * `<WorkspaceIdProvider>`. */
47
- export function useWorkspaceId(): string {
48
- const id = useContext(WorkspaceIdContext);
49
- if (!id) throw new Error('[labkit] useWorkspaceId must be used inside <WorkspaceIdProvider>');
45
+ /** The id of the trial this component is inside. Throws outside a
46
+ * `<TrialIdProvider>`. */
47
+ export function useTrialId(): string {
48
+ const id = useContext(TrialIdContext);
49
+ if (!id) throw new Error('[labkit] useTrialId must be used inside <TrialIdProvider>');
50
50
  return id;
51
51
  }
@@ -0,0 +1,369 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createMemoryAdapter } from './adapters';
3
+ import {
4
+ CURRENT_DOCUMENT_VERSION,
5
+ deleteLegacyKeys,
6
+ emptyDocument,
7
+ labDocumentKey,
8
+ MIGRATIONS,
9
+ migrateV0toV1,
10
+ migrateV1toV2,
11
+ normalizeDocument,
12
+ quarantineDocument,
13
+ quarantineKey,
14
+ readLegacyDocument,
15
+ runMigrations,
16
+ } from './document';
17
+ import { labStorageKey } from './helpers';
18
+
19
+ describe('document keys', () => {
20
+ it('namespaces the document key by storageKey', () => {
21
+ expect(labDocumentKey('mylab')).toBe('lk:mylab:doc');
22
+ });
23
+
24
+ it('namespaces the quarantine key by storageKey', () => {
25
+ expect(quarantineKey('mylab')).toBe('lk:mylab:quarantine');
26
+ });
27
+
28
+ it('cannot collide with a legacy bucket key of another lab', () => {
29
+ for (const bucket of ['workspaces', 'saves', 'layout', 'theme'] as const) {
30
+ expect(labDocumentKey(`a:${bucket}`)).not.toBe(labStorageKey('a', bucket));
31
+ expect(labDocumentKey('a')).not.toBe(labStorageKey('a', bucket));
32
+ }
33
+ });
34
+
35
+ it('cannot collide with a quarantine key of another lab', () => {
36
+ expect(labDocumentKey('a:quarantine')).not.toBe(quarantineKey('a'));
37
+ });
38
+ });
39
+
40
+ describe('emptyDocument', () => {
41
+ it('is stamped at the current version with the given mode', () => {
42
+ const doc = emptyDocument('light');
43
+ expect(doc.version).toBe(CURRENT_DOCUMENT_VERSION);
44
+ expect(doc.mode).toBe('light');
45
+ expect(doc.trials).toEqual([]);
46
+ expect(doc.saves).toEqual([]);
47
+ expect(doc.layout).toEqual({});
48
+ });
49
+ });
50
+
51
+ const bump = (to: number) => (doc: Record<string, unknown>) => ({ ...doc, version: to });
52
+
53
+ describe('runMigrations', () => {
54
+ it('returns a current-version document untouched', () => {
55
+ const doc = { version: 2, a: 1 };
56
+ const out = runMigrations(doc, [bump(1), bump(2)], 2);
57
+ expect(out).toEqual({ ok: true, doc: { version: 2, a: 1 }, migrated: false });
58
+ });
59
+
60
+ it('runs every migration from the stored version to the current one', () => {
61
+ const out = runMigrations({ version: 0 }, [bump(1), bump(2)], 2);
62
+ expect(out).toEqual({ ok: true, doc: { version: 2 }, migrated: true });
63
+ });
64
+
65
+ it('refuses a document from a future version', () => {
66
+ const out = runMigrations({ version: 9 }, [bump(1)], 1);
67
+ expect(out).toEqual({ ok: false, reason: 'future' });
68
+ });
69
+
70
+ it('reports a throwing migration as failed', () => {
71
+ const boom = () => {
72
+ throw new Error('nope');
73
+ };
74
+ const out = runMigrations({ version: 0 }, [boom], 1);
75
+ expect(out.ok).toBe(false);
76
+ if (!out.ok) expect(out.reason).toBe('failed');
77
+ });
78
+
79
+ it('treats a document with no version as version 0', () => {
80
+ const out = runMigrations({}, [bump(1)], 1);
81
+ expect(out).toEqual({ ok: true, doc: { version: 1 }, migrated: true });
82
+ });
83
+ });
84
+
85
+ describe('readLegacyDocument', () => {
86
+ it('returns null when no legacy key is present', () => {
87
+ expect(readLegacyDocument(createMemoryAdapter(), 'lab', 'auto')).toBeNull();
88
+ });
89
+
90
+ it('assembles a version-0 document from the four buckets', () => {
91
+ const storage = createMemoryAdapter();
92
+ storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
93
+ storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
94
+ storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
95
+ storage.write(labStorageKey('lab', 'theme'), 'dark');
96
+
97
+ expect(readLegacyDocument(storage, 'lab', 'auto')).toEqual({
98
+ version: 0,
99
+ workspaces: [{ id: 'w1' }],
100
+ saves: [{ id: 's1' }],
101
+ layout: { w1: { h: 4 } },
102
+ mode: 'dark',
103
+ });
104
+ });
105
+
106
+ it('survives one unparseable bucket without losing the others', () => {
107
+ const storage = createMemoryAdapter();
108
+ storage.write(labStorageKey('lab', 'workspaces'), '{{{not json');
109
+ storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
110
+
111
+ const doc = readLegacyDocument(storage, 'lab', 'auto');
112
+ expect(doc?.workspaces).toEqual([]);
113
+ expect(doc?.saves).toEqual([{ id: 's1' }]);
114
+ });
115
+
116
+ it('falls back to fallbackMode when the theme key is absent', () => {
117
+ const storage = createMemoryAdapter();
118
+ storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([]));
119
+
120
+ expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
121
+ });
122
+
123
+ it('falls back to fallbackMode when the theme value is unrecognized', () => {
124
+ const storage = createMemoryAdapter();
125
+ storage.write(labStorageKey('lab', 'theme'), 'chartreuse');
126
+
127
+ expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
128
+ });
129
+
130
+ it('a valid theme value beats the fallback', () => {
131
+ const storage = createMemoryAdapter();
132
+ storage.write(labStorageKey('lab', 'theme'), 'light');
133
+
134
+ expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('light');
135
+ });
136
+
137
+ it('passes interstellar through untouched for migrateV0toV1 to coerce', () => {
138
+ const storage = createMemoryAdapter();
139
+ storage.write(labStorageKey('lab', 'theme'), 'interstellar');
140
+
141
+ expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('interstellar');
142
+ });
143
+ });
144
+
145
+ describe('deleteLegacyKeys', () => {
146
+ it('deletes all four legacy buckets when the document matches', () => {
147
+ const storage = createMemoryAdapter();
148
+ storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
149
+ storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
150
+ storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
151
+ storage.write(labStorageKey('lab', 'theme'), 'dark');
152
+ storage.write(labDocumentKey('lab'), 'the-document');
153
+
154
+ expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(true);
155
+
156
+ expect(storage.read(labStorageKey('lab', 'workspaces'))).toBeNull();
157
+ expect(storage.read(labStorageKey('lab', 'saves'))).toBeNull();
158
+ expect(storage.read(labStorageKey('lab', 'layout'))).toBeNull();
159
+ expect(storage.read(labStorageKey('lab', 'theme'))).toBeNull();
160
+ });
161
+
162
+ it('the document key itself survives deletion', () => {
163
+ const storage = createMemoryAdapter();
164
+ storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
165
+ storage.write(labDocumentKey('lab'), 'the-document');
166
+
167
+ deleteLegacyKeys(storage, 'lab', 'the-document');
168
+
169
+ expect(storage.read(labDocumentKey('lab'))).toBe('the-document');
170
+ });
171
+
172
+ it('deletes nothing and returns false when the document key is absent', () => {
173
+ const storage = createMemoryAdapter();
174
+ storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
175
+
176
+ expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
177
+ expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
178
+ });
179
+
180
+ it('returns false when the adapter cannot delete, and the keys survive', () => {
181
+ const mem = createMemoryAdapter();
182
+ const storage = { read: mem.read, write: mem.write };
183
+ storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
184
+ storage.write(labDocumentKey('lab'), 'the-document');
185
+
186
+ expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
187
+ expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
188
+ });
189
+
190
+ it('deletes nothing and returns false when the document key holds different content', () => {
191
+ const storage = createMemoryAdapter();
192
+ storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
193
+ storage.write(labDocumentKey('lab'), 'something-else');
194
+
195
+ expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
196
+ expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
197
+ });
198
+ });
199
+
200
+ describe('migrateV0toV1', () => {
201
+ it('stamps version 1', () => {
202
+ expect(migrateV0toV1({ version: 0 }).version).toBe(1);
203
+ });
204
+
205
+ it('renames the old interstellar mode to dark', () => {
206
+ expect(migrateV0toV1({ version: 0, mode: 'interstellar' }).mode).toBe('dark');
207
+ });
208
+
209
+ it('replaces an unrecognized mode with auto', () => {
210
+ expect(migrateV0toV1({ version: 0, mode: 'chartreuse' }).mode).toBe('auto');
211
+ });
212
+
213
+ it.each(['light', 'dark', 'auto'] as const)('passes mode %s through unchanged', (mode) => {
214
+ expect(migrateV0toV1({ version: 0, mode }).mode).toBe(mode);
215
+ });
216
+
217
+ it('fills in every missing section', () => {
218
+ expect(migrateV0toV1({ version: 0 })).toEqual({
219
+ version: 1,
220
+ workspaces: [],
221
+ saves: [],
222
+ layout: {},
223
+ mode: 'auto',
224
+ });
225
+ });
226
+
227
+ it('preserves a populated workspaces array', () => {
228
+ const workspaces = [{ id: 'w1', instrumentName: 'osc' }];
229
+ expect(migrateV0toV1({ version: 0, workspaces }).workspaces).toEqual(workspaces);
230
+ });
231
+
232
+ it('preserves a populated saves array', () => {
233
+ const saves = [{ id: 's1', name: 'my save' }];
234
+ expect(migrateV0toV1({ version: 0, saves }).saves).toEqual(saves);
235
+ });
236
+
237
+ it('preserves a populated layout object', () => {
238
+ const layout = { w1: { h: 4 } };
239
+ expect(migrateV0toV1({ version: 0, layout }).layout).toEqual(layout);
240
+ });
241
+
242
+ it('falls back layout to {} and workspaces/saves to [] without swapping them', () => {
243
+ const out = migrateV0toV1({ version: 0, workspaces: 'nope', saves: 'nope', layout: 'nope' });
244
+ expect(out.workspaces).toEqual([]);
245
+ expect(out.saves).toEqual([]);
246
+ expect(out.layout).toEqual({});
247
+ });
248
+ });
249
+
250
+ describe('migrateV1toV2', () => {
251
+ it('stamps version 2', () => {
252
+ expect(migrateV1toV2({ version: 1, workspaces: [] }).version).toBe(2);
253
+ });
254
+
255
+ it('renames workspaces to trials, record for record', () => {
256
+ const records = [{ id: 'w1', instrumentName: 'osc', state: { n: 1 } }];
257
+ const out = migrateV1toV2({ version: 1, workspaces: records });
258
+ expect(out.trials).toEqual(records);
259
+ expect(out).not.toHaveProperty('workspaces');
260
+ });
261
+
262
+ it('leaves the other sections alone', () => {
263
+ const out = migrateV1toV2({
264
+ version: 1,
265
+ workspaces: [],
266
+ saves: [{ id: 's1' }],
267
+ layout: { w1: { h: 4 } },
268
+ mode: 'dark',
269
+ });
270
+ expect(out.saves).toEqual([{ id: 's1' }]);
271
+ expect(out.layout).toEqual({ w1: { h: 4 } });
272
+ expect(out.mode).toBe('dark');
273
+ });
274
+
275
+ it('falls back to an empty trials list when workspaces is the wrong shape', () => {
276
+ expect(migrateV1toV2({ version: 1, workspaces: 'nope' }).trials).toEqual([]);
277
+ });
278
+ });
279
+
280
+ describe('MIGRATIONS', () => {
281
+ it('has one entry per version below the current one', () => {
282
+ expect(MIGRATIONS).toHaveLength(CURRENT_DOCUMENT_VERSION);
283
+ });
284
+
285
+ it('indexes migrateV0toV1 at position 0', () => {
286
+ expect(MIGRATIONS[0]).toBe(migrateV0toV1);
287
+ });
288
+
289
+ it('indexes migrateV1toV2 at position 1', () => {
290
+ expect(MIGRATIONS[1]).toBe(migrateV1toV2);
291
+ });
292
+
293
+ it('walks a version-0 document all the way to trials', () => {
294
+ const outcome = runMigrations(
295
+ { version: 0, workspaces: [{ id: 'w1' }] },
296
+ MIGRATIONS,
297
+ CURRENT_DOCUMENT_VERSION,
298
+ );
299
+ expect(outcome).toMatchObject({ ok: true, migrated: true });
300
+ expect(outcome.ok && outcome.doc.trials).toEqual([{ id: 'w1' }]);
301
+ });
302
+ });
303
+
304
+ describe('quarantineDocument', () => {
305
+ it('copies the document aside and confirms it landed', () => {
306
+ const storage = createMemoryAdapter();
307
+
308
+ expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(true);
309
+ expect(storage.read(quarantineKey('lab'))).toBe('the-bytes');
310
+ });
311
+
312
+ it('returns false when the quarantine write silently fails', () => {
313
+ const storage = createMemoryAdapter();
314
+ storage.write = () => {};
315
+
316
+ expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(false);
317
+ });
318
+ });
319
+
320
+ describe('normalizeDocument', () => {
321
+ it('fills in every missing section', () => {
322
+ expect(normalizeDocument({ version: CURRENT_DOCUMENT_VERSION }, 'auto')).toEqual({
323
+ version: CURRENT_DOCUMENT_VERSION,
324
+ trials: [],
325
+ saves: [],
326
+ layout: {},
327
+ mode: 'auto',
328
+ });
329
+ });
330
+
331
+ it('uses the fallback mode when the document has none', () => {
332
+ expect(normalizeDocument({ version: CURRENT_DOCUMENT_VERSION }, 'light').mode).toBe('light');
333
+ });
334
+
335
+ it('uses the fallback mode when the stored mode is unrecognized', () => {
336
+ expect(
337
+ normalizeDocument({ version: CURRENT_DOCUMENT_VERSION, mode: 'chartreuse' }, 'light').mode,
338
+ ).toBe('light');
339
+ });
340
+
341
+ it('keeps a stored mode over the fallback', () => {
342
+ expect(
343
+ normalizeDocument({ version: CURRENT_DOCUMENT_VERSION, mode: 'dark' }, 'light').mode,
344
+ ).toBe('dark');
345
+ });
346
+
347
+ it('preserves populated sections', () => {
348
+ const trials = [{ id: 'w1' }];
349
+ const saves = [{ id: 's1' }];
350
+ const layout = { w1: { h: 4 } };
351
+ const out = normalizeDocument(
352
+ { version: CURRENT_DOCUMENT_VERSION, trials, saves, layout },
353
+ 'auto',
354
+ );
355
+ expect(out.trials).toEqual(trials);
356
+ expect(out.saves).toEqual(saves);
357
+ expect(out.layout).toEqual(layout);
358
+ });
359
+
360
+ it('replaces wrong-shaped sections without swapping them', () => {
361
+ const out = normalizeDocument(
362
+ { version: CURRENT_DOCUMENT_VERSION, trials: 'nope', saves: 'nope', layout: 'nope' },
363
+ 'auto',
364
+ );
365
+ expect(out.trials).toEqual([]);
366
+ expect(out.saves).toEqual([]);
367
+ expect(out.layout).toEqual({});
368
+ });
369
+ });