@weasel-js/labkit 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-D7EQnnwJ.d.ts → useTrialState-DYe2vUwN.d.ts} +60 -36
- package/dist/canvas/index.d.ts +1 -1
- package/dist/chunk-3TYUJR7Z.js +488 -0
- package/dist/chunk-3TYUJR7Z.js.map +1 -0
- package/dist/{chunk-BAPZPDDA.js → chunk-C6GJKPUI.js} +365 -340
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-DJLDIRFN.js} +28 -12
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-NRKWVTVT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-NRKWVTVT.js.map} +1 -1
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +149 -149
- package/dist/index.js +164 -159
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +9 -2
- 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 +21 -13
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/canvas/AGENTS.md +6 -2
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +52 -0
- package/src/index.ts +11 -7
- package/src/instrument/SineWave.smoke.test.tsx +1 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +18 -6
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +5 -5
- 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/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +18 -18
- 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 +159 -102
- package/src/state/types.ts +29 -12
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/styles.less +2 -2
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
- package/src/{workspace → trial}/index.ts +13 -13
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- 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/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/workspaceOps.ts +0 -102
package/src/layers/AGENTS.md
CHANGED
|
@@ -31,9 +31,9 @@ The instrument declares layer ids:
|
|
|
31
31
|
layers: { ids: ['grid', 'plants'] }
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`
|
|
34
|
+
`Trial.tsx` converts each id into a `LayerDescriptor` (with `label === id` by default) and tracks `visibility` and a derived `layerOrder` in local state. The order produced by `onReorder` is then applied to `instrument.canvas.layers` before they're passed to `<CanvasStack>`.
|
|
35
35
|
|
|
36
|
-
There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the
|
|
36
|
+
There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the trial assumes the ids match. If you declare a layer in `canvas` that isn't in `layers.ids`, it stays in default order and is always visible.
|
|
37
37
|
|
|
38
38
|
## `alwaysOn` semantics
|
|
39
39
|
|
|
@@ -52,7 +52,7 @@ Pointer capture is acquired on `pointerdown` and released on `pointerup`, so dra
|
|
|
52
52
|
|
|
53
53
|
## Empty state
|
|
54
54
|
|
|
55
|
-
If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers".
|
|
55
|
+
If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers". Trial already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
|
|
56
56
|
|
|
57
57
|
## When to fork
|
|
58
58
|
|
|
@@ -12,7 +12,7 @@ type Story = StoryObj<typeof Toolbar>;
|
|
|
12
12
|
export const Default: Story = {
|
|
13
13
|
render: () => (
|
|
14
14
|
<Toolbar>
|
|
15
|
-
<Toolbar.Title>My
|
|
15
|
+
<Toolbar.Title>My Trial</Toolbar.Title>
|
|
16
16
|
<Toolbar.Button onClick={() => {}}>Undo</Toolbar.Button>
|
|
17
17
|
<Toolbar.Button onClick={() => {}}>Redo</Toolbar.Button>
|
|
18
18
|
<Toolbar.Spacer />
|
|
@@ -24,7 +24,7 @@ export const Default: Story = {
|
|
|
24
24
|
export const WithDisabled: Story = {
|
|
25
25
|
render: () => (
|
|
26
26
|
<Toolbar>
|
|
27
|
-
<Toolbar.Title>Empty
|
|
27
|
+
<Toolbar.Title>Empty trial</Toolbar.Title>
|
|
28
28
|
<Toolbar.Button onClick={() => {}} disabled>
|
|
29
29
|
Undo
|
|
30
30
|
</Toolbar.Button>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { act, render, renderHook } from '@testing-library/react';
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { createMemoryAdapter } from './adapters';
|
|
4
|
+
import { labDocumentKey } from './document';
|
|
4
5
|
import { SingletonExperimentProvider } from './SingletonExperiment';
|
|
5
|
-
import {
|
|
6
|
+
import { useTrialState } from './useTrialState';
|
|
6
7
|
|
|
7
8
|
interface Config {
|
|
8
9
|
width: number;
|
|
@@ -17,7 +18,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
17
18
|
vi.useRealTimers();
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
it('exposes config and state via
|
|
21
|
+
it('exposes config and state via useTrialState', () => {
|
|
21
22
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
22
23
|
<SingletonExperimentProvider<State, Config>
|
|
23
24
|
id="test"
|
|
@@ -29,7 +30,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
29
30
|
{children}
|
|
30
31
|
</SingletonExperimentProvider>
|
|
31
32
|
);
|
|
32
|
-
const { result } = renderHook(() =>
|
|
33
|
+
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
33
34
|
expect(result.current.config).toEqual({ width: 100, bg: '#000' });
|
|
34
35
|
expect(result.current.state).toEqual({ zoom: 1 });
|
|
35
36
|
});
|
|
@@ -38,7 +39,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
38
39
|
vi.useFakeTimers();
|
|
39
40
|
const storage = createMemoryAdapter();
|
|
40
41
|
const Probe = () => {
|
|
41
|
-
const h =
|
|
42
|
+
const h = useTrialState<State, Config>();
|
|
42
43
|
return (
|
|
43
44
|
<button type="button" onClick={() => h.setConfig('width', 200)}>
|
|
44
45
|
go
|
|
@@ -62,15 +63,14 @@ describe('SingletonExperimentProvider', () => {
|
|
|
62
63
|
act(() => {
|
|
63
64
|
vi.advanceTimersByTime(400);
|
|
64
65
|
});
|
|
65
|
-
|
|
66
|
-
const raw = storage.read('lk:test:workspaces');
|
|
66
|
+
const raw = storage.read(labDocumentKey('test'));
|
|
67
67
|
expect(raw).toBeTruthy();
|
|
68
68
|
expect(raw).toContain('"width":200');
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
it('rehydrates from storage on mount', () => {
|
|
72
72
|
const storage = createMemoryAdapter();
|
|
73
|
-
//
|
|
73
|
+
// A pre-document lab, which createLabStore folds forward on hydration.
|
|
74
74
|
storage.write(
|
|
75
75
|
'lk:test:workspaces',
|
|
76
76
|
JSON.stringify([
|
|
@@ -94,7 +94,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
94
94
|
{children}
|
|
95
95
|
</SingletonExperimentProvider>
|
|
96
96
|
);
|
|
97
|
-
const { result } = renderHook(() =>
|
|
97
|
+
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
98
98
|
expect(result.current.config).toEqual({ width: 999, bg: '#fff' });
|
|
99
99
|
expect(result.current.state).toEqual({ zoom: 2 });
|
|
100
100
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode, useRef } from 'react';
|
|
2
|
-
import { LabStoreProvider,
|
|
2
|
+
import { LabStoreProvider, TrialIdProvider } from './context';
|
|
3
3
|
import { createLabStore, type LabStore } from './store';
|
|
4
4
|
import type { StorageAdapter } from './types';
|
|
5
5
|
|
|
@@ -7,8 +7,8 @@ const SINGLETON_INSTRUMENT = '__singleton__';
|
|
|
7
7
|
|
|
8
8
|
/** Props for `<SingletonExperimentProvider>`. */
|
|
9
9
|
export interface SingletonExperimentProviderProps<TS, TC> {
|
|
10
|
-
/** Stable id for the synthetic
|
|
11
|
-
*
|
|
10
|
+
/** Stable id for the synthetic trial; also doubles as the
|
|
11
|
+
* TrialIdContext value. */
|
|
12
12
|
id: string;
|
|
13
13
|
initialConfig: TC;
|
|
14
14
|
initialState: TS;
|
|
@@ -18,9 +18,9 @@ export interface SingletonExperimentProviderProps<TS, TC> {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* One-
|
|
22
|
-
* a `LabStoreProvider` + `
|
|
23
|
-
*
|
|
21
|
+
* One-trial `<Lab>` substitute for single-screen experiments. Mounts
|
|
22
|
+
* a `LabStoreProvider` + `TrialIdProvider` with one synthetic
|
|
23
|
+
* trial, so `useTrialState` works without going through the
|
|
24
24
|
* full `<Lab instruments={...}>` runtime.
|
|
25
25
|
*/
|
|
26
26
|
export function SingletonExperimentProvider<TS, TC>({
|
|
@@ -34,8 +34,8 @@ export function SingletonExperimentProvider<TS, TC>({
|
|
|
34
34
|
const storeRef = useRef<LabStore | null>(null);
|
|
35
35
|
if (storeRef.current === null) {
|
|
36
36
|
const store = createLabStore({ storageKey, storage });
|
|
37
|
-
if (!store.getState().
|
|
38
|
-
store.getState().
|
|
37
|
+
if (!store.getState().trials.some((w) => w.id === id)) {
|
|
38
|
+
store.getState().addTrial({
|
|
39
39
|
id,
|
|
40
40
|
instrumentName: SINGLETON_INSTRUMENT,
|
|
41
41
|
config: initialConfig,
|
|
@@ -47,7 +47,7 @@ export function SingletonExperimentProvider<TS, TC>({
|
|
|
47
47
|
}
|
|
48
48
|
return (
|
|
49
49
|
<LabStoreProvider store={storeRef.current}>
|
|
50
|
-
<
|
|
50
|
+
<TrialIdProvider trialId={id}>{children}</TrialIdProvider>
|
|
51
51
|
</LabStoreProvider>
|
|
52
52
|
);
|
|
53
53
|
}
|
package/src/state/context.tsx
CHANGED
|
@@ -27,25 +27,25 @@ export function useLabStore(): LabStoreState & ReturnType<LabStore['getState']>
|
|
|
27
27
|
return useStore(ctx.store);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Context carrying which
|
|
31
|
-
export const
|
|
30
|
+
/** Context carrying which trial the subtree belongs to. */
|
|
31
|
+
export const TrialIdContext = createContext<string | null>(null);
|
|
32
32
|
|
|
33
|
-
/** Names the
|
|
33
|
+
/** Names the trial its subtree belongs to, so an instrument's hooks can
|
|
34
34
|
* find their own record in the store without being passed an id. */
|
|
35
|
-
export function
|
|
36
|
-
|
|
35
|
+
export function TrialIdProvider({
|
|
36
|
+
trialId,
|
|
37
37
|
children,
|
|
38
38
|
}: {
|
|
39
|
-
|
|
39
|
+
trialId: string;
|
|
40
40
|
children: ReactNode;
|
|
41
41
|
}): ReactElement {
|
|
42
|
-
return <
|
|
42
|
+
return <TrialIdContext.Provider value={trialId}>{children}</TrialIdContext.Provider>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** The id of the
|
|
46
|
-
* `<
|
|
47
|
-
export function
|
|
48
|
-
const id = useContext(
|
|
49
|
-
if (!id) throw new Error('[labkit]
|
|
45
|
+
/** The id of the trial this component is inside. Throws outside a
|
|
46
|
+
* `<TrialIdProvider>`. */
|
|
47
|
+
export function useTrialId(): string {
|
|
48
|
+
const id = useContext(TrialIdContext);
|
|
49
|
+
if (!id) throw new Error('[labkit] useTrialId must be used inside <TrialIdProvider>');
|
|
50
50
|
return id;
|
|
51
51
|
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createMemoryAdapter } from './adapters';
|
|
3
|
+
import {
|
|
4
|
+
CURRENT_DOCUMENT_VERSION,
|
|
5
|
+
deleteLegacyKeys,
|
|
6
|
+
emptyDocument,
|
|
7
|
+
labDocumentKey,
|
|
8
|
+
MIGRATIONS,
|
|
9
|
+
migrateV0toV1,
|
|
10
|
+
migrateV1toV2,
|
|
11
|
+
normalizeDocument,
|
|
12
|
+
quarantineDocument,
|
|
13
|
+
quarantineKey,
|
|
14
|
+
readLegacyDocument,
|
|
15
|
+
runMigrations,
|
|
16
|
+
} from './document';
|
|
17
|
+
import { labStorageKey } from './helpers';
|
|
18
|
+
|
|
19
|
+
describe('document keys', () => {
|
|
20
|
+
it('namespaces the document key by storageKey', () => {
|
|
21
|
+
expect(labDocumentKey('mylab')).toBe('lk:mylab:doc');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('namespaces the quarantine key by storageKey', () => {
|
|
25
|
+
expect(quarantineKey('mylab')).toBe('lk:mylab:quarantine');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('cannot collide with a legacy bucket key of another lab', () => {
|
|
29
|
+
for (const bucket of ['workspaces', 'saves', 'layout', 'theme'] as const) {
|
|
30
|
+
expect(labDocumentKey(`a:${bucket}`)).not.toBe(labStorageKey('a', bucket));
|
|
31
|
+
expect(labDocumentKey('a')).not.toBe(labStorageKey('a', bucket));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('cannot collide with a quarantine key of another lab', () => {
|
|
36
|
+
expect(labDocumentKey('a:quarantine')).not.toBe(quarantineKey('a'));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('emptyDocument', () => {
|
|
41
|
+
it('is stamped at the current version with the given mode', () => {
|
|
42
|
+
const doc = emptyDocument('light');
|
|
43
|
+
expect(doc.version).toBe(CURRENT_DOCUMENT_VERSION);
|
|
44
|
+
expect(doc.mode).toBe('light');
|
|
45
|
+
expect(doc.trials).toEqual([]);
|
|
46
|
+
expect(doc.saves).toEqual([]);
|
|
47
|
+
expect(doc.layout).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const bump = (to: number) => (doc: Record<string, unknown>) => ({ ...doc, version: to });
|
|
52
|
+
|
|
53
|
+
describe('runMigrations', () => {
|
|
54
|
+
it('returns a current-version document untouched', () => {
|
|
55
|
+
const doc = { version: 2, a: 1 };
|
|
56
|
+
const out = runMigrations(doc, [bump(1), bump(2)], 2);
|
|
57
|
+
expect(out).toEqual({ ok: true, doc: { version: 2, a: 1 }, migrated: false });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('runs every migration from the stored version to the current one', () => {
|
|
61
|
+
const out = runMigrations({ version: 0 }, [bump(1), bump(2)], 2);
|
|
62
|
+
expect(out).toEqual({ ok: true, doc: { version: 2 }, migrated: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('refuses a document from a future version', () => {
|
|
66
|
+
const out = runMigrations({ version: 9 }, [bump(1)], 1);
|
|
67
|
+
expect(out).toEqual({ ok: false, reason: 'future' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reports a throwing migration as failed', () => {
|
|
71
|
+
const boom = () => {
|
|
72
|
+
throw new Error('nope');
|
|
73
|
+
};
|
|
74
|
+
const out = runMigrations({ version: 0 }, [boom], 1);
|
|
75
|
+
expect(out.ok).toBe(false);
|
|
76
|
+
if (!out.ok) expect(out.reason).toBe('failed');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('treats a document with no version as version 0', () => {
|
|
80
|
+
const out = runMigrations({}, [bump(1)], 1);
|
|
81
|
+
expect(out).toEqual({ ok: true, doc: { version: 1 }, migrated: true });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('readLegacyDocument', () => {
|
|
86
|
+
it('returns null when no legacy key is present', () => {
|
|
87
|
+
expect(readLegacyDocument(createMemoryAdapter(), 'lab', 'auto')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('assembles a version-0 document from the four buckets', () => {
|
|
91
|
+
const storage = createMemoryAdapter();
|
|
92
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
93
|
+
storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
|
|
94
|
+
storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
|
|
95
|
+
storage.write(labStorageKey('lab', 'theme'), 'dark');
|
|
96
|
+
|
|
97
|
+
expect(readLegacyDocument(storage, 'lab', 'auto')).toEqual({
|
|
98
|
+
version: 0,
|
|
99
|
+
workspaces: [{ id: 'w1' }],
|
|
100
|
+
saves: [{ id: 's1' }],
|
|
101
|
+
layout: { w1: { h: 4 } },
|
|
102
|
+
mode: 'dark',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('survives one unparseable bucket without losing the others', () => {
|
|
107
|
+
const storage = createMemoryAdapter();
|
|
108
|
+
storage.write(labStorageKey('lab', 'workspaces'), '{{{not json');
|
|
109
|
+
storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
|
|
110
|
+
|
|
111
|
+
const doc = readLegacyDocument(storage, 'lab', 'auto');
|
|
112
|
+
expect(doc?.workspaces).toEqual([]);
|
|
113
|
+
expect(doc?.saves).toEqual([{ id: 's1' }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls back to fallbackMode when the theme key is absent', () => {
|
|
117
|
+
const storage = createMemoryAdapter();
|
|
118
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([]));
|
|
119
|
+
|
|
120
|
+
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('falls back to fallbackMode when the theme value is unrecognized', () => {
|
|
124
|
+
const storage = createMemoryAdapter();
|
|
125
|
+
storage.write(labStorageKey('lab', 'theme'), 'chartreuse');
|
|
126
|
+
|
|
127
|
+
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('a valid theme value beats the fallback', () => {
|
|
131
|
+
const storage = createMemoryAdapter();
|
|
132
|
+
storage.write(labStorageKey('lab', 'theme'), 'light');
|
|
133
|
+
|
|
134
|
+
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('light');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('passes interstellar through untouched for migrateV0toV1 to coerce', () => {
|
|
138
|
+
const storage = createMemoryAdapter();
|
|
139
|
+
storage.write(labStorageKey('lab', 'theme'), 'interstellar');
|
|
140
|
+
|
|
141
|
+
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('interstellar');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('deleteLegacyKeys', () => {
|
|
146
|
+
it('deletes all four legacy buckets when the document matches', () => {
|
|
147
|
+
const storage = createMemoryAdapter();
|
|
148
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
149
|
+
storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
|
|
150
|
+
storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
|
|
151
|
+
storage.write(labStorageKey('lab', 'theme'), 'dark');
|
|
152
|
+
storage.write(labDocumentKey('lab'), 'the-document');
|
|
153
|
+
|
|
154
|
+
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(true);
|
|
155
|
+
|
|
156
|
+
expect(storage.read(labStorageKey('lab', 'workspaces'))).toBeNull();
|
|
157
|
+
expect(storage.read(labStorageKey('lab', 'saves'))).toBeNull();
|
|
158
|
+
expect(storage.read(labStorageKey('lab', 'layout'))).toBeNull();
|
|
159
|
+
expect(storage.read(labStorageKey('lab', 'theme'))).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('the document key itself survives deletion', () => {
|
|
163
|
+
const storage = createMemoryAdapter();
|
|
164
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
165
|
+
storage.write(labDocumentKey('lab'), 'the-document');
|
|
166
|
+
|
|
167
|
+
deleteLegacyKeys(storage, 'lab', 'the-document');
|
|
168
|
+
|
|
169
|
+
expect(storage.read(labDocumentKey('lab'))).toBe('the-document');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('deletes nothing and returns false when the document key is absent', () => {
|
|
173
|
+
const storage = createMemoryAdapter();
|
|
174
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
175
|
+
|
|
176
|
+
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
|
|
177
|
+
expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns false when the adapter cannot delete, and the keys survive', () => {
|
|
181
|
+
const mem = createMemoryAdapter();
|
|
182
|
+
const storage = { read: mem.read, write: mem.write };
|
|
183
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
184
|
+
storage.write(labDocumentKey('lab'), 'the-document');
|
|
185
|
+
|
|
186
|
+
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
|
|
187
|
+
expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('deletes nothing and returns false when the document key holds different content', () => {
|
|
191
|
+
const storage = createMemoryAdapter();
|
|
192
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
193
|
+
storage.write(labDocumentKey('lab'), 'something-else');
|
|
194
|
+
|
|
195
|
+
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
|
|
196
|
+
expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('migrateV0toV1', () => {
|
|
201
|
+
it('stamps version 1', () => {
|
|
202
|
+
expect(migrateV0toV1({ version: 0 }).version).toBe(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('renames the old interstellar mode to dark', () => {
|
|
206
|
+
expect(migrateV0toV1({ version: 0, mode: 'interstellar' }).mode).toBe('dark');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('replaces an unrecognized mode with auto', () => {
|
|
210
|
+
expect(migrateV0toV1({ version: 0, mode: 'chartreuse' }).mode).toBe('auto');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it.each(['light', 'dark', 'auto'] as const)('passes mode %s through unchanged', (mode) => {
|
|
214
|
+
expect(migrateV0toV1({ version: 0, mode }).mode).toBe(mode);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('fills in every missing section', () => {
|
|
218
|
+
expect(migrateV0toV1({ version: 0 })).toEqual({
|
|
219
|
+
version: 1,
|
|
220
|
+
workspaces: [],
|
|
221
|
+
saves: [],
|
|
222
|
+
layout: {},
|
|
223
|
+
mode: 'auto',
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('preserves a populated workspaces array', () => {
|
|
228
|
+
const workspaces = [{ id: 'w1', instrumentName: 'osc' }];
|
|
229
|
+
expect(migrateV0toV1({ version: 0, workspaces }).workspaces).toEqual(workspaces);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('preserves a populated saves array', () => {
|
|
233
|
+
const saves = [{ id: 's1', name: 'my save' }];
|
|
234
|
+
expect(migrateV0toV1({ version: 0, saves }).saves).toEqual(saves);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('preserves a populated layout object', () => {
|
|
238
|
+
const layout = { w1: { h: 4 } };
|
|
239
|
+
expect(migrateV0toV1({ version: 0, layout }).layout).toEqual(layout);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('falls back layout to {} and workspaces/saves to [] without swapping them', () => {
|
|
243
|
+
const out = migrateV0toV1({ version: 0, workspaces: 'nope', saves: 'nope', layout: 'nope' });
|
|
244
|
+
expect(out.workspaces).toEqual([]);
|
|
245
|
+
expect(out.saves).toEqual([]);
|
|
246
|
+
expect(out.layout).toEqual({});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('migrateV1toV2', () => {
|
|
251
|
+
it('stamps version 2', () => {
|
|
252
|
+
expect(migrateV1toV2({ version: 1, workspaces: [] }).version).toBe(2);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('renames workspaces to trials, record for record', () => {
|
|
256
|
+
const records = [{ id: 'w1', instrumentName: 'osc', state: { n: 1 } }];
|
|
257
|
+
const out = migrateV1toV2({ version: 1, workspaces: records });
|
|
258
|
+
expect(out.trials).toEqual(records);
|
|
259
|
+
expect(out).not.toHaveProperty('workspaces');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('leaves the other sections alone', () => {
|
|
263
|
+
const out = migrateV1toV2({
|
|
264
|
+
version: 1,
|
|
265
|
+
workspaces: [],
|
|
266
|
+
saves: [{ id: 's1' }],
|
|
267
|
+
layout: { w1: { h: 4 } },
|
|
268
|
+
mode: 'dark',
|
|
269
|
+
});
|
|
270
|
+
expect(out.saves).toEqual([{ id: 's1' }]);
|
|
271
|
+
expect(out.layout).toEqual({ w1: { h: 4 } });
|
|
272
|
+
expect(out.mode).toBe('dark');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('falls back to an empty trials list when workspaces is the wrong shape', () => {
|
|
276
|
+
expect(migrateV1toV2({ version: 1, workspaces: 'nope' }).trials).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('MIGRATIONS', () => {
|
|
281
|
+
it('has one entry per version below the current one', () => {
|
|
282
|
+
expect(MIGRATIONS).toHaveLength(CURRENT_DOCUMENT_VERSION);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('indexes migrateV0toV1 at position 0', () => {
|
|
286
|
+
expect(MIGRATIONS[0]).toBe(migrateV0toV1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('indexes migrateV1toV2 at position 1', () => {
|
|
290
|
+
expect(MIGRATIONS[1]).toBe(migrateV1toV2);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('walks a version-0 document all the way to trials', () => {
|
|
294
|
+
const outcome = runMigrations(
|
|
295
|
+
{ version: 0, workspaces: [{ id: 'w1' }] },
|
|
296
|
+
MIGRATIONS,
|
|
297
|
+
CURRENT_DOCUMENT_VERSION,
|
|
298
|
+
);
|
|
299
|
+
expect(outcome).toMatchObject({ ok: true, migrated: true });
|
|
300
|
+
expect(outcome.ok && outcome.doc.trials).toEqual([{ id: 'w1' }]);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('quarantineDocument', () => {
|
|
305
|
+
it('copies the document aside and confirms it landed', () => {
|
|
306
|
+
const storage = createMemoryAdapter();
|
|
307
|
+
|
|
308
|
+
expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(true);
|
|
309
|
+
expect(storage.read(quarantineKey('lab'))).toBe('the-bytes');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('returns false when the quarantine write silently fails', () => {
|
|
313
|
+
const storage = createMemoryAdapter();
|
|
314
|
+
storage.write = () => {};
|
|
315
|
+
|
|
316
|
+
expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('normalizeDocument', () => {
|
|
321
|
+
it('fills in every missing section', () => {
|
|
322
|
+
expect(normalizeDocument({ version: CURRENT_DOCUMENT_VERSION }, 'auto')).toEqual({
|
|
323
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
324
|
+
trials: [],
|
|
325
|
+
saves: [],
|
|
326
|
+
layout: {},
|
|
327
|
+
mode: 'auto',
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('uses the fallback mode when the document has none', () => {
|
|
332
|
+
expect(normalizeDocument({ version: CURRENT_DOCUMENT_VERSION }, 'light').mode).toBe('light');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('uses the fallback mode when the stored mode is unrecognized', () => {
|
|
336
|
+
expect(
|
|
337
|
+
normalizeDocument({ version: CURRENT_DOCUMENT_VERSION, mode: 'chartreuse' }, 'light').mode,
|
|
338
|
+
).toBe('light');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('keeps a stored mode over the fallback', () => {
|
|
342
|
+
expect(
|
|
343
|
+
normalizeDocument({ version: CURRENT_DOCUMENT_VERSION, mode: 'dark' }, 'light').mode,
|
|
344
|
+
).toBe('dark');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('preserves populated sections', () => {
|
|
348
|
+
const trials = [{ id: 'w1' }];
|
|
349
|
+
const saves = [{ id: 's1' }];
|
|
350
|
+
const layout = { w1: { h: 4 } };
|
|
351
|
+
const out = normalizeDocument(
|
|
352
|
+
{ version: CURRENT_DOCUMENT_VERSION, trials, saves, layout },
|
|
353
|
+
'auto',
|
|
354
|
+
);
|
|
355
|
+
expect(out.trials).toEqual(trials);
|
|
356
|
+
expect(out.saves).toEqual(saves);
|
|
357
|
+
expect(out.layout).toEqual(layout);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('replaces wrong-shaped sections without swapping them', () => {
|
|
361
|
+
const out = normalizeDocument(
|
|
362
|
+
{ version: CURRENT_DOCUMENT_VERSION, trials: 'nope', saves: 'nope', layout: 'nope' },
|
|
363
|
+
'auto',
|
|
364
|
+
);
|
|
365
|
+
expect(out.trials).toEqual([]);
|
|
366
|
+
expect(out.saves).toEqual([]);
|
|
367
|
+
expect(out.layout).toEqual({});
|
|
368
|
+
});
|
|
369
|
+
});
|