@weasel-js/labkit 1.0.4 → 1.2.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.
- package/README.md +33 -6
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
- package/dist/canvas/index.d.ts +2 -1
- package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
- package/dist/chunk-73KA7WBO.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
- package/dist/chunk-BOHF3PQO.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/chunk-THBG7FQZ.js +167 -0
- package/dist/chunk-THBG7FQZ.js.map +1 -0
- package/dist/chunk-VUU5UXHE.js +491 -0
- package/dist/chunk-VUU5UXHE.js.map +1 -0
- package/dist/dragdrop/index.d.ts +2 -1
- package/dist/index.d.ts +211 -151
- package/dist/index.js +317 -169
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +3 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +29 -6
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +12 -12
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +31 -13
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +3 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/canvas/AGENTS.md +14 -2
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +70 -0
- package/src/index.ts +24 -7
- package/src/instrument/SineWave.smoke.test.tsx +2 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +30 -6
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +6 -7
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +50 -50
- package/src/lab/LabContext.ts +12 -12
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
- package/src/lab/index.ts +2 -2
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +21 -29
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -41
- package/src/state/store.ts +165 -102
- package/src/state/types.ts +32 -13
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +2 -2
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +157 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/trial/DefaultStatusBar.tsx +19 -0
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/trial/Trial.job.test.tsx +75 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
- package/src/{workspace → trial}/index.ts +13 -13
- package/src/{workspace → trial}/slotTypes.ts +18 -17
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
- package/dist/chunk-BAPZPDDA.js.map +0 -1
- package/dist/chunk-DWV7SFKR.js.map +0 -1
- package/dist/chunk-KSTEW2AF.js +0 -367
- package/dist/chunk-KSTEW2AF.js.map +0 -1
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/DefaultStatusBar.tsx +0 -18
- package/src/workspace/workspaceOps.ts +0 -102
package/src/state/store.test.ts
CHANGED
|
@@ -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
|
|
16
|
+
it('starts with empty trials and saves', () => {
|
|
16
17
|
const s = makeStore();
|
|
17
|
-
expect(s.getState().
|
|
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('
|
|
33
|
-
it('adds a
|
|
33
|
+
describe('addTrial', () => {
|
|
34
|
+
it('adds a trial with an empty undoStack', () => {
|
|
34
35
|
const s = makeStore();
|
|
35
|
-
s.getState().
|
|
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().
|
|
43
|
-
expect(s.getState().
|
|
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('
|
|
48
|
+
describe('removeTrial', () => {
|
|
48
49
|
it('removes by id', () => {
|
|
49
50
|
const s = makeStore();
|
|
50
|
-
s.getState().
|
|
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().
|
|
58
|
-
expect(s.getState().
|
|
58
|
+
s.getState().removeTrial('w1');
|
|
59
|
+
expect(s.getState().trials).toHaveLength(0);
|
|
59
60
|
});
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
describe('
|
|
63
|
+
describe('updateTrialState', () => {
|
|
63
64
|
it('updates state with a plain value', () => {
|
|
64
65
|
const s = makeStore();
|
|
65
|
-
s.getState().
|
|
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().
|
|
73
|
-
expect(s.getState().
|
|
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().
|
|
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().
|
|
86
|
+
s.getState().updateTrialState('w1', (prev: unknown) => ({
|
|
86
87
|
n: (prev as { n: number }).n + 1,
|
|
87
88
|
}));
|
|
88
|
-
expect((s.getState().
|
|
89
|
+
expect((s.getState().trials[0]?.state as { n: number }).n).toBe(2);
|
|
89
90
|
});
|
|
90
91
|
});
|
|
91
92
|
|
|
92
|
-
describe('
|
|
93
|
+
describe('updateTrialConfig', () => {
|
|
93
94
|
it('updates a single config key', () => {
|
|
94
95
|
const s = makeStore();
|
|
95
|
-
s.getState().
|
|
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().
|
|
103
|
-
expect((s.getState().
|
|
104
|
-
expect((s.getState().
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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
|
|
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().
|
|
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().
|
|
222
|
-
expect((hydrated.getState().
|
|
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().
|
|
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().
|
|
241
|
-
s.getState().
|
|
242
|
-
s.getState().
|
|
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
|
|
249
|
-
expect(writesAfter - writesBefore).toBe(
|
|
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
|
+
});
|