@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.
- package/README.md +13 -2
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
- package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
- package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
- package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
- package/dist/canvas/index.d.ts +17 -128
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +4 -92
- package/dist/chrome/index.js +9 -5
- package/dist/chunk-67SJLMC7.js +704 -0
- package/dist/chunk-67SJLMC7.js.map +1 -0
- package/dist/chunk-6ZDGOZQV.js +310 -0
- package/dist/chunk-6ZDGOZQV.js.map +1 -0
- package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
- package/dist/chunk-EKIICY6X.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
- package/dist/chunk-KJALCDWE.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
- package/dist/chunk-NDRYLVOW.js +15 -0
- package/dist/chunk-NDRYLVOW.js.map +1 -0
- package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
- package/dist/chunk-NS64DXMN.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
- package/dist/chunk-PGETSEDK.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
- package/dist/chunk-QLSV2N3G.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-S2SHZRD7.js +7681 -0
- package/dist/chunk-S2SHZRD7.js.map +1 -0
- package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
- package/dist/chunk-TQBBYTAF.js.map +1 -0
- package/dist/chunk-UU3NJO6N.js +72 -0
- package/dist/chunk-UU3NJO6N.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
- package/dist/chunk-ZPH5WTWF.js.map +1 -0
- package/dist/config/index.d.ts +232 -0
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +2 -3
- package/dist/controls/index.js +3 -2
- package/dist/dragdrop/index.d.ts +2 -4
- package/dist/index.d.ts +110 -381
- package/dist/index.js +691 -817
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -5
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +6 -8
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +164 -122
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +53 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +12 -7
- package/dist/state/index.js +16 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +84 -50
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +2 -4
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +53 -5
- package/src/annotations/Annotations.overlay.test.tsx +85 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +5 -4
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/toolMap.ts +3 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.ts +10 -5
- package/src/chrome/ChromeRegions.stories.tsx +0 -3
- package/src/chrome/LabChrome.tsx +114 -0
- package/src/chrome/builtins.tsx +20 -31
- package/src/chrome/index.ts +15 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +21 -9
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +30 -16
- package/src/chrome/types.ts +47 -20
- package/src/config/builder.test.ts +4 -0
- package/src/config/builder.ts +16 -10
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +13 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/types.ts +3 -1
- package/src/controls/ControlPanel.test.tsx +16 -0
- package/src/controls/ControlPanel.tsx +5 -2
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +38 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +11 -0
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +0 -2
- package/src/lab/Lab.surface.test.tsx +66 -3
- package/src/lab/Lab.test.tsx +91 -17
- package/src/lab/Lab.tsx +337 -124
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +0 -3
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -13
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabShell.less +49 -12
- package/src/lab/LabSwitcher.less +8 -1
- package/src/lab/Workspace.surface.test.tsx +2 -1
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/loupe/AGENTS.md +1 -1
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +1 -1
- package/src/loupe/useLoupe.ts +1 -1
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +13 -1
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +108 -0
- package/src/primitives/Split.tsx +159 -0
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +1 -1
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +104 -635
- package/src/state/store.ts +116 -157
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +39 -15
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +2 -3
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +2 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +2 -1
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +175 -0
- package/src/surface/useTiledSurface.ts +54 -5
- package/src/theme/Interstellar.stories.tsx +7 -7
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.tokens.json +2 -2
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +47 -4
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +1 -26
- package/src/trial/Trial.stories.tsx +1 -2
- package/src/trial/Trial.test.tsx +28 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +118 -38
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +7 -4
- package/src/trial/trialOps.ts +5 -3
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2P6PP5N4.js +0 -553
- package/dist/chunk-2P6PP5N4.js.map +0 -1
- package/dist/chunk-64ZCN3DA.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MOM3GOVY.js.map +0 -1
- package/dist/chunk-SMHP6XZ4.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { createMemoryAdapter } from './adapters';
|
|
3
3
|
import {
|
|
4
4
|
CURRENT_DOCUMENT_VERSION,
|
|
5
|
-
|
|
5
|
+
deleteConfirmed,
|
|
6
6
|
emptyDocument,
|
|
7
7
|
labDocumentKey,
|
|
8
8
|
MIGRATIONS,
|
|
9
9
|
migrateV0toV1,
|
|
10
10
|
migrateV1toV2,
|
|
11
11
|
migrateV2toV3,
|
|
12
|
+
migrateV3toV4,
|
|
12
13
|
normalizeDocument,
|
|
13
14
|
quarantineDocument,
|
|
14
15
|
quarantineKey,
|
|
@@ -83,19 +84,24 @@ describe('runMigrations', () => {
|
|
|
83
84
|
});
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
function reader(entries: Record<string, unknown>) {
|
|
88
|
+
const map = new Map(Object.entries(entries));
|
|
89
|
+
return async (key: string) => map.get(key);
|
|
90
|
+
}
|
|
91
|
+
|
|
86
92
|
describe('readLegacyDocument', () => {
|
|
87
|
-
it('returns null when no legacy key is present', () => {
|
|
88
|
-
expect(readLegacyDocument(
|
|
93
|
+
it('returns null when no legacy key is present', async () => {
|
|
94
|
+
expect(await readLegacyDocument(reader({}), 'lab', 'auto')).toBeNull();
|
|
89
95
|
});
|
|
90
96
|
|
|
91
|
-
it('assembles a version-0 document from the four buckets', () => {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
expect(readLegacyDocument(
|
|
97
|
+
it('assembles a version-0 document from the four buckets, parsed or not', async () => {
|
|
98
|
+
const read = reader({
|
|
99
|
+
[labStorageKey('lab', 'workspaces')]: JSON.stringify([{ id: 'w1' }]),
|
|
100
|
+
[labStorageKey('lab', 'saves')]: [{ id: 's1' }],
|
|
101
|
+
[labStorageKey('lab', 'layout')]: JSON.stringify({ w1: { h: 4 } }),
|
|
102
|
+
[labStorageKey('lab', 'theme')]: 'dark',
|
|
103
|
+
});
|
|
104
|
+
expect(await readLegacyDocument(read, 'lab', 'auto')).toEqual({
|
|
99
105
|
version: 0,
|
|
100
106
|
workspaces: [{ id: 'w1' }],
|
|
101
107
|
saves: [{ id: 's1' }],
|
|
@@ -104,97 +110,54 @@ describe('readLegacyDocument', () => {
|
|
|
104
110
|
});
|
|
105
111
|
});
|
|
106
112
|
|
|
107
|
-
it('survives one unparseable bucket without losing the others', () => {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
+
it('survives one unparseable bucket without losing the others', async () => {
|
|
114
|
+
const read = reader({
|
|
115
|
+
[labStorageKey('lab', 'workspaces')]: '{{{not json',
|
|
116
|
+
[labStorageKey('lab', 'saves')]: JSON.stringify([{ id: 's1' }]),
|
|
117
|
+
});
|
|
118
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
119
|
+
const doc = await readLegacyDocument(read, 'lab', 'auto');
|
|
113
120
|
expect(doc?.workspaces).toEqual([]);
|
|
114
121
|
expect(doc?.saves).toEqual([{ id: 's1' }]);
|
|
122
|
+
warn.mockRestore();
|
|
115
123
|
});
|
|
116
124
|
|
|
117
|
-
it('falls back to fallbackMode when the theme key is absent', () => {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
|
|
125
|
+
it('falls back to fallbackMode when the theme key is absent', async () => {
|
|
126
|
+
const read = reader({ [labStorageKey('lab', 'workspaces')]: '[]' });
|
|
127
|
+
expect((await readLegacyDocument(read, 'lab', 'dark'))?.mode).toBe('dark');
|
|
122
128
|
});
|
|
123
129
|
|
|
124
|
-
it('falls back to fallbackMode when the theme value is unrecognized', () => {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
|
|
130
|
+
it('falls back to fallbackMode when the theme value is unrecognized', async () => {
|
|
131
|
+
const read = reader({ [labStorageKey('lab', 'theme')]: 'chartreuse' });
|
|
132
|
+
expect((await readLegacyDocument(read, 'lab', 'dark'))?.mode).toBe('dark');
|
|
129
133
|
});
|
|
130
134
|
|
|
131
|
-
it('a valid theme value beats the fallback', () => {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('light');
|
|
135
|
+
it('a valid theme value beats the fallback', async () => {
|
|
136
|
+
const read = reader({ [labStorageKey('lab', 'theme')]: 'light' });
|
|
137
|
+
expect((await readLegacyDocument(read, 'lab', 'dark'))?.mode).toBe('light');
|
|
136
138
|
});
|
|
137
139
|
|
|
138
|
-
it('passes interstellar through untouched for migrateV0toV1 to coerce', () => {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('interstellar');
|
|
140
|
+
it('passes interstellar through untouched for migrateV0toV1 to coerce', async () => {
|
|
141
|
+
const read = reader({ [labStorageKey('lab', 'theme')]: 'interstellar' });
|
|
142
|
+
expect((await readLegacyDocument(read, 'lab', 'dark'))?.mode).toBe('interstellar');
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
describe('
|
|
147
|
-
it('deletes
|
|
148
|
-
const storage = createMemoryAdapter();
|
|
149
|
-
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
150
|
-
storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
|
|
151
|
-
storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
|
|
152
|
-
storage.write(labStorageKey('lab', 'theme'), 'dark');
|
|
153
|
-
storage.write(labDocumentKey('lab'), 'the-document');
|
|
154
|
-
|
|
155
|
-
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(true);
|
|
156
|
-
|
|
157
|
-
expect(storage.read(labStorageKey('lab', 'workspaces'))).toBeNull();
|
|
158
|
-
expect(storage.read(labStorageKey('lab', 'saves'))).toBeNull();
|
|
159
|
-
expect(storage.read(labStorageKey('lab', 'layout'))).toBeNull();
|
|
160
|
-
expect(storage.read(labStorageKey('lab', 'theme'))).toBeNull();
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('the document key itself survives deletion', () => {
|
|
164
|
-
const storage = createMemoryAdapter();
|
|
165
|
-
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
166
|
-
storage.write(labDocumentKey('lab'), 'the-document');
|
|
167
|
-
|
|
168
|
-
deleteLegacyKeys(storage, 'lab', 'the-document');
|
|
169
|
-
|
|
170
|
-
expect(storage.read(labDocumentKey('lab'))).toBe('the-document');
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('deletes nothing and returns false when the document key is absent', () => {
|
|
146
|
+
describe('deleteConfirmed', () => {
|
|
147
|
+
it('deletes the keys and confirms they are gone', async () => {
|
|
174
148
|
const storage = createMemoryAdapter();
|
|
175
|
-
storage.
|
|
176
|
-
|
|
177
|
-
expect(
|
|
178
|
-
expect(storage.
|
|
149
|
+
await storage.set('a', 1);
|
|
150
|
+
await storage.set('b', 2);
|
|
151
|
+
expect(await deleteConfirmed(storage, ['a', 'b'])).toBe(true);
|
|
152
|
+
expect(await storage.get('a')).toBeUndefined();
|
|
179
153
|
});
|
|
180
154
|
|
|
181
|
-
it('returns false when
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
storage
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
|
|
188
|
-
expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('deletes nothing and returns false when the document key holds different content', () => {
|
|
192
|
-
const storage = createMemoryAdapter();
|
|
193
|
-
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
194
|
-
storage.write(labDocumentKey('lab'), 'something-else');
|
|
195
|
-
|
|
196
|
-
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
|
|
197
|
-
expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
|
|
155
|
+
it('returns false when a delete silently does nothing', async () => {
|
|
156
|
+
const memory = createMemoryAdapter();
|
|
157
|
+
await memory.set('a', 1);
|
|
158
|
+
const storage = { ...memory, delete: async () => {} };
|
|
159
|
+
expect(await deleteConfirmed(storage, ['a'])).toBe(false);
|
|
160
|
+
expect(await memory.get('a')).toBe(1);
|
|
198
161
|
});
|
|
199
162
|
});
|
|
200
163
|
|
|
@@ -303,18 +266,15 @@ describe('MIGRATIONS', () => {
|
|
|
303
266
|
});
|
|
304
267
|
|
|
305
268
|
describe('quarantineDocument', () => {
|
|
306
|
-
it('copies the document aside and confirms it landed', () => {
|
|
269
|
+
it('copies the document aside and confirms it landed', async () => {
|
|
307
270
|
const storage = createMemoryAdapter();
|
|
308
|
-
|
|
309
|
-
expect(
|
|
310
|
-
expect(storage.read(quarantineKey('lab'))).toBe('the-bytes');
|
|
271
|
+
expect(await quarantineDocument(storage, 'lab', 'the-bytes')).toBe(true);
|
|
272
|
+
expect(await storage.get(quarantineKey('lab'))).toBe('the-bytes');
|
|
311
273
|
});
|
|
312
274
|
|
|
313
|
-
it('returns false when the quarantine write silently fails', () => {
|
|
314
|
-
const storage = createMemoryAdapter();
|
|
315
|
-
storage
|
|
316
|
-
|
|
317
|
-
expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(false);
|
|
275
|
+
it('returns false when the quarantine write silently fails', async () => {
|
|
276
|
+
const storage = { ...createMemoryAdapter(), set: async () => {} };
|
|
277
|
+
expect(await quarantineDocument(storage, 'lab', 'the-bytes')).toBe(false);
|
|
318
278
|
});
|
|
319
279
|
});
|
|
320
280
|
|
|
@@ -390,3 +350,18 @@ describe('version 2 to version 3', () => {
|
|
|
390
350
|
expect(out.mode).toBe('dark');
|
|
391
351
|
});
|
|
392
352
|
});
|
|
353
|
+
|
|
354
|
+
describe('version 3 to version 4', () => {
|
|
355
|
+
it('only restamps the document, whose shape does not change', () => {
|
|
356
|
+
const doc = {
|
|
357
|
+
version: 3,
|
|
358
|
+
trials: [{ id: 'w1' }],
|
|
359
|
+
saves: [],
|
|
360
|
+
layout: {},
|
|
361
|
+
undockedPanels: {},
|
|
362
|
+
mode: 'auto',
|
|
363
|
+
};
|
|
364
|
+
expect(migrateV3toV4(doc)).toEqual({ ...doc, version: 4 });
|
|
365
|
+
expect(MIGRATIONS[3]).toBe(migrateV3toV4);
|
|
366
|
+
});
|
|
367
|
+
});
|
package/src/state/document.ts
CHANGED
|
@@ -10,11 +10,10 @@ import type {
|
|
|
10
10
|
import type { UndockedPanels } from './undock';
|
|
11
11
|
|
|
12
12
|
/** Bumped whenever the persisted shape changes; every bump needs a migration. */
|
|
13
|
-
export const CURRENT_DOCUMENT_VERSION =
|
|
13
|
+
export const CURRENT_DOCUMENT_VERSION = 4;
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
* its document over lab `a`'s saves bucket. */
|
|
15
|
+
/** Where a version-3 lab kept its one document. Read once, to fold it into
|
|
16
|
+
* records, then deleted. */
|
|
18
17
|
export function labDocumentKey(storageKey: string): string {
|
|
19
18
|
return `lk:${storageKey}:doc`;
|
|
20
19
|
}
|
|
@@ -69,83 +68,92 @@ export function runMigrations(
|
|
|
69
68
|
|
|
70
69
|
const LEGACY_BUCKETS = ['workspaces', 'saves', 'layout', 'theme'] as const;
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
/** The four keys a lab used before it was one document. */
|
|
72
|
+
export function legacyKeys(storageKey: string): string[] {
|
|
73
|
+
return LEGACY_BUCKETS.map((bucket) => labStorageKey(storageKey, bucket));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A value some substrate returned for an old key: already parsed, or a JSON
|
|
77
|
+
* string from a store that does not parse. `undefined` when it is neither. */
|
|
78
|
+
export function parseStored(value: unknown): unknown {
|
|
79
|
+
if (typeof value !== 'string') return value;
|
|
74
80
|
try {
|
|
75
|
-
return JSON.parse(
|
|
81
|
+
return JSON.parse(value);
|
|
76
82
|
} catch {
|
|
77
|
-
|
|
78
|
-
return fallback;
|
|
83
|
+
return undefined;
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
function bucketOr<T>(value: unknown, fallback: T, bucket: string, shape: 'array' | 'object'): T {
|
|
88
|
+
if (value === undefined) return fallback;
|
|
89
|
+
const parsed = parseStored(value);
|
|
90
|
+
const fits = shape === 'array' ? Array.isArray(parsed) : !!parsed && typeof parsed === 'object';
|
|
91
|
+
if (fits) return parsed as T;
|
|
92
|
+
console.warn(`[labkit] unparseable legacy bucket "${bucket}", dropping it`);
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/** Assemble a version-0 document out of the four pre-document keys, or null
|
|
83
97
|
* if none of them is present. `fallbackMode` is used when the theme key is
|
|
84
98
|
* absent or holds something unrecognized; `interstellar` passes through
|
|
85
99
|
* untouched for `migrateV0toV1` to coerce. */
|
|
86
|
-
export function readLegacyDocument(
|
|
87
|
-
|
|
100
|
+
export async function readLegacyDocument(
|
|
101
|
+
read: (key: string) => Promise<unknown>,
|
|
88
102
|
storageKey: string,
|
|
89
103
|
fallbackMode: LabMode,
|
|
90
|
-
): Record<string, unknown> | null {
|
|
91
|
-
const
|
|
92
|
-
|
|
104
|
+
): Promise<Record<string, unknown> | null> {
|
|
105
|
+
const [workspaces, saves, layout, theme] = await Promise.all(
|
|
106
|
+
LEGACY_BUCKETS.map((bucket) => read(labStorageKey(storageKey, bucket))),
|
|
107
|
+
);
|
|
108
|
+
if ([workspaces, saves, layout, theme].every((v) => v === undefined)) return null;
|
|
93
109
|
|
|
94
|
-
const rawMode = storage.read(labStorageKey(storageKey, 'theme'));
|
|
95
110
|
const mode =
|
|
96
|
-
|
|
97
|
-
?
|
|
111
|
+
theme === 'light' || theme === 'dark' || theme === 'auto' || theme === 'interstellar'
|
|
112
|
+
? theme
|
|
98
113
|
: fallbackMode;
|
|
99
114
|
|
|
100
115
|
return {
|
|
101
116
|
version: 0,
|
|
102
|
-
workspaces:
|
|
103
|
-
saves:
|
|
104
|
-
layout:
|
|
117
|
+
workspaces: bucketOr(workspaces, [], 'workspaces', 'array'),
|
|
118
|
+
saves: bucketOr(saves, [], 'saves', 'array'),
|
|
119
|
+
layout: bucketOr(layout, {}, 'layout', 'object'),
|
|
105
120
|
mode,
|
|
106
121
|
};
|
|
107
122
|
}
|
|
108
123
|
|
|
109
|
-
/**
|
|
110
|
-
*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
* is
|
|
117
|
-
export function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
if (actual !== expectedDocument) {
|
|
124
|
-
console.warn(
|
|
125
|
-
`[labkit] keeping legacy keys for "${storageKey}": could not confirm the migrated document was written`,
|
|
126
|
-
);
|
|
124
|
+
/** Whether two stored values hold the same data. Stored values are
|
|
125
|
+
* structured-clone copies, so identity says nothing. */
|
|
126
|
+
export function sameStored(a: unknown, b: unknown): boolean {
|
|
127
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Delete `keys` and read each back, so an adapter whose delete silently did
|
|
131
|
+
* nothing is caught. `true` only once all of them are gone. */
|
|
132
|
+
export async function deleteConfirmed(storage: StorageAdapter, keys: string[]): Promise<boolean> {
|
|
133
|
+
try {
|
|
134
|
+
await Promise.all(keys.map((key) => storage.delete(key)));
|
|
135
|
+
const left = await Promise.all(keys.map((key) => storage.get(key)));
|
|
136
|
+
return left.every((v) => v === undefined);
|
|
137
|
+
} catch {
|
|
127
138
|
return false;
|
|
128
139
|
}
|
|
129
|
-
let allGone = true;
|
|
130
|
-
for (const bucket of LEGACY_BUCKETS) {
|
|
131
|
-
const key = labStorageKey(storageKey, bucket);
|
|
132
|
-
storage.delete?.(key);
|
|
133
|
-
if (storage.read(key) !== null) allGone = false;
|
|
134
|
-
}
|
|
135
|
-
return allGone;
|
|
136
140
|
}
|
|
137
141
|
|
|
138
|
-
/** Set `
|
|
142
|
+
/** Set `value` aside under the quarantine key, reading it back to confirm it
|
|
139
143
|
* landed. Returns `false` when it did not — a full disk is exactly when a
|
|
140
144
|
* document goes unreadable — so the caller can leave the original alone
|
|
141
145
|
* rather than overwrite the only copy of it. */
|
|
142
|
-
export function quarantineDocument(
|
|
146
|
+
export async function quarantineDocument(
|
|
143
147
|
storage: StorageAdapter,
|
|
144
148
|
storageKey: string,
|
|
145
|
-
|
|
146
|
-
): boolean {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
value: unknown,
|
|
150
|
+
): Promise<boolean> {
|
|
151
|
+
try {
|
|
152
|
+
await storage.set(quarantineKey(storageKey), value);
|
|
153
|
+
return sameStored(await storage.get(quarantineKey(storageKey)), value);
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
/** Fill in whatever a document is missing or holds the wrong shape of, so a
|
|
@@ -202,5 +210,11 @@ export function migrateV2toV3(doc: Record<string, unknown>): Record<string, unkn
|
|
|
202
210
|
return { ...doc, undockedPanels: {}, version: 3 };
|
|
203
211
|
}
|
|
204
212
|
|
|
213
|
+
/** Version 3 to version 4: a lab is stored as records under one prefix
|
|
214
|
+
* instead of one document. The joined shape does not change. */
|
|
215
|
+
export function migrateV3toV4(doc: Record<string, unknown>): Record<string, unknown> {
|
|
216
|
+
return { ...doc, version: 4 };
|
|
217
|
+
}
|
|
218
|
+
|
|
205
219
|
/** Index `i` migrates a version-`i` document to version `i + 1`. */
|
|
206
|
-
export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3];
|
|
220
|
+
export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3, migrateV3toV4];
|
package/src/state/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export {
|
|
2
|
+
createIndexedDbAdapter,
|
|
2
3
|
createMemoryAdapter,
|
|
4
|
+
type IndexedDbAdapterOptions,
|
|
5
|
+
indexedDbAdapter,
|
|
3
6
|
localStorageAdapter,
|
|
4
7
|
noneAdapter,
|
|
5
8
|
sessionStorageAdapter,
|
|
@@ -26,6 +29,9 @@ export {
|
|
|
26
29
|
labStorageKey,
|
|
27
30
|
serializeTrials,
|
|
28
31
|
} from './helpers';
|
|
32
|
+
export { type OpenedLabStore, type OpenLabStoreOptions, openLabStore } from './openLabStore';
|
|
33
|
+
export { Persistence, type PersistenceProps } from './Persistence';
|
|
34
|
+
export type { RecordCache, RecordChange } from './records';
|
|
29
35
|
export {
|
|
30
36
|
SingletonExperimentProvider,
|
|
31
37
|
type SingletonExperimentProviderProps,
|
|
@@ -41,10 +47,12 @@ export type {
|
|
|
41
47
|
SavedSnapshot,
|
|
42
48
|
SerializedTrial,
|
|
43
49
|
StorageAdapter,
|
|
50
|
+
StorageChange,
|
|
44
51
|
TrialRecord,
|
|
45
52
|
TrialStateHandle,
|
|
46
53
|
UndoStack,
|
|
47
54
|
} from './types';
|
|
48
55
|
export type { UndockedPanel, UndockedPanels } from './undock';
|
|
49
56
|
export { dockPanel, panelKey, undockPanel } from './undock';
|
|
57
|
+
export { type PersistedStateOptions, usePersistedState } from './usePersistedState';
|
|
50
58
|
export { useTrialState } from './useTrialState';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CURRENT_DOCUMENT_VERSION } from './document';
|
|
3
|
+
import {
|
|
4
|
+
documentOfRecords,
|
|
5
|
+
labPrefix,
|
|
6
|
+
parseRecordName,
|
|
7
|
+
recordsOfDocument,
|
|
8
|
+
trialRecord,
|
|
9
|
+
valueRecord,
|
|
10
|
+
} from './labRecords';
|
|
11
|
+
import type { LabDocument } from './types';
|
|
12
|
+
|
|
13
|
+
const VIEW = { zoom: 1, pan: { x: 0, y: 0 } };
|
|
14
|
+
|
|
15
|
+
describe('record names', () => {
|
|
16
|
+
it('encodes the storage key, so no lab prefix contains another', () => {
|
|
17
|
+
expect(labPrefix('drag-lab')).toBe('lk:drag-lab:');
|
|
18
|
+
expect(labPrefix('a:trial:x')).toBe('lk:a%3Atrial%3Ax:');
|
|
19
|
+
expect(labPrefix('a:trial:x').startsWith(labPrefix('a'))).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('round-trips ids holding the separator', () => {
|
|
23
|
+
expect(parseRecordName(trialRecord('odd:id'))).toEqual({ kind: 'trial', id: 'odd:id' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('scopes a value to its trial or to the lab', () => {
|
|
27
|
+
expect(valueRecord('t:1', 'tab')).toBe('value:trial:t%3A1:tab');
|
|
28
|
+
expect(valueRecord(null, 'tab')).toBe('value:lab:tab');
|
|
29
|
+
expect(parseRecordName(valueRecord('t:1', 'a:b'))).toEqual({ kind: 'value' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('answers null for a name no lab writes', () => {
|
|
33
|
+
for (const name of [
|
|
34
|
+
'doc',
|
|
35
|
+
'meta:x',
|
|
36
|
+
'trial:',
|
|
37
|
+
'trial:a:b',
|
|
38
|
+
'value:lab',
|
|
39
|
+
'value:x:y',
|
|
40
|
+
'trial:%E0%A4%A',
|
|
41
|
+
]) {
|
|
42
|
+
expect(parseRecordName(name)).toBeNull();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('documents and records', () => {
|
|
48
|
+
const doc: LabDocument = {
|
|
49
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
50
|
+
trials: [
|
|
51
|
+
{ id: 'b', instrumentName: 'T', config: {}, state: { n: 2 }, view: VIEW },
|
|
52
|
+
{ id: 'a', instrumentName: 'T', config: {}, state: { n: 1 }, view: VIEW },
|
|
53
|
+
],
|
|
54
|
+
saves: [
|
|
55
|
+
{
|
|
56
|
+
id: 's2',
|
|
57
|
+
name: 'later',
|
|
58
|
+
trialId: 'a',
|
|
59
|
+
instrumentName: 'T',
|
|
60
|
+
config: {},
|
|
61
|
+
state: {},
|
|
62
|
+
savedAt: 2,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 's1',
|
|
66
|
+
name: 'earlier',
|
|
67
|
+
trialId: 'a',
|
|
68
|
+
instrumentName: 'T',
|
|
69
|
+
config: {},
|
|
70
|
+
state: {},
|
|
71
|
+
savedAt: 1,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
layout: { a: { h: 1 } },
|
|
75
|
+
undockedPanels: {},
|
|
76
|
+
mode: 'dark',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
it('writes meta last', () => {
|
|
80
|
+
const names = recordsOfDocument(doc).map(([name]) => name);
|
|
81
|
+
expect(names.at(-1)).toBe('meta');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('joins back into the document it came from, trials in stored order', () => {
|
|
85
|
+
const joined = documentOfRecords(recordsOfDocument(doc).reverse());
|
|
86
|
+
expect(joined?.doc).toEqual({ ...doc, saves: [doc.saves[1], doc.saves[0]] });
|
|
87
|
+
expect([...(joined?.orders ?? [])]).toEqual([
|
|
88
|
+
['b', 0],
|
|
89
|
+
['a', 1],
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('breaks an order tie by id', () => {
|
|
94
|
+
const joined = documentOfRecords([
|
|
95
|
+
['meta', { version: 4, mode: 'auto' }],
|
|
96
|
+
['trial:z', { instrumentName: 'T', order: 0 }],
|
|
97
|
+
['trial:m', { instrumentName: 'T', order: 0 }],
|
|
98
|
+
]);
|
|
99
|
+
expect((joined?.doc.trials as { id: string }[]).map((t) => t.id)).toEqual(['m', 'z']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('is null without a meta record', () => {
|
|
103
|
+
expect(documentOfRecords([['trial:a', { instrumentName: 'T', order: 0 }]])).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|