@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
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { createMemoryAdapter } from './adapters';
3
+ import { CURRENT_DOCUMENT_VERSION, labDocumentKey, quarantineKey } from './document';
3
4
  import { labStorageKey } from './helpers';
4
5
  import { createLabStore } from './store';
5
6
 
@@ -12,9 +13,9 @@ function makeStore(overrides?: Partial<Parameters<typeof createLabStore>[0]>) {
12
13
  }
13
14
 
14
15
  describe('createLabStore — initial state', () => {
15
- it('starts with empty workspaces and saves', () => {
16
+ it('starts with empty trials and saves', () => {
16
17
  const s = makeStore();
17
- expect(s.getState().workspaces).toEqual([]);
18
+ expect(s.getState().trials).toEqual([]);
18
19
  expect(s.getState().savedSnapshots).toEqual([]);
19
20
  });
20
21
 
@@ -29,79 +30,79 @@ describe('createLabStore — initial state', () => {
29
30
  });
30
31
  });
31
32
 
32
- describe('addWorkspace', () => {
33
- it('adds a workspace with an empty undoStack', () => {
33
+ describe('addTrial', () => {
34
+ it('adds a trial with an empty undoStack', () => {
34
35
  const s = makeStore();
35
- s.getState().addWorkspace({
36
+ s.getState().addTrial({
36
37
  id: 'w1',
37
38
  instrumentName: 'Test',
38
39
  config: {},
39
40
  state: {},
40
41
  view: { zoom: 1, pan: { x: 0, y: 0 } },
41
42
  });
42
- expect(s.getState().workspaces).toHaveLength(1);
43
- expect(s.getState().workspaces[0]?.undoStack).toEqual({ past: [], future: [] });
43
+ expect(s.getState().trials).toHaveLength(1);
44
+ expect(s.getState().trials[0]?.undoStack).toEqual({ past: [], future: [] });
44
45
  });
45
46
  });
46
47
 
47
- describe('removeWorkspace', () => {
48
+ describe('removeTrial', () => {
48
49
  it('removes by id', () => {
49
50
  const s = makeStore();
50
- s.getState().addWorkspace({
51
+ s.getState().addTrial({
51
52
  id: 'w1',
52
53
  instrumentName: 'T',
53
54
  config: {},
54
55
  state: {},
55
56
  view: { zoom: 1, pan: { x: 0, y: 0 } },
56
57
  });
57
- s.getState().removeWorkspace('w1');
58
- expect(s.getState().workspaces).toHaveLength(0);
58
+ s.getState().removeTrial('w1');
59
+ expect(s.getState().trials).toHaveLength(0);
59
60
  });
60
61
  });
61
62
 
62
- describe('updateWorkspaceState', () => {
63
+ describe('updateTrialState', () => {
63
64
  it('updates state with a plain value', () => {
64
65
  const s = makeStore();
65
- s.getState().addWorkspace({
66
+ s.getState().addTrial({
66
67
  id: 'w1',
67
68
  instrumentName: 'T',
68
69
  config: {},
69
70
  state: { n: 0 },
70
71
  view: { zoom: 1, pan: { x: 0, y: 0 } },
71
72
  });
72
- s.getState().updateWorkspaceState('w1', { n: 42 });
73
- expect(s.getState().workspaces[0]?.state).toEqual({ n: 42 });
73
+ s.getState().updateTrialState('w1', { n: 42 });
74
+ expect(s.getState().trials[0]?.state).toEqual({ n: 42 });
74
75
  });
75
76
 
76
77
  it('updates state with an updater function', () => {
77
78
  const s = makeStore();
78
- s.getState().addWorkspace({
79
+ s.getState().addTrial({
79
80
  id: 'w1',
80
81
  instrumentName: 'T',
81
82
  config: {},
82
83
  state: { n: 1 },
83
84
  view: { zoom: 1, pan: { x: 0, y: 0 } },
84
85
  });
85
- s.getState().updateWorkspaceState('w1', (prev: unknown) => ({
86
+ s.getState().updateTrialState('w1', (prev: unknown) => ({
86
87
  n: (prev as { n: number }).n + 1,
87
88
  }));
88
- expect((s.getState().workspaces[0]?.state as { n: number }).n).toBe(2);
89
+ expect((s.getState().trials[0]?.state as { n: number }).n).toBe(2);
89
90
  });
90
91
  });
91
92
 
92
- describe('updateWorkspaceConfig', () => {
93
+ describe('updateTrialConfig', () => {
93
94
  it('updates a single config key', () => {
94
95
  const s = makeStore();
95
- s.getState().addWorkspace({
96
+ s.getState().addTrial({
96
97
  id: 'w1',
97
98
  instrumentName: 'T',
98
99
  config: { x: 1, y: 2 },
99
100
  state: {},
100
101
  view: { zoom: 1, pan: { x: 0, y: 0 } },
101
102
  });
102
- s.getState().updateWorkspaceConfig('w1', 'x' as never, 99 as never);
103
- expect((s.getState().workspaces[0]?.config as { x: number }).x).toBe(99);
104
- expect((s.getState().workspaces[0]?.config as { y: number }).y).toBe(2);
103
+ s.getState().updateTrialConfig('w1', 'x' as never, 99 as never);
104
+ expect((s.getState().trials[0]?.config as { x: number }).x).toBe(99);
105
+ expect((s.getState().trials[0]?.config as { y: number }).y).toBe(2);
105
106
  });
106
107
  });
107
108
 
@@ -123,7 +124,7 @@ describe('setMode', () => {
123
124
  describe('save/load/delete snapshots', () => {
124
125
  it('saveSnapshot creates a snapshot', () => {
125
126
  const s = makeStore();
126
- s.getState().addWorkspace({
127
+ s.getState().addTrial({
127
128
  id: 'w1',
128
129
  instrumentName: 'T',
129
130
  config: { x: 1 },
@@ -139,7 +140,7 @@ describe('save/load/delete snapshots', () => {
139
140
 
140
141
  it('loadSnapshot restores state and config', () => {
141
142
  const s = makeStore();
142
- s.getState().addWorkspace({
143
+ s.getState().addTrial({
143
144
  id: 'w1',
144
145
  instrumentName: 'T',
145
146
  config: { x: 1 },
@@ -147,15 +148,15 @@ describe('save/load/delete snapshots', () => {
147
148
  view: { zoom: 1, pan: { x: 0, y: 0 } },
148
149
  });
149
150
  s.getState().saveSnapshot('w1', 'snap1');
150
- s.getState().updateWorkspaceState('w1', { n: 99 });
151
+ s.getState().updateTrialState('w1', { n: 99 });
151
152
  const snapId = s.getState().savedSnapshots[0]?.id ?? '';
152
153
  s.getState().loadSnapshot(snapId, 'w1');
153
- expect((s.getState().workspaces[0]?.state as { n: number }).n).toBe(5);
154
+ expect((s.getState().trials[0]?.state as { n: number }).n).toBe(5);
154
155
  });
155
156
 
156
157
  it('loadSnapshot blocks cross-instrument load', () => {
157
158
  const s = makeStore();
158
- s.getState().addWorkspace({
159
+ s.getState().addTrial({
159
160
  id: 'w1',
160
161
  instrumentName: 'A',
161
162
  config: {},
@@ -163,7 +164,7 @@ describe('save/load/delete snapshots', () => {
163
164
  view: { zoom: 1, pan: { x: 0, y: 0 } },
164
165
  });
165
166
  s.getState().saveSnapshot('w1', 'snap-a');
166
- s.getState().setWorkspaceInstrument('w1', 'B');
167
+ s.getState().setTrialInstrument('w1', 'B');
167
168
  const snapId = s.getState().savedSnapshots[0]?.id ?? '';
168
169
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
169
170
  s.getState().loadSnapshot(snapId, 'w1');
@@ -173,7 +174,7 @@ describe('save/load/delete snapshots', () => {
173
174
 
174
175
  it('deleteSnapshot removes the snapshot', () => {
175
176
  const s = makeStore();
176
- s.getState().addWorkspace({
177
+ s.getState().addTrial({
177
178
  id: 'w1',
178
179
  instrumentName: 'T',
179
180
  config: {},
@@ -188,7 +189,7 @@ describe('save/load/delete snapshots', () => {
188
189
 
189
190
  it('listSnapshots returns newest first', () => {
190
191
  const s = makeStore();
191
- s.getState().addWorkspace({
192
+ s.getState().addTrial({
192
193
  id: 'w1',
193
194
  instrumentName: 'T',
194
195
  config: {},
@@ -203,11 +204,11 @@ describe('save/load/delete snapshots', () => {
203
204
  });
204
205
 
205
206
  describe('persistence — hydration', () => {
206
- it('hydrates workspaces from storage on construction', () => {
207
+ it('hydrates trials from storage on construction', () => {
207
208
  vi.useFakeTimers();
208
209
  const mem = createMemoryAdapter();
209
210
  const seedStore = createLabStore({ storageKey: 'test', storage: mem });
210
- seedStore.getState().addWorkspace({
211
+ seedStore.getState().addTrial({
211
212
  id: 'w1',
212
213
  instrumentName: 'T',
213
214
  config: {},
@@ -218,8 +219,8 @@ describe('persistence — hydration', () => {
218
219
  vi.useRealTimers();
219
220
 
220
221
  const hydrated = createLabStore({ storageKey: 'test', storage: mem });
221
- expect(hydrated.getState().workspaces).toHaveLength(1);
222
- expect((hydrated.getState().workspaces[0]?.state as { n: number }).n).toBe(7);
222
+ expect(hydrated.getState().trials).toHaveLength(1);
223
+ expect((hydrated.getState().trials[0]?.state as { n: number }).n).toBe(7);
223
224
  });
224
225
  });
225
226
 
@@ -230,23 +231,421 @@ describe('persistence — debounced writes', () => {
230
231
  const writeSpy = vi.spyOn(mem, 'write');
231
232
  const s = createLabStore({ storageKey: 'test', storage: mem });
232
233
 
233
- s.getState().addWorkspace({
234
+ s.getState().addTrial({
234
235
  id: 'w1',
235
236
  instrumentName: 'T',
236
237
  config: {},
237
238
  state: {},
238
239
  view: { zoom: 1, pan: { x: 0, y: 0 } },
239
240
  });
240
- s.getState().updateWorkspaceState('w1', { n: 1 });
241
- s.getState().updateWorkspaceState('w1', { n: 2 });
242
- s.getState().updateWorkspaceState('w1', { n: 3 });
241
+ s.getState().updateTrialState('w1', { n: 1 });
242
+ s.getState().updateTrialState('w1', { n: 2 });
243
+ s.getState().updateTrialState('w1', { n: 3 });
243
244
 
244
245
  const writesBefore = writeSpy.mock.calls.length;
245
246
  vi.advanceTimersByTime(400);
246
247
  const writesAfter = writeSpy.mock.calls.length;
247
248
 
248
- // One flush, one write per storage key: workspaces, saves, theme, layout.
249
- expect(writesAfter - writesBefore).toBe(4);
249
+ // One flush, one document.
250
+ expect(writesAfter - writesBefore).toBe(1);
250
251
  vi.useRealTimers();
251
252
  });
252
253
  });
254
+
255
+ describe('createLabStore — hydrating', () => {
256
+ it('reads a current-version document', () => {
257
+ const storage = createMemoryAdapter();
258
+ storage.write(
259
+ labDocumentKey('test'),
260
+ JSON.stringify({
261
+ version: CURRENT_DOCUMENT_VERSION,
262
+ trials: [
263
+ {
264
+ id: 'w1',
265
+ instrumentName: 'Test',
266
+ config: {},
267
+ state: {},
268
+ view: { zoom: 2, pan: { x: 0, y: 0 } },
269
+ },
270
+ ],
271
+ saves: [],
272
+ layout: { w1: { h: 3 } },
273
+ mode: 'dark',
274
+ }),
275
+ );
276
+
277
+ const s = makeStore({ storage }).getState();
278
+ expect(s.trials).toHaveLength(1);
279
+ expect(s.trials[0].undoStack).toEqual({ past: [], future: [] });
280
+ expect(s.layout).toEqual({ w1: { h: 3 } });
281
+ expect(s.mode).toBe('dark');
282
+ });
283
+
284
+ it('opens a version-1 document, whose tiles were still called workspaces', () => {
285
+ const storage = createMemoryAdapter();
286
+ storage.write(
287
+ labDocumentKey('test'),
288
+ JSON.stringify({
289
+ version: 1,
290
+ workspaces: [
291
+ {
292
+ id: 'w1',
293
+ instrumentName: 'Test',
294
+ config: { gain: 3 },
295
+ state: { n: 7 },
296
+ view: { zoom: 2, pan: { x: 0, y: 0 } },
297
+ },
298
+ ],
299
+ saves: [{ id: 's1', name: 'a save', trialId: 'w1' }],
300
+ layout: { w1: { h: 3 } },
301
+ mode: 'dark',
302
+ }),
303
+ );
304
+
305
+ const s = makeStore({ storage }).getState();
306
+ expect(s.trials).toHaveLength(1);
307
+ expect(s.trials[0]).toMatchObject({ id: 'w1', config: { gain: 3 }, state: { n: 7 } });
308
+ expect(s.savedSnapshots).toHaveLength(1);
309
+ expect(s.layout).toEqual({ w1: { h: 3 } });
310
+ expect(s.mode).toBe('dark');
311
+ });
312
+
313
+ it('folds the four legacy keys into state', () => {
314
+ const storage = createMemoryAdapter();
315
+ storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
316
+ storage.write(labStorageKey('test', 'theme'), 'interstellar');
317
+
318
+ const s = makeStore({ storage }).getState();
319
+ expect(s.mode).toBe('dark');
320
+ });
321
+
322
+ it('honors initialMode when a folded legacy lab never set a theme', () => {
323
+ const storage = createMemoryAdapter();
324
+ storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
325
+
326
+ const s = makeStore({ storage, initialMode: 'dark' }).getState();
327
+ expect(s.mode).toBe('dark');
328
+ });
329
+
330
+ it('starts empty and preserves a document from a future version', () => {
331
+ const storage = createMemoryAdapter();
332
+ const future = JSON.stringify({ version: 999, trials: [{ id: 'w1' }] });
333
+ storage.write(labDocumentKey('test'), future);
334
+
335
+ const store = makeStore({ storage });
336
+ expect(store.getState().trials).toEqual([]);
337
+ expect(storage.read(labDocumentKey('test'))).toBe(future);
338
+ });
339
+
340
+ it('quarantines a document that fails to parse', () => {
341
+ const storage = createMemoryAdapter();
342
+ storage.write(labDocumentKey('test'), '{{{not json');
343
+
344
+ const s = makeStore({ storage }).getState();
345
+ expect(s.trials).toEqual([]);
346
+ expect(storage.read(quarantineKey('test'))).toBe('{{{not json');
347
+ });
348
+ });
349
+
350
+ describe('createLabStore — flushing', () => {
351
+ it('writes one document and no legacy keys', () => {
352
+ vi.useFakeTimers();
353
+ const storage = createMemoryAdapter();
354
+ const s = makeStore({ storage });
355
+ s.getState().setMode('light');
356
+ vi.advanceTimersByTime(400);
357
+ vi.useRealTimers();
358
+
359
+ const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
360
+ expect(doc.version).toBe(CURRENT_DOCUMENT_VERSION);
361
+ expect(doc.mode).toBe('light');
362
+ });
363
+
364
+ it('deletes the legacy keys once the folded document is written', () => {
365
+ vi.useFakeTimers();
366
+ const storage = createMemoryAdapter();
367
+ storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
368
+ storage.write(labStorageKey('test', 'theme'), 'interstellar');
369
+
370
+ const s = makeStore({ storage });
371
+ s.getState().setMode('dark');
372
+ vi.advanceTimersByTime(400);
373
+ vi.useRealTimers();
374
+
375
+ expect(storage.read(labDocumentKey('test'))).not.toBeNull();
376
+ expect(storage.read(labStorageKey('test', 'workspaces'))).toBeNull();
377
+ expect(storage.read(labStorageKey('test', 'theme'))).toBeNull();
378
+ });
379
+
380
+ it('does not write when the stored document is from the future', () => {
381
+ vi.useFakeTimers();
382
+ const storage = createMemoryAdapter();
383
+ const future = JSON.stringify({ version: 999 });
384
+ storage.write(labDocumentKey('test'), future);
385
+
386
+ const s = makeStore({ storage });
387
+ s.getState().setMode('light');
388
+ vi.advanceTimersByTime(400);
389
+ vi.useRealTimers();
390
+
391
+ expect(storage.read(labDocumentKey('test'))).toBe(future);
392
+ });
393
+
394
+ it('keeps the legacy keys when the document write silently fails', () => {
395
+ vi.useFakeTimers();
396
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
397
+ const storage = createMemoryAdapter();
398
+ const write = storage.write.bind(storage);
399
+ // A quota failure as localStorageAdapter reports one: swallowed, void.
400
+ storage.write = (key, value) => {
401
+ if (key === labDocumentKey('test')) return;
402
+ write(key, value);
403
+ };
404
+ storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
405
+ storage.write(labStorageKey('test', 'theme'), 'interstellar');
406
+
407
+ const s = makeStore({ storage });
408
+ s.getState().setMode('dark');
409
+ vi.advanceTimersByTime(400);
410
+ vi.useRealTimers();
411
+
412
+ expect(storage.read(labDocumentKey('test'))).toBeNull();
413
+ expect(storage.read(labStorageKey('test', 'workspaces'))).toBe('[]');
414
+ expect(storage.read(labStorageKey('test', 'theme'))).toBe('interstellar');
415
+ expect(warn).toHaveBeenCalled();
416
+ warn.mockRestore();
417
+ });
418
+ });
419
+
420
+ const VIEW = { zoom: 1, pan: { x: 0, y: 0 } };
421
+
422
+ const SNAPSHOT = {
423
+ id: 's1',
424
+ name: 'saved',
425
+ trialId: 'w1',
426
+ instrumentName: 'T',
427
+ config: { x: 1 },
428
+ state: { n: 5 },
429
+ savedAt: 1000,
430
+ };
431
+
432
+ function writeDocument(
433
+ storage: ReturnType<typeof createMemoryAdapter>,
434
+ doc: Record<string, unknown>,
435
+ storageKey = 'test',
436
+ ): void {
437
+ storage.write(
438
+ labDocumentKey(storageKey),
439
+ JSON.stringify({ version: CURRENT_DOCUMENT_VERSION, ...doc }),
440
+ );
441
+ }
442
+
443
+ describe('createLabStore — labs whose keys once aliased', () => {
444
+ it("keeps a document out of a sibling lab's legacy bucket", () => {
445
+ vi.useFakeTimers();
446
+ const storage = createMemoryAdapter();
447
+
448
+ // Lab "a:saves" writes where lab "a"'s legacy saves bucket used to live.
449
+ const sibling = createLabStore({ storageKey: 'a:saves', storage });
450
+ sibling.getState().addTrial({
451
+ id: 'sw',
452
+ instrumentName: 'T',
453
+ config: {},
454
+ state: { n: 1 },
455
+ view: VIEW,
456
+ });
457
+ vi.advanceTimersByTime(400);
458
+ const siblingDocument = storage.read(labDocumentKey('a:saves'));
459
+ expect(siblingDocument).not.toBeNull();
460
+
461
+ storage.write(labStorageKey('a', 'workspaces'), JSON.stringify([]));
462
+ storage.write(labStorageKey('a', 'theme'), 'dark');
463
+
464
+ const lab = createLabStore({ storageKey: 'a', storage });
465
+ // Lab "a" does not read the sibling's document as its own saves bucket.
466
+ expect(lab.getState().savedSnapshots).toEqual([]);
467
+ expect(lab.getState().trials).toEqual([]);
468
+
469
+ lab.getState().setMode('light');
470
+ vi.advanceTimersByTime(400);
471
+ vi.useRealTimers();
472
+
473
+ // Folding lab "a" deletes its own legacy keys, not the sibling's document.
474
+ expect(storage.read(labStorageKey('a', 'workspaces'))).toBeNull();
475
+ expect(storage.read(labDocumentKey('a:saves'))).toBe(siblingDocument);
476
+ const rehydrated = createLabStore({ storageKey: 'a:saves', storage });
477
+ expect(rehydrated.getState().trials).toHaveLength(1);
478
+ });
479
+ });
480
+
481
+ describe('createLabStore — hydrating an incomplete current-version document', () => {
482
+ it('hydrates a document with no saves as an empty snapshot list', () => {
483
+ const storage = createMemoryAdapter();
484
+ writeDocument(storage, { trials: [], layout: {}, mode: 'dark' });
485
+
486
+ const s = makeStore({ storage }).getState();
487
+ expect(s.savedSnapshots).toEqual([]);
488
+ expect(s.listSnapshots()).toEqual([]);
489
+ });
490
+
491
+ it('hydrates a document with no layout as an empty layout', () => {
492
+ const storage = createMemoryAdapter();
493
+ writeDocument(storage, { trials: [], saves: [], mode: 'dark' });
494
+
495
+ expect(makeStore({ storage }).getState().layout).toEqual({});
496
+ });
497
+
498
+ it('falls back to initialMode when the document has no mode', () => {
499
+ const storage = createMemoryAdapter();
500
+ writeDocument(storage, { trials: [], saves: [], layout: {} });
501
+
502
+ expect(makeStore({ storage, initialMode: 'light' }).getState().mode).toBe('light');
503
+ });
504
+
505
+ it('hydrates a document holding nothing but its version', () => {
506
+ const storage = createMemoryAdapter();
507
+ writeDocument(storage, {});
508
+
509
+ const s = makeStore({ storage }).getState();
510
+ expect(s.trials).toEqual([]);
511
+ expect(s.savedSnapshots).toEqual([]);
512
+ expect(s.layout).toEqual({});
513
+ expect(s.mode).toBe('auto');
514
+ expect(s.listSnapshots()).toEqual([]);
515
+ });
516
+
517
+ it('can save a snapshot into a document that had no saves', () => {
518
+ const storage = createMemoryAdapter();
519
+ writeDocument(storage, {});
520
+
521
+ const s = makeStore({ storage });
522
+ s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
523
+ s.getState().saveSnapshot('w1', 'first');
524
+ expect(s.getState().listSnapshots('w1')).toHaveLength(1);
525
+ });
526
+ });
527
+
528
+ describe('persistence — saves and layout round-trip', () => {
529
+ it('hydrates a populated saves array into the store', () => {
530
+ const storage = createMemoryAdapter();
531
+ writeDocument(storage, { trials: [], saves: [SNAPSHOT], layout: {}, mode: 'auto' });
532
+
533
+ const s = makeStore({ storage }).getState();
534
+ expect(s.savedSnapshots).toEqual([SNAPSHOT]);
535
+ expect(s.listSnapshots('w1')).toEqual([SNAPSHOT]);
536
+ });
537
+
538
+ it('writes the hydrated saves back out on the next flush', () => {
539
+ vi.useFakeTimers();
540
+ const storage = createMemoryAdapter();
541
+ writeDocument(storage, { trials: [], saves: [SNAPSHOT], layout: {}, mode: 'auto' });
542
+
543
+ const s = makeStore({ storage });
544
+ s.getState().setMode('light');
545
+ vi.advanceTimersByTime(400);
546
+ vi.useRealTimers();
547
+
548
+ const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
549
+ expect(doc.saves).toEqual([SNAPSHOT]);
550
+ });
551
+
552
+ it('writes a snapshot saved this session out on flush', () => {
553
+ vi.useFakeTimers();
554
+ const storage = createMemoryAdapter();
555
+ const s = makeStore({ storage });
556
+ s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
557
+ s.getState().saveSnapshot('w1', 'first');
558
+ vi.advanceTimersByTime(400);
559
+ vi.useRealTimers();
560
+
561
+ const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
562
+ expect(doc.saves).toHaveLength(1);
563
+ expect(doc.saves[0].name).toBe('first');
564
+ });
565
+
566
+ it('writes the layout out on flush', () => {
567
+ vi.useFakeTimers();
568
+ const storage = createMemoryAdapter();
569
+ const s = makeStore({ storage });
570
+ s.getState().setLayout({ w1: { h: 9 } });
571
+ vi.advanceTimersByTime(400);
572
+ vi.useRealTimers();
573
+
574
+ const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
575
+ expect(doc.layout).toEqual({ w1: { h: 9 } });
576
+ });
577
+ });
578
+
579
+ describe('createLabStore — quarantining an unusable document', () => {
580
+ it('persists again once the unusable document is safely quarantined', () => {
581
+ vi.useFakeTimers();
582
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
583
+ const storage = createMemoryAdapter();
584
+ storage.write(labDocumentKey('test'), '{{{not json');
585
+
586
+ const s = makeStore({ storage });
587
+ s.getState().setMode('light');
588
+ vi.advanceTimersByTime(400);
589
+ vi.useRealTimers();
590
+
591
+ expect(storage.read(quarantineKey('test'))).toBe('{{{not json');
592
+ const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
593
+ expect(doc.mode).toBe('light');
594
+ warn.mockRestore();
595
+ });
596
+
597
+ it('leaves the original alone when the quarantine copy cannot be written', () => {
598
+ vi.useFakeTimers();
599
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
600
+ const storage = createMemoryAdapter();
601
+ const write = storage.write.bind(storage);
602
+ // A quota failure as localStorageAdapter reports one: swallowed, void.
603
+ storage.write = (key, value) => {
604
+ if (key === quarantineKey('test')) return;
605
+ write(key, value);
606
+ };
607
+ storage.write(labDocumentKey('test'), '{{{not json');
608
+
609
+ const s = makeStore({ storage });
610
+ s.getState().setMode('light');
611
+ vi.advanceTimersByTime(400);
612
+ vi.useRealTimers();
613
+
614
+ expect(storage.read(labDocumentKey('test'))).toBe('{{{not json');
615
+ expect(warn).toHaveBeenCalled();
616
+ warn.mockRestore();
617
+ });
618
+ });
619
+
620
+ describe('createLabStore — folding without a mutation', () => {
621
+ it('completes the fold on first load alone', () => {
622
+ vi.useFakeTimers();
623
+ const storage = createMemoryAdapter();
624
+ storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
625
+ storage.write(labStorageKey('test', 'theme'), 'interstellar');
626
+
627
+ makeStore({ storage });
628
+ vi.advanceTimersByTime(400);
629
+ vi.useRealTimers();
630
+
631
+ const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
632
+ expect(doc.mode).toBe('dark');
633
+ expect(storage.read(labStorageKey('test', 'workspaces'))).toBeNull();
634
+ expect(storage.read(labStorageKey('test', 'theme'))).toBeNull();
635
+ });
636
+
637
+ it('does not schedule a write for a document from the future', () => {
638
+ vi.useFakeTimers();
639
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
640
+ const storage = createMemoryAdapter();
641
+ const future = JSON.stringify({ version: 999 });
642
+ storage.write(labDocumentKey('test'), future);
643
+
644
+ makeStore({ storage });
645
+ vi.advanceTimersByTime(400);
646
+ vi.useRealTimers();
647
+
648
+ expect(storage.read(labDocumentKey('test'))).toBe(future);
649
+ warn.mockRestore();
650
+ });
651
+ });