@weasel-js/labkit 1.4.4 → 1.5.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 (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +110 -381
  53. package/dist/index.js +691 -817
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +164 -122
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +84 -50
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
@@ -1,17 +1,14 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { createMemoryAdapter } from './adapters';
3
- import { CURRENT_DOCUMENT_VERSION, labDocumentKey, quarantineKey } from './document';
4
- import { labStorageKey } from './helpers';
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Instrument } from '../instrument/types';
5
3
  import { createLabStore } from './store';
4
+ import type { CreateLabStoreOptions } from './types';
6
5
 
7
- function makeStore(overrides?: Partial<Parameters<typeof createLabStore>[0]>) {
8
- return createLabStore({
9
- storageKey: 'test',
10
- storage: createMemoryAdapter(),
11
- ...overrides,
12
- });
6
+ function makeStore(overrides?: CreateLabStoreOptions) {
7
+ return createLabStore({ ...overrides });
13
8
  }
14
9
 
10
+ const VIEW = { zoom: 1, pan: { x: 0, y: 0 } };
11
+
15
12
  describe('createLabStore — initial state', () => {
16
13
  it('starts with empty trials and saves', () => {
17
14
  const s = makeStore();
@@ -125,104 +122,12 @@ describe('updateTrialConfig', () => {
125
122
  });
126
123
  });
127
124
 
128
- describe('createLabStore - config defaults', () => {
129
- const stored = (config: unknown) =>
130
- JSON.stringify({
131
- version: CURRENT_DOCUMENT_VERSION,
132
- trials: [
133
- {
134
- id: 'w1',
135
- instrumentName: 'T',
136
- config,
137
- state: {},
138
- view: { zoom: 1, pan: { x: 0, y: 0 } },
139
- },
140
- ],
141
- saves: [
142
- {
143
- id: 's1',
144
- name: 'saved',
145
- trialId: 'w1',
146
- instrumentName: 'T',
147
- config,
148
- state: {},
149
- savedAt: 1,
150
- },
151
- ],
152
- layout: {},
153
- undockedPanels: {},
154
- mode: 'auto',
155
- });
156
-
157
- it('fills a branch a config stored before the schema nested it never had', () => {
158
- const storage = createMemoryAdapter();
159
- storage.write(labDocumentKey('test'), stored({ showGrid: false, gridSize: 40 }));
160
- const s = createLabStore({
161
- storageKey: 'test',
162
- storage,
163
- configDefaults: { T: () => ({ showGrid: true, grid: { size: 20, color: '#fff' } }) },
164
- });
165
- expect(s.getState().trials[0]?.config).toEqual({
166
- showGrid: false,
167
- gridSize: 40,
168
- grid: { size: 20, color: '#fff' },
169
- });
170
- });
171
-
172
- it('fills the config on a saved snapshot the same way', () => {
173
- const storage = createMemoryAdapter();
174
- storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
175
- const s = createLabStore({
176
- storageKey: 'test',
177
- storage,
178
- configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
179
- });
180
- expect(s.getState().savedSnapshots[0]?.config).toEqual({
181
- grid: { size: 40, color: '#fff' },
182
- });
183
- });
184
-
185
- it('hands a deserializer the filled config, not the stored one', () => {
186
- const storage = createMemoryAdapter();
187
- storage.write(labDocumentKey('test'), stored({ grid: { size: 40 } }));
188
- const seen: unknown[] = [];
189
- createLabStore({
190
- storageKey: 'test',
191
- storage,
192
- configDefaults: { T: () => ({ grid: { size: 20, color: '#fff' } }) },
193
- serializers: {
194
- T: {
195
- deserialize: (state, config) => {
196
- seen.push(config);
197
- return state;
198
- },
199
- },
200
- },
201
- });
202
- expect(seen).toEqual([{ grid: { size: 40, color: '#fff' } }]);
203
- });
204
-
205
- it('leaves a config alone when no defaults are registered for its instrument', () => {
206
- const storage = createMemoryAdapter();
207
- storage.write(labDocumentKey('test'), stored({ only: 1 }));
208
- const s = createLabStore({ storageKey: 'test', storage });
209
- expect(s.getState().trials[0]?.config).toEqual({ only: 1 });
210
- });
211
- });
212
-
213
125
  describe('setMode', () => {
214
126
  it('updates mode', () => {
215
127
  const s = makeStore();
216
128
  s.getState().setMode('dark');
217
129
  expect(s.getState().mode).toBe('dark');
218
130
  });
219
-
220
- it('hydrates a stored interstellar preference as dark', () => {
221
- const storage = createMemoryAdapter();
222
- storage.write(labStorageKey('test', 'theme'), 'interstellar');
223
- const s = createLabStore({ storageKey: 'test', storage });
224
- expect(s.getState().mode).toBe('dark');
225
- });
226
131
  });
227
132
 
228
133
  describe('save/load/delete snapshots', () => {
@@ -307,503 +212,6 @@ describe('save/load/delete snapshots', () => {
307
212
  });
308
213
  });
309
214
 
310
- describe('persistence — hydration', () => {
311
- it('hydrates trials from storage on construction', () => {
312
- vi.useFakeTimers();
313
- const mem = createMemoryAdapter();
314
- const seedStore = createLabStore({ storageKey: 'test', storage: mem });
315
- seedStore.getState().addTrial({
316
- id: 'w1',
317
- instrumentName: 'T',
318
- config: {},
319
- state: { n: 7 },
320
- view: { zoom: 1, pan: { x: 0, y: 0 } },
321
- });
322
- vi.advanceTimersByTime(500);
323
- vi.useRealTimers();
324
-
325
- const hydrated = createLabStore({ storageKey: 'test', storage: mem });
326
- expect(hydrated.getState().trials).toHaveLength(1);
327
- expect((hydrated.getState().trials[0]?.state as { n: number }).n).toBe(7);
328
- });
329
-
330
- // An instrument holding a Map, a Set or anything else JSON drops needs these
331
- // to run at both ends. They are read while the store is being built, which
332
- // is why they arrive as an option rather than being registered afterwards.
333
- it("runs an instrument's serializers at flush and at hydrate", () => {
334
- vi.useFakeTimers();
335
- const mem = createMemoryAdapter();
336
- const serializers = {
337
- T: {
338
- serialize: (state: unknown) => [...(state as Map<string, number>)],
339
- deserialize: (data: unknown) => new Map(data as [string, number][]),
340
- },
341
- };
342
-
343
- const seed = createLabStore({ storageKey: 'test', storage: mem, serializers });
344
- seed.getState().addTrial({
345
- id: 'w1',
346
- instrumentName: 'T',
347
- config: {},
348
- state: new Map([['a', 1]]),
349
- view: { zoom: 1, pan: { x: 0, y: 0 } },
350
- });
351
- vi.advanceTimersByTime(500);
352
- vi.useRealTimers();
353
-
354
- const written = mem.read(labDocumentKey('test')) ?? '';
355
- expect(written).toContain('[["a",1]]');
356
-
357
- const hydrated = createLabStore({ storageKey: 'test', storage: mem, serializers });
358
- expect(hydrated.getState().trials[0]?.state).toEqual(new Map([['a', 1]]));
359
- });
360
-
361
- it('hands `deserialize` the config the state was saved against', () => {
362
- vi.useFakeTimers();
363
- const mem = createMemoryAdapter();
364
- const deserialize = vi.fn((data: unknown) => data);
365
- const seed = createLabStore({ storageKey: 'test', storage: mem });
366
- seed.getState().addTrial({
367
- id: 'w1',
368
- instrumentName: 'T',
369
- config: { scale: 4 },
370
- state: { n: 1 },
371
- view: { zoom: 1, pan: { x: 0, y: 0 } },
372
- });
373
- vi.advanceTimersByTime(500);
374
- vi.useRealTimers();
375
-
376
- createLabStore({ storageKey: 'test', storage: mem, serializers: { T: { deserialize } } });
377
- expect(deserialize).toHaveBeenCalledWith({ n: 1 }, { scale: 4 });
378
- });
379
- });
380
-
381
- describe('persistence — debounced writes', () => {
382
- it('multiple rapid mutations produce one write call', () => {
383
- vi.useFakeTimers();
384
- const mem = createMemoryAdapter();
385
- const writeSpy = vi.spyOn(mem, 'write');
386
- const s = createLabStore({ storageKey: 'test', storage: mem });
387
-
388
- s.getState().addTrial({
389
- id: 'w1',
390
- instrumentName: 'T',
391
- config: {},
392
- state: {},
393
- view: { zoom: 1, pan: { x: 0, y: 0 } },
394
- });
395
- s.getState().updateTrialState('w1', { n: 1 });
396
- s.getState().updateTrialState('w1', { n: 2 });
397
- s.getState().updateTrialState('w1', { n: 3 });
398
-
399
- const writesBefore = writeSpy.mock.calls.length;
400
- vi.advanceTimersByTime(400);
401
- const writesAfter = writeSpy.mock.calls.length;
402
-
403
- // One flush, one document.
404
- expect(writesAfter - writesBefore).toBe(1);
405
- vi.useRealTimers();
406
- });
407
- });
408
-
409
- describe('createLabStore — hydrating', () => {
410
- it('reads a current-version document', () => {
411
- const storage = createMemoryAdapter();
412
- storage.write(
413
- labDocumentKey('test'),
414
- JSON.stringify({
415
- version: CURRENT_DOCUMENT_VERSION,
416
- trials: [
417
- {
418
- id: 'w1',
419
- instrumentName: 'Test',
420
- config: {},
421
- state: {},
422
- view: { zoom: 2, pan: { x: 0, y: 0 } },
423
- },
424
- ],
425
- saves: [],
426
- layout: { w1: { h: 3 } },
427
- mode: 'dark',
428
- }),
429
- );
430
-
431
- const s = makeStore({ storage }).getState();
432
- expect(s.trials).toHaveLength(1);
433
- expect(s.trials[0].undoStack).toEqual({ past: [], future: [] });
434
- expect(s.layout).toEqual({ w1: { h: 3 } });
435
- expect(s.mode).toBe('dark');
436
- });
437
-
438
- it('opens a version-1 document, whose tiles were still called workspaces', () => {
439
- const storage = createMemoryAdapter();
440
- storage.write(
441
- labDocumentKey('test'),
442
- JSON.stringify({
443
- version: 1,
444
- workspaces: [
445
- {
446
- id: 'w1',
447
- instrumentName: 'Test',
448
- config: { gain: 3 },
449
- state: { n: 7 },
450
- view: { zoom: 2, pan: { x: 0, y: 0 } },
451
- },
452
- ],
453
- saves: [{ id: 's1', name: 'a save', trialId: 'w1' }],
454
- layout: { w1: { h: 3 } },
455
- mode: 'dark',
456
- }),
457
- );
458
-
459
- const s = makeStore({ storage }).getState();
460
- expect(s.trials).toHaveLength(1);
461
- expect(s.trials[0]).toMatchObject({ id: 'w1', config: { gain: 3 }, state: { n: 7 } });
462
- expect(s.savedSnapshots).toHaveLength(1);
463
- expect(s.layout).toEqual({ w1: { h: 3 } });
464
- expect(s.mode).toBe('dark');
465
- });
466
-
467
- it('folds the four legacy keys into state', () => {
468
- const storage = createMemoryAdapter();
469
- storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
470
- storage.write(labStorageKey('test', 'theme'), 'interstellar');
471
-
472
- const s = makeStore({ storage }).getState();
473
- expect(s.mode).toBe('dark');
474
- });
475
-
476
- it('honors initialMode when a folded legacy lab never set a theme', () => {
477
- const storage = createMemoryAdapter();
478
- storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
479
-
480
- const s = makeStore({ storage, initialMode: 'dark' }).getState();
481
- expect(s.mode).toBe('dark');
482
- });
483
-
484
- it('starts empty and preserves a document from a future version', () => {
485
- const storage = createMemoryAdapter();
486
- const future = JSON.stringify({ version: 999, trials: [{ id: 'w1' }] });
487
- storage.write(labDocumentKey('test'), future);
488
-
489
- const store = makeStore({ storage });
490
- expect(store.getState().trials).toEqual([]);
491
- expect(storage.read(labDocumentKey('test'))).toBe(future);
492
- });
493
-
494
- it('quarantines a document that fails to parse', () => {
495
- const storage = createMemoryAdapter();
496
- storage.write(labDocumentKey('test'), '{{{not json');
497
-
498
- const s = makeStore({ storage }).getState();
499
- expect(s.trials).toEqual([]);
500
- expect(storage.read(quarantineKey('test'))).toBe('{{{not json');
501
- });
502
- });
503
-
504
- describe('createLabStore — flushing', () => {
505
- it('writes one document and no legacy keys', () => {
506
- vi.useFakeTimers();
507
- const storage = createMemoryAdapter();
508
- const s = makeStore({ storage });
509
- s.getState().setMode('light');
510
- vi.advanceTimersByTime(400);
511
- vi.useRealTimers();
512
-
513
- const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
514
- expect(doc.version).toBe(CURRENT_DOCUMENT_VERSION);
515
- expect(doc.mode).toBe('light');
516
- });
517
-
518
- it('deletes the legacy keys once the folded document is written', () => {
519
- vi.useFakeTimers();
520
- const storage = createMemoryAdapter();
521
- storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
522
- storage.write(labStorageKey('test', 'theme'), 'interstellar');
523
-
524
- const s = makeStore({ storage });
525
- s.getState().setMode('dark');
526
- vi.advanceTimersByTime(400);
527
- vi.useRealTimers();
528
-
529
- expect(storage.read(labDocumentKey('test'))).not.toBeNull();
530
- expect(storage.read(labStorageKey('test', 'workspaces'))).toBeNull();
531
- expect(storage.read(labStorageKey('test', 'theme'))).toBeNull();
532
- });
533
-
534
- it('does not write when the stored document is from the future', () => {
535
- vi.useFakeTimers();
536
- const storage = createMemoryAdapter();
537
- const future = JSON.stringify({ version: 999 });
538
- storage.write(labDocumentKey('test'), future);
539
-
540
- const s = makeStore({ storage });
541
- s.getState().setMode('light');
542
- vi.advanceTimersByTime(400);
543
- vi.useRealTimers();
544
-
545
- expect(storage.read(labDocumentKey('test'))).toBe(future);
546
- });
547
-
548
- it('keeps the legacy keys when the document write silently fails', () => {
549
- vi.useFakeTimers();
550
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
551
- const storage = createMemoryAdapter();
552
- const write = storage.write.bind(storage);
553
- // A quota failure as localStorageAdapter reports one: swallowed, void.
554
- storage.write = (key, value) => {
555
- if (key === labDocumentKey('test')) return;
556
- write(key, value);
557
- };
558
- storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
559
- storage.write(labStorageKey('test', 'theme'), 'interstellar');
560
-
561
- const s = makeStore({ storage });
562
- s.getState().setMode('dark');
563
- vi.advanceTimersByTime(400);
564
- vi.useRealTimers();
565
-
566
- expect(storage.read(labDocumentKey('test'))).toBeNull();
567
- expect(storage.read(labStorageKey('test', 'workspaces'))).toBe('[]');
568
- expect(storage.read(labStorageKey('test', 'theme'))).toBe('interstellar');
569
- expect(warn).toHaveBeenCalled();
570
- warn.mockRestore();
571
- });
572
- });
573
-
574
- const VIEW = { zoom: 1, pan: { x: 0, y: 0 } };
575
-
576
- const SNAPSHOT = {
577
- id: 's1',
578
- name: 'saved',
579
- trialId: 'w1',
580
- instrumentName: 'T',
581
- config: { x: 1 },
582
- state: { n: 5 },
583
- savedAt: 1000,
584
- };
585
-
586
- function writeDocument(
587
- storage: ReturnType<typeof createMemoryAdapter>,
588
- doc: Record<string, unknown>,
589
- storageKey = 'test',
590
- ): void {
591
- storage.write(
592
- labDocumentKey(storageKey),
593
- JSON.stringify({ version: CURRENT_DOCUMENT_VERSION, ...doc }),
594
- );
595
- }
596
-
597
- describe('createLabStore — labs whose keys once aliased', () => {
598
- it("keeps a document out of a sibling lab's legacy bucket", () => {
599
- vi.useFakeTimers();
600
- const storage = createMemoryAdapter();
601
-
602
- // Lab "a:saves" writes where lab "a"'s legacy saves bucket used to live.
603
- const sibling = createLabStore({ storageKey: 'a:saves', storage });
604
- sibling.getState().addTrial({
605
- id: 'sw',
606
- instrumentName: 'T',
607
- config: {},
608
- state: { n: 1 },
609
- view: VIEW,
610
- });
611
- vi.advanceTimersByTime(400);
612
- const siblingDocument = storage.read(labDocumentKey('a:saves'));
613
- expect(siblingDocument).not.toBeNull();
614
-
615
- storage.write(labStorageKey('a', 'workspaces'), JSON.stringify([]));
616
- storage.write(labStorageKey('a', 'theme'), 'dark');
617
-
618
- const lab = createLabStore({ storageKey: 'a', storage });
619
- // Lab "a" does not read the sibling's document as its own saves bucket.
620
- expect(lab.getState().savedSnapshots).toEqual([]);
621
- expect(lab.getState().trials).toEqual([]);
622
-
623
- lab.getState().setMode('light');
624
- vi.advanceTimersByTime(400);
625
- vi.useRealTimers();
626
-
627
- // Folding lab "a" deletes its own legacy keys, not the sibling's document.
628
- expect(storage.read(labStorageKey('a', 'workspaces'))).toBeNull();
629
- expect(storage.read(labDocumentKey('a:saves'))).toBe(siblingDocument);
630
- const rehydrated = createLabStore({ storageKey: 'a:saves', storage });
631
- expect(rehydrated.getState().trials).toHaveLength(1);
632
- });
633
- });
634
-
635
- describe('createLabStore — hydrating an incomplete current-version document', () => {
636
- it('hydrates a document with no saves as an empty snapshot list', () => {
637
- const storage = createMemoryAdapter();
638
- writeDocument(storage, { trials: [], layout: {}, mode: 'dark' });
639
-
640
- const s = makeStore({ storage }).getState();
641
- expect(s.savedSnapshots).toEqual([]);
642
- expect(s.listSnapshots()).toEqual([]);
643
- });
644
-
645
- it('hydrates a document with no layout as an empty layout', () => {
646
- const storage = createMemoryAdapter();
647
- writeDocument(storage, { trials: [], saves: [], mode: 'dark' });
648
-
649
- expect(makeStore({ storage }).getState().layout).toEqual({});
650
- });
651
-
652
- it('falls back to initialMode when the document has no mode', () => {
653
- const storage = createMemoryAdapter();
654
- writeDocument(storage, { trials: [], saves: [], layout: {} });
655
-
656
- expect(makeStore({ storage, initialMode: 'light' }).getState().mode).toBe('light');
657
- });
658
-
659
- it('hydrates a document holding nothing but its version', () => {
660
- const storage = createMemoryAdapter();
661
- writeDocument(storage, {});
662
-
663
- const s = makeStore({ storage }).getState();
664
- expect(s.trials).toEqual([]);
665
- expect(s.savedSnapshots).toEqual([]);
666
- expect(s.layout).toEqual({});
667
- expect(s.mode).toBe('auto');
668
- expect(s.listSnapshots()).toEqual([]);
669
- });
670
-
671
- it('can save a snapshot into a document that had no saves', () => {
672
- const storage = createMemoryAdapter();
673
- writeDocument(storage, {});
674
-
675
- const s = makeStore({ storage });
676
- s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
677
- s.getState().saveSnapshot('w1', 'first');
678
- expect(s.getState().listSnapshots('w1')).toHaveLength(1);
679
- });
680
- });
681
-
682
- describe('persistence — saves and layout round-trip', () => {
683
- it('hydrates a populated saves array into the store', () => {
684
- const storage = createMemoryAdapter();
685
- writeDocument(storage, { trials: [], saves: [SNAPSHOT], layout: {}, mode: 'auto' });
686
-
687
- const s = makeStore({ storage }).getState();
688
- expect(s.savedSnapshots).toEqual([SNAPSHOT]);
689
- expect(s.listSnapshots('w1')).toEqual([SNAPSHOT]);
690
- });
691
-
692
- it('writes the hydrated saves back out on the next flush', () => {
693
- vi.useFakeTimers();
694
- const storage = createMemoryAdapter();
695
- writeDocument(storage, { trials: [], saves: [SNAPSHOT], layout: {}, mode: 'auto' });
696
-
697
- const s = makeStore({ storage });
698
- s.getState().setMode('light');
699
- vi.advanceTimersByTime(400);
700
- vi.useRealTimers();
701
-
702
- const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
703
- expect(doc.saves).toEqual([SNAPSHOT]);
704
- });
705
-
706
- it('writes a snapshot saved this session out on flush', () => {
707
- vi.useFakeTimers();
708
- const storage = createMemoryAdapter();
709
- const s = makeStore({ storage });
710
- s.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
711
- s.getState().saveSnapshot('w1', 'first');
712
- vi.advanceTimersByTime(400);
713
- vi.useRealTimers();
714
-
715
- const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
716
- expect(doc.saves).toHaveLength(1);
717
- expect(doc.saves[0].name).toBe('first');
718
- });
719
-
720
- it('writes the layout out on flush', () => {
721
- vi.useFakeTimers();
722
- const storage = createMemoryAdapter();
723
- const s = makeStore({ storage });
724
- s.getState().setLayout({ w1: { h: 9 } });
725
- vi.advanceTimersByTime(400);
726
- vi.useRealTimers();
727
-
728
- const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
729
- expect(doc.layout).toEqual({ w1: { h: 9 } });
730
- });
731
- });
732
-
733
- describe('createLabStore — quarantining an unusable document', () => {
734
- it('persists again once the unusable document is safely quarantined', () => {
735
- vi.useFakeTimers();
736
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
737
- const storage = createMemoryAdapter();
738
- storage.write(labDocumentKey('test'), '{{{not json');
739
-
740
- const s = makeStore({ storage });
741
- s.getState().setMode('light');
742
- vi.advanceTimersByTime(400);
743
- vi.useRealTimers();
744
-
745
- expect(storage.read(quarantineKey('test'))).toBe('{{{not json');
746
- const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
747
- expect(doc.mode).toBe('light');
748
- warn.mockRestore();
749
- });
750
-
751
- it('leaves the original alone when the quarantine copy cannot be written', () => {
752
- vi.useFakeTimers();
753
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
754
- const storage = createMemoryAdapter();
755
- const write = storage.write.bind(storage);
756
- // A quota failure as localStorageAdapter reports one: swallowed, void.
757
- storage.write = (key, value) => {
758
- if (key === quarantineKey('test')) return;
759
- write(key, value);
760
- };
761
- storage.write(labDocumentKey('test'), '{{{not json');
762
-
763
- const s = makeStore({ storage });
764
- s.getState().setMode('light');
765
- vi.advanceTimersByTime(400);
766
- vi.useRealTimers();
767
-
768
- expect(storage.read(labDocumentKey('test'))).toBe('{{{not json');
769
- expect(warn).toHaveBeenCalled();
770
- warn.mockRestore();
771
- });
772
- });
773
-
774
- describe('createLabStore — folding without a mutation', () => {
775
- it('completes the fold on first load alone', () => {
776
- vi.useFakeTimers();
777
- const storage = createMemoryAdapter();
778
- storage.write(labStorageKey('test', 'workspaces'), JSON.stringify([]));
779
- storage.write(labStorageKey('test', 'theme'), 'interstellar');
780
-
781
- makeStore({ storage });
782
- vi.advanceTimersByTime(400);
783
- vi.useRealTimers();
784
-
785
- const doc = JSON.parse(storage.read(labDocumentKey('test')) as string);
786
- expect(doc.mode).toBe('dark');
787
- expect(storage.read(labStorageKey('test', 'workspaces'))).toBeNull();
788
- expect(storage.read(labStorageKey('test', 'theme'))).toBeNull();
789
- });
790
-
791
- it('does not schedule a write for a document from the future', () => {
792
- vi.useFakeTimers();
793
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
794
- const storage = createMemoryAdapter();
795
- const future = JSON.stringify({ version: 999 });
796
- storage.write(labDocumentKey('test'), future);
797
-
798
- makeStore({ storage });
799
- vi.advanceTimersByTime(400);
800
- vi.useRealTimers();
801
-
802
- expect(storage.read(labDocumentKey('test'))).toBe(future);
803
- warn.mockRestore();
804
- });
805
- });
806
-
807
215
  describe('undocking a sidebar panel', () => {
808
216
  it('starts with nothing undocked', () => {
809
217
  expect(makeStore().getState().undockedPanels).toEqual({});
@@ -846,18 +254,6 @@ describe('setTrialTitle', () => {
846
254
  s.getState().setTrialTitle('w1', 'Sprocket 7');
847
255
  expect(s.getState().trials[0]).toBe(before);
848
256
  });
849
-
850
- it('round-trips through storage', () => {
851
- vi.useFakeTimers();
852
- const storage = createMemoryAdapter();
853
- const first = makeStore({ storage });
854
- first.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
855
- first.getState().setTrialTitle('w1', 'Sprocket 7');
856
- vi.advanceTimersByTime(400);
857
- vi.useRealTimers();
858
-
859
- expect(makeStore({ storage }).getState().trials[0]?.title).toBe('Sprocket 7');
860
- });
861
257
  });
862
258
 
863
259
  describe('setTrialSectionCollapsed', () => {
@@ -877,31 +273,104 @@ describe('setTrialSectionCollapsed', () => {
877
273
  s.getState().setTrialSectionCollapsed('w1', 'settings', true);
878
274
  expect(s.getState().trials[0]).toBe(before);
879
275
  });
276
+ });
880
277
 
881
- it('round-trips through storage', () => {
882
- vi.useFakeTimers();
883
- const storage = createMemoryAdapter();
884
- const first = makeStore({ storage });
885
- first.getState().addTrial({ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW });
886
- first.getState().setTrialSectionCollapsed('w1', 'settings/Shape', true);
887
- vi.advanceTimersByTime(400);
888
- vi.useRealTimers();
889
-
890
- expect(makeStore({ storage }).getState().trials[0]?.collapsedSections).toEqual({
891
- 'settings/Shape': true,
892
- });
893
- });
894
-
895
- it('hydrates a version-3 trial, which carries neither field', () => {
896
- const storage = createMemoryAdapter();
897
- writeDocument(storage, {
898
- trials: [{ id: 'w1', instrumentName: 'T', config: {}, state: {}, view: VIEW }],
899
- saves: [],
900
- layout: {},
901
- mode: 'auto',
278
+ describe('setInstruments', () => {
279
+ const small: Instrument = {
280
+ name: 'Grid',
281
+ defaultConfig: () => ({ size: 10 }),
282
+ initialState: () => ({}),
283
+ render: () => null,
284
+ };
285
+ const other: Instrument = { ...small, name: 'Other', defaultConfig: () => ({ n: 1 }) };
286
+
287
+ function trial(id: string, instrumentName: string, config: unknown) {
288
+ return { id, instrumentName, config, state: {}, view: null };
289
+ }
290
+
291
+ it('fills a replaced instrument’s open trials from its new defaults', () => {
292
+ const store = createLabStore({ instruments: [small, other] });
293
+ store.getState().addTrial(trial('a', 'Grid', { size: 4 }));
294
+ store.getState().addTrial(trial('b', 'Other', { n: 2 }));
295
+ const untouched = store.getState().trials[1];
296
+
297
+ const grown: Instrument = { ...small, defaultConfig: () => ({ size: 10, color: 'red' }) };
298
+ store.getState().setInstruments([grown, other]);
299
+
300
+ expect(store.getState().trials[0]?.config).toEqual({ size: 4, color: 'red' });
301
+ expect(store.getState().trials[1]).toBe(untouched);
302
+ expect(store.getState().instruments).toEqual([grown, other]);
303
+ });
304
+
305
+ it('serializes an instrument added after the store was built', () => {
306
+ const store = createLabStore({ instruments: [small] });
307
+ const mapped: Instrument = {
308
+ ...small,
309
+ name: 'Mapped',
310
+ serialize: (s) => ({ seen: [...(s as { seen: Set<string> }).seen] }),
311
+ };
312
+ store.getState().setInstruments([small, mapped]);
313
+ store.getState().addTrial({
314
+ id: 'm',
315
+ instrumentName: 'Mapped',
316
+ config: { size: 10 },
317
+ state: { seen: new Set(['a']) },
318
+ view: null,
902
319
  });
903
- const trial = makeStore({ storage }).getState().trials[0];
904
- expect(trial?.title).toBeUndefined();
905
- expect(trial?.collapsedSections).toBeUndefined();
320
+ store.getState().saveSnapshot('m', 'one');
321
+ expect(store.getState().savedSnapshots[0]?.state).toEqual({ seen: ['a'] });
322
+ });
323
+
324
+ it('is a no-op for the list it already holds', () => {
325
+ const list = [small];
326
+ const store = createLabStore({ instruments: list });
327
+ const before = store.getState();
328
+ store.getState().setInstruments(list);
329
+ expect(store.getState()).toBe(before);
330
+ });
331
+
332
+ const mapped: Instrument = {
333
+ ...small,
334
+ name: 'Mapped',
335
+ serialize: (s) => ({ seen: [...(s as { seen: Set<string> }).seen] }),
336
+ };
337
+ const mappedTrial = {
338
+ id: 'm',
339
+ instrumentName: 'Mapped',
340
+ config: { size: 10 },
341
+ state: { seen: new Set(['a']) },
342
+ view: null,
343
+ };
344
+
345
+ it('keeps the hooks of an instrument that leaves the list while its trials remain', () => {
346
+ const store = createLabStore({ instruments: [mapped] });
347
+ store.getState().addTrial(mappedTrial);
348
+ store.getState().setInstruments([]);
349
+ store.getState().saveSnapshot('m', 'one');
350
+ expect(store.getState().savedSnapshots[0]?.state).toEqual({ seen: ['a'] });
351
+ });
352
+
353
+ it('drops the serializer of an instrument replaced by one that declares none', () => {
354
+ const store = createLabStore({ instruments: [mapped] });
355
+ store.getState().addTrial(mappedTrial);
356
+ store.getState().setInstruments([{ ...small, name: 'Mapped' }]);
357
+ store.getState().saveSnapshot('m', 'one');
358
+ expect(store.getState().savedSnapshots[0]?.state).toEqual({ seen: new Set(['a']) });
359
+ });
360
+
361
+ it('changes neither hooks nor state when a new default config throws', () => {
362
+ const store = createLabStore({ instruments: [small, other] });
363
+ store.getState().addTrial(trial('a', 'Grid', { size: 4 }));
364
+ const before = store.getState();
365
+ const hooks = store.getState().instrumentHooks();
366
+ const broken: Instrument = {
367
+ ...small,
368
+ defaultConfig: () => {
369
+ throw new Error('boom');
370
+ },
371
+ };
372
+ expect(() => store.getState().setInstruments([broken, mapped])).toThrow('boom');
373
+ expect(store.getState()).toBe(before);
374
+ expect(store.getState().instrumentHooks()).toBe(hooks);
906
375
  });
907
376
  });