@weasel-js/labkit 1.4.4 → 1.5.1

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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -1,17 +1,15 @@
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 { auto } from '../config/auto';
3
+ import type { Instrument } from '../instrument/types';
5
4
  import { createLabStore } from './store';
5
+ import type { CreateLabStoreOptions } from './types';
6
6
 
7
- function makeStore(overrides?: Partial<Parameters<typeof createLabStore>[0]>) {
8
- return createLabStore({
9
- storageKey: 'test',
10
- storage: createMemoryAdapter(),
11
- ...overrides,
12
- });
7
+ function makeStore(overrides?: CreateLabStoreOptions) {
8
+ return createLabStore({ ...overrides });
13
9
  }
14
10
 
11
+ const VIEW = { zoom: 1, pan: { x: 0, y: 0 } };
12
+
15
13
  describe('createLabStore — initial state', () => {
16
14
  it('starts with empty trials and saves', () => {
17
15
  const s = makeStore();
@@ -125,104 +123,12 @@ describe('updateTrialConfig', () => {
125
123
  });
126
124
  });
127
125
 
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
126
  describe('setMode', () => {
214
127
  it('updates mode', () => {
215
128
  const s = makeStore();
216
129
  s.getState().setMode('dark');
217
130
  expect(s.getState().mode).toBe('dark');
218
131
  });
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
132
  });
227
133
 
228
134
  describe('save/load/delete snapshots', () => {
@@ -258,133 +164,90 @@ describe('save/load/delete snapshots', () => {
258
164
  expect((s.getState().trials[0]?.state as { n: number }).n).toBe(5);
259
165
  });
260
166
 
261
- it('loadSnapshot blocks cross-instrument load', () => {
167
+ it('loadSnapshot restores the auto paths the trial had when saved', () => {
262
168
  const s = makeStore();
263
169
  s.getState().addTrial({
264
170
  id: 'w1',
265
- instrumentName: 'A',
266
- config: {},
267
- state: { n: 1 },
171
+ instrumentName: 'T',
172
+ config: { x: 1, y: 2 },
173
+ state: {},
268
174
  view: { zoom: 1, pan: { x: 0, y: 0 } },
175
+ auto: ['x'],
269
176
  });
270
- s.getState().saveSnapshot('w1', 'snap-a');
271
- s.getState().setTrialInstrument('w1', 'B');
177
+ s.getState().saveSnapshot('w1', 'snap1');
178
+ expect(s.getState().savedSnapshots[0]?.auto).toEqual(['x']);
272
179
  const snapId = s.getState().savedSnapshots[0]?.id ?? '';
273
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
274
- s.getState().loadSnapshot(snapId, 'w1');
275
- expect(warn).toHaveBeenCalled();
276
- warn.mockRestore();
180
+
181
+ const w = s.getState().trials[0];
182
+ s.getState().addTrial({ ...(w as NonNullable<typeof w>), id: 'w2', auto: ['y'] });
183
+ s.getState().loadSnapshot(snapId, 'w2');
184
+ expect(s.getState().trials[1]?.auto).toEqual(['x']);
277
185
  });
278
186
 
279
- it('deleteSnapshot removes the snapshot', () => {
187
+ it('a snapshot saved with nothing auto pins every field on load', () => {
280
188
  const s = makeStore();
281
189
  s.getState().addTrial({
282
190
  id: 'w1',
283
191
  instrumentName: 'T',
284
- config: {},
192
+ config: { x: 1 },
285
193
  state: {},
286
194
  view: { zoom: 1, pan: { x: 0, y: 0 } },
287
195
  });
288
- s.getState().saveSnapshot('w1', 'snap');
289
- const id = s.getState().savedSnapshots[0]?.id ?? '';
290
- s.getState().deleteSnapshot(id);
291
- expect(s.getState().savedSnapshots).toHaveLength(0);
196
+ s.getState().saveSnapshot('w1', 'snap1');
197
+ expect(s.getState().savedSnapshots[0]?.auto).toEqual([]);
198
+ const snapId = s.getState().savedSnapshots[0]?.id ?? '';
199
+ s.getState().updateTrialConfig('w1', 'x', auto);
200
+ expect(s.getState().trials[0]?.auto).toEqual(['x']);
201
+ s.getState().loadSnapshot(snapId, 'w1');
202
+ expect(s.getState().trials[0]?.auto).toBeUndefined();
292
203
  });
293
204
 
294
- it('listSnapshots returns newest first', () => {
295
- const s = makeStore();
296
- s.getState().addTrial({
297
- id: 'w1',
205
+ it("a snapshot saved before snapshots carried auto paths leaves the trial's own", () => {
206
+ const snap = {
207
+ id: 'old',
208
+ name: 'old',
209
+ trialId: 'w1',
298
210
  instrumentName: 'T',
299
- config: {},
211
+ config: { x: 7 },
300
212
  state: {},
301
- view: { zoom: 1, pan: { x: 0, y: 0 } },
213
+ savedAt: 1,
214
+ };
215
+ const s = makeStore({
216
+ initial: { version: 1, trials: [], saves: [snap], mode: 'auto' } as never,
302
217
  });
303
- s.getState().saveSnapshot('w1', 'first');
304
- s.getState().saveSnapshot('w1', 'second');
305
- const list = s.getState().listSnapshots('w1');
306
- expect(list[0]?.name).toBe('second');
307
- });
308
- });
309
-
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({
218
+ s.getState().addTrial({
316
219
  id: 'w1',
317
220
  instrumentName: 'T',
318
- config: {},
319
- state: { n: 7 },
221
+ config: { x: 1 },
222
+ state: {},
320
223
  view: { zoom: 1, pan: { x: 0, y: 0 } },
224
+ auto: ['x'],
321
225
  });
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);
226
+ s.getState().loadSnapshot('old', 'w1');
227
+ expect(s.getState().trials[0]?.config).toEqual({ x: 7 });
228
+ expect(s.getState().trials[0]?.auto).toEqual(['x']);
328
229
  });
329
230
 
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({
231
+ it('loadSnapshot blocks cross-instrument load', () => {
232
+ const s = makeStore();
233
+ s.getState().addTrial({
345
234
  id: 'w1',
346
- instrumentName: 'T',
235
+ instrumentName: 'A',
347
236
  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
237
  state: { n: 1 },
371
238
  view: { zoom: 1, pan: { x: 0, y: 0 } },
372
239
  });
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 });
240
+ s.getState().saveSnapshot('w1', 'snap-a');
241
+ s.getState().setTrialInstrument('w1', 'B');
242
+ const snapId = s.getState().savedSnapshots[0]?.id ?? '';
243
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
244
+ s.getState().loadSnapshot(snapId, 'w1');
245
+ expect(warn).toHaveBeenCalled();
246
+ warn.mockRestore();
378
247
  });
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
248
 
249
+ it('deleteSnapshot removes the snapshot', () => {
250
+ const s = makeStore();
388
251
  s.getState().addTrial({
389
252
  id: 'w1',
390
253
  instrumentName: 'T',
@@ -392,415 +255,25 @@ describe('persistence — debounced writes', () => {
392
255
  state: {},
393
256
  view: { zoom: 1, pan: { x: 0, y: 0 } },
394
257
  });
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();
258
+ s.getState().saveSnapshot('w1', 'snap');
259
+ const id = s.getState().savedSnapshots[0]?.id ?? '';
260
+ s.getState().deleteSnapshot(id);
261
+ expect(s.getState().savedSnapshots).toHaveLength(0);
571
262
  });
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
263
 
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',
264
+ it('listSnapshots returns newest first', () => {
265
+ const s = makeStore();
266
+ s.getState().addTrial({
267
+ id: 'w1',
606
268
  instrumentName: 'T',
607
269
  config: {},
608
- state: { n: 1 },
609
- view: VIEW,
270
+ state: {},
271
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
610
272
  });
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
273
  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();
274
+ s.getState().saveSnapshot('w1', 'second');
275
+ const list = s.getState().listSnapshots('w1');
276
+ expect(list[0]?.name).toBe('second');
804
277
  });
805
278
  });
806
279
 
@@ -846,18 +319,6 @@ describe('setTrialTitle', () => {
846
319
  s.getState().setTrialTitle('w1', 'Sprocket 7');
847
320
  expect(s.getState().trials[0]).toBe(before);
848
321
  });
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
322
  });
862
323
 
863
324
  describe('setTrialSectionCollapsed', () => {
@@ -877,31 +338,104 @@ describe('setTrialSectionCollapsed', () => {
877
338
  s.getState().setTrialSectionCollapsed('w1', 'settings', true);
878
339
  expect(s.getState().trials[0]).toBe(before);
879
340
  });
341
+ });
880
342
 
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',
343
+ describe('setInstruments', () => {
344
+ const small: Instrument = {
345
+ name: 'Grid',
346
+ defaultConfig: () => ({ size: 10 }),
347
+ initialState: () => ({}),
348
+ render: () => null,
349
+ };
350
+ const other: Instrument = { ...small, name: 'Other', defaultConfig: () => ({ n: 1 }) };
351
+
352
+ function trial(id: string, instrumentName: string, config: unknown) {
353
+ return { id, instrumentName, config, state: {}, view: null };
354
+ }
355
+
356
+ it('fills a replaced instrument’s open trials from its new defaults', () => {
357
+ const store = createLabStore({ instruments: [small, other] });
358
+ store.getState().addTrial(trial('a', 'Grid', { size: 4 }));
359
+ store.getState().addTrial(trial('b', 'Other', { n: 2 }));
360
+ const untouched = store.getState().trials[1];
361
+
362
+ const grown: Instrument = { ...small, defaultConfig: () => ({ size: 10, color: 'red' }) };
363
+ store.getState().setInstruments([grown, other]);
364
+
365
+ expect(store.getState().trials[0]?.config).toEqual({ size: 4, color: 'red' });
366
+ expect(store.getState().trials[1]).toBe(untouched);
367
+ expect(store.getState().instruments).toEqual([grown, other]);
368
+ });
369
+
370
+ it('serializes an instrument added after the store was built', () => {
371
+ const store = createLabStore({ instruments: [small] });
372
+ const mapped: Instrument = {
373
+ ...small,
374
+ name: 'Mapped',
375
+ serialize: (s) => ({ seen: [...(s as { seen: Set<string> }).seen] }),
376
+ };
377
+ store.getState().setInstruments([small, mapped]);
378
+ store.getState().addTrial({
379
+ id: 'm',
380
+ instrumentName: 'Mapped',
381
+ config: { size: 10 },
382
+ state: { seen: new Set(['a']) },
383
+ view: null,
902
384
  });
903
- const trial = makeStore({ storage }).getState().trials[0];
904
- expect(trial?.title).toBeUndefined();
905
- expect(trial?.collapsedSections).toBeUndefined();
385
+ store.getState().saveSnapshot('m', 'one');
386
+ expect(store.getState().savedSnapshots[0]?.state).toEqual({ seen: ['a'] });
387
+ });
388
+
389
+ it('is a no-op for the list it already holds', () => {
390
+ const list = [small];
391
+ const store = createLabStore({ instruments: list });
392
+ const before = store.getState();
393
+ store.getState().setInstruments(list);
394
+ expect(store.getState()).toBe(before);
395
+ });
396
+
397
+ const mapped: Instrument = {
398
+ ...small,
399
+ name: 'Mapped',
400
+ serialize: (s) => ({ seen: [...(s as { seen: Set<string> }).seen] }),
401
+ };
402
+ const mappedTrial = {
403
+ id: 'm',
404
+ instrumentName: 'Mapped',
405
+ config: { size: 10 },
406
+ state: { seen: new Set(['a']) },
407
+ view: null,
408
+ };
409
+
410
+ it('keeps the hooks of an instrument that leaves the list while its trials remain', () => {
411
+ const store = createLabStore({ instruments: [mapped] });
412
+ store.getState().addTrial(mappedTrial);
413
+ store.getState().setInstruments([]);
414
+ store.getState().saveSnapshot('m', 'one');
415
+ expect(store.getState().savedSnapshots[0]?.state).toEqual({ seen: ['a'] });
416
+ });
417
+
418
+ it('drops the serializer of an instrument replaced by one that declares none', () => {
419
+ const store = createLabStore({ instruments: [mapped] });
420
+ store.getState().addTrial(mappedTrial);
421
+ store.getState().setInstruments([{ ...small, name: 'Mapped' }]);
422
+ store.getState().saveSnapshot('m', 'one');
423
+ expect(store.getState().savedSnapshots[0]?.state).toEqual({ seen: new Set(['a']) });
424
+ });
425
+
426
+ it('changes neither hooks nor state when a new default config throws', () => {
427
+ const store = createLabStore({ instruments: [small, other] });
428
+ store.getState().addTrial(trial('a', 'Grid', { size: 4 }));
429
+ const before = store.getState();
430
+ const hooks = store.getState().instrumentHooks();
431
+ const broken: Instrument = {
432
+ ...small,
433
+ defaultConfig: () => {
434
+ throw new Error('boom');
435
+ },
436
+ };
437
+ expect(() => store.getState().setInstruments([broken, mapped])).toThrow('boom');
438
+ expect(store.getState()).toBe(before);
439
+ expect(store.getState().instrumentHooks()).toBe(hooks);
906
440
  });
907
441
  });