@weasel-js/labkit 1.4.4 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -6
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
- package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
- package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
- package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
- package/dist/canvas/index.d.ts +18 -129
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +5 -93
- package/dist/chrome/index.js +10 -6
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
- package/dist/chunk-CEIUBPPW.js.map +1 -0
- package/dist/chunk-EH7UEQV7.js +590 -0
- package/dist/chunk-EH7UEQV7.js.map +1 -0
- package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
- package/dist/chunk-ERFMC6WU.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-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/chunk-IXTQI7JX.js +737 -0
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/chunk-PHK4W562.js +72 -0
- package/dist/chunk-PHK4W562.js.map +1 -0
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
- package/dist/chunk-UN4U4P63.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/chunk-Z76KEZAX.js +15 -0
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/chunk-ZDHY47EV.js +363 -0
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +276 -0
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +3 -4
- package/dist/controls/index.js +4 -3
- package/dist/dragdrop/index.d.ts +3 -5
- package/dist/index.d.ts +266 -413
- package/dist/index.js +1775 -2251
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -6
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +69 -20
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +175 -309
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +55 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +13 -8
- package/dist/state/index.js +18 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +223 -76
- 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 +3 -5
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +56 -5
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +105 -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/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +6 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- 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.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +21 -9
- package/src/chrome/ChromeRegions.stories.tsx +1 -4
- package/src/chrome/LabChrome.tsx +130 -0
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +21 -31
- package/src/chrome/index.ts +16 -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 +16 -9
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +34 -16
- package/src/chrome/types.ts +50 -20
- package/src/config/auto.test.ts +12 -0
- package/src/config/auto.ts +15 -0
- package/src/config/autoConfig.test.ts +107 -0
- package/src/config/autoConfig.ts +153 -0
- package/src/config/builder.test.ts +42 -0
- package/src/config/builder.ts +68 -17
- 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 +16 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/path.ts +7 -2
- package/src/config/resolve.test.ts +25 -0
- package/src/config/resolve.ts +17 -1
- package/src/config/types.ts +34 -2
- package/src/config/useResolvedConfig.ts +26 -0
- package/src/controls/AutoControls.stories.tsx +88 -0
- package/src/controls/ControlPanel.stories.tsx +42 -1
- package/src/controls/ControlPanel.test.tsx +530 -4
- package/src/controls/ControlPanel.tsx +562 -82
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +66 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +40 -1
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +1 -3
- package/src/lab/Lab.surface.test.tsx +71 -3
- package/src/lab/Lab.test.tsx +168 -16
- package/src/lab/Lab.tsx +434 -129
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +1 -4
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -14
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +73 -15
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +18 -11
- package/src/lab/LabSwitcher.test.tsx +14 -9
- package/src/lab/LabSwitcher.tsx +8 -4
- package/src/lab/Workspace.less +1 -1
- package/src/lab/Workspace.stories.tsx +1 -1
- package/src/lab/Workspace.surface.test.tsx +1 -0
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +17 -5
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +60 -3
- package/src/loupe/useLoupe.ts +29 -49
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +14 -2
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/JobProgress.stories.tsx +1 -1
- package/src/primitives/Legend.stories.tsx +1 -1
- package/src/primitives/ScaleIndicator.stories.tsx +1 -1
- package/src/primitives/Sidebar.less +7 -0
- package/src/primitives/Sidebar.stories.tsx +1 -1
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +133 -0
- package/src/primitives/Split.tsx +175 -0
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.less +95 -0
- package/src/specimen/Specimen.stories.tsx +13 -0
- package/src/specimen/Specimen.test.tsx +13 -0
- package/src/specimen/Specimen.tsx +888 -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 +169 -635
- package/src/state/store.ts +146 -163
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +65 -17
- 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 +87 -3
- package/src/state/useTrialState.ts +9 -1
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -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 +1 -0
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +231 -1
- package/src/surface/useTiledSurface.ts +76 -15
- package/src/theme/Interstellar.stories.tsx +9 -9
- package/src/theme/interstellar.test.ts +17 -6
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +59 -7
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +5 -26
- package/src/trial/Trial.stories.tsx +5 -3
- package/src/trial/Trial.test.tsx +31 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +139 -52
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +24 -7
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +67 -7
- 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-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.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 +0 -343
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@weasel-js/forge';
|
|
2
|
+
import { expect, userEvent, waitFor, within } from '@weasel-js/forge/play';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { createIndexedDbAdapter } from './adapters';
|
|
5
|
+
import { Persistence } from './Persistence';
|
|
6
|
+
import { usePersistedState } from './usePersistedState';
|
|
7
|
+
|
|
8
|
+
// jsdom only has fake-indexeddb, an imitation. This round-trips a value through
|
|
9
|
+
// the browser's own IndexedDB: write it, unmount the provider so it closes,
|
|
10
|
+
// mount a fresh one, and read it back.
|
|
11
|
+
|
|
12
|
+
function Tab() {
|
|
13
|
+
const [tab, setTab] = usePersistedState('tab', 'shape');
|
|
14
|
+
return (
|
|
15
|
+
<p>
|
|
16
|
+
<output aria-label="tab">{tab}</output>
|
|
17
|
+
<button type="button" onClick={() => setTab('color')}>
|
|
18
|
+
choose color
|
|
19
|
+
</button>
|
|
20
|
+
</p>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Remountable({ database }: { database: string }) {
|
|
25
|
+
const [generation, setGeneration] = useState(0);
|
|
26
|
+
const [shown, setShown] = useState(true);
|
|
27
|
+
const storage = useState(() => createIndexedDbAdapter({ database }))[0];
|
|
28
|
+
return (
|
|
29
|
+
<div>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={() => {
|
|
33
|
+
setShown(false);
|
|
34
|
+
// Past the provider's deferred close, which sends the queued write.
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
setGeneration((g) => g + 1);
|
|
37
|
+
setShown(true);
|
|
38
|
+
}, 100);
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
remount
|
|
42
|
+
</button>
|
|
43
|
+
{shown ? (
|
|
44
|
+
<Persistence
|
|
45
|
+
key={generation}
|
|
46
|
+
storageKey="story"
|
|
47
|
+
storage={storage}
|
|
48
|
+
fallback={<p>loading</p>}
|
|
49
|
+
>
|
|
50
|
+
<Tab />
|
|
51
|
+
</Persistence>
|
|
52
|
+
) : null}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const meta: Meta<typeof Remountable> = {
|
|
58
|
+
title: 'labkit/State/Persistence',
|
|
59
|
+
component: Remountable,
|
|
60
|
+
};
|
|
61
|
+
export default meta;
|
|
62
|
+
|
|
63
|
+
export const SurvivesARemountInIndexedDb: StoryObj<typeof Remountable> = {
|
|
64
|
+
args: { database: `labkit-story-${Date.now()}` },
|
|
65
|
+
play: async ({ canvasElement }) => {
|
|
66
|
+
const canvas = within(canvasElement);
|
|
67
|
+
await waitFor(() => expect(canvas.getByLabelText('tab').textContent).toBe('shape'));
|
|
68
|
+
await userEvent.click(canvas.getByText('choose color'));
|
|
69
|
+
await userEvent.click(canvas.getByText('remount'));
|
|
70
|
+
await waitFor(() => expect(canvas.queryByLabelText('tab')).toBeNull());
|
|
71
|
+
await waitFor(() => expect(canvas.getByLabelText('tab').textContent).toBe('color'), {
|
|
72
|
+
timeout: 3000,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createContext, type ReactNode } from 'react';
|
|
2
|
+
import { defaultStorage } from './adapters';
|
|
3
|
+
import { labPrefix } from './labRecords';
|
|
4
|
+
import { openRecords, type RecordCache } from './records';
|
|
5
|
+
import type { StorageAdapter } from './types';
|
|
6
|
+
import { useOpenOnce, useWarnIgnoredChange } from './useOpenOnce';
|
|
7
|
+
|
|
8
|
+
/** The records `usePersistedState` reads and writes. `<Lab>`,
|
|
9
|
+
* `SingletonExperimentProvider` and `<Persistence>` provide one. */
|
|
10
|
+
export const PersistenceContext = createContext<RecordCache | null>(null);
|
|
11
|
+
|
|
12
|
+
/** Props for `<Persistence>`. */
|
|
13
|
+
export interface PersistenceProps {
|
|
14
|
+
/** Names the records, as `<Lab storageKey>` does. A lab and a
|
|
15
|
+
* `<Persistence>` given the same key share lab-scoped values. */
|
|
16
|
+
storageKey: string;
|
|
17
|
+
/** Default: IndexedDB, or localStorage where IndexedDB will not open. */
|
|
18
|
+
storage?: StorageAdapter;
|
|
19
|
+
/** Rendered until the records have loaded. Default: nothing. */
|
|
20
|
+
fallback?: ReactNode;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Persisted values for a page with no lab: `usePersistedState` below it keeps
|
|
25
|
+
* what it holds across a reload. Both props are read once, at mount. */
|
|
26
|
+
export function Persistence({ storageKey, storage, fallback = null, children }: PersistenceProps) {
|
|
27
|
+
useWarnIgnoredChange('<Persistence>', { storageKey, storage });
|
|
28
|
+
const records = useOpenOnce(async () =>
|
|
29
|
+
openRecords({ storage: storage ?? (await defaultStorage()), prefix: labPrefix(storageKey) }),
|
|
30
|
+
);
|
|
31
|
+
if (!records) return <>{fallback}</>;
|
|
32
|
+
return <PersistenceContext.Provider value={records}>{children}</PersistenceContext.Provider>;
|
|
33
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { act, render, renderHook } from '@testing-library/react';
|
|
2
|
-
import {
|
|
1
|
+
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import { createMemoryAdapter } from './adapters';
|
|
4
|
-
import {
|
|
4
|
+
import { labPrefix } from './labRecords';
|
|
5
5
|
import { SingletonExperimentProvider } from './SingletonExperiment';
|
|
6
6
|
import { useTrialState } from './useTrialState';
|
|
7
7
|
|
|
@@ -13,31 +13,41 @@ interface State {
|
|
|
13
13
|
zoom: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
function Provider({
|
|
17
|
+
backing,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
backing: Map<string, unknown>;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<SingletonExperimentProvider<State, Config>
|
|
25
|
+
id="test"
|
|
26
|
+
initialConfig={{ width: 100, bg: '#000' }}
|
|
27
|
+
initialState={{ zoom: 1 }}
|
|
28
|
+
storage={createMemoryAdapter(backing)}
|
|
29
|
+
storageKey="test"
|
|
30
|
+
fallback={<p>loading</p>}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</SingletonExperimentProvider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
20
36
|
|
|
21
|
-
|
|
37
|
+
describe('SingletonExperimentProvider', () => {
|
|
38
|
+
it('shows its fallback, then exposes config and state via useTrialState', async () => {
|
|
39
|
+
const backing = new Map<string, unknown>();
|
|
22
40
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
23
|
-
<
|
|
24
|
-
id="test"
|
|
25
|
-
initialConfig={{ width: 100, bg: '#000' }}
|
|
26
|
-
initialState={{ zoom: 1 }}
|
|
27
|
-
storage={createMemoryAdapter()}
|
|
28
|
-
storageKey="test"
|
|
29
|
-
>
|
|
30
|
-
{children}
|
|
31
|
-
</SingletonExperimentProvider>
|
|
41
|
+
<Provider backing={backing}>{children}</Provider>
|
|
32
42
|
);
|
|
33
43
|
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
34
|
-
expect(
|
|
44
|
+
expect(screen.getByText('loading')).toBeInTheDocument();
|
|
45
|
+
await waitFor(() => expect(result.current?.config).toEqual({ width: 100, bg: '#000' }));
|
|
35
46
|
expect(result.current.state).toEqual({ zoom: 1 });
|
|
36
47
|
});
|
|
37
48
|
|
|
38
|
-
it('persists config changes to storage', () => {
|
|
39
|
-
|
|
40
|
-
const storage = createMemoryAdapter();
|
|
49
|
+
it('persists config changes to storage', async () => {
|
|
50
|
+
const backing = new Map<string, unknown>();
|
|
41
51
|
const Probe = () => {
|
|
42
52
|
const h = useTrialState<State, Config>();
|
|
43
53
|
return (
|
|
@@ -46,32 +56,24 @@ describe('SingletonExperimentProvider', () => {
|
|
|
46
56
|
</button>
|
|
47
57
|
);
|
|
48
58
|
};
|
|
49
|
-
|
|
50
|
-
<
|
|
51
|
-
id="test"
|
|
52
|
-
initialConfig={{ width: 100, bg: '#000' }}
|
|
53
|
-
initialState={{ zoom: 1 }}
|
|
54
|
-
storage={storage}
|
|
55
|
-
storageKey="test"
|
|
56
|
-
>
|
|
59
|
+
render(
|
|
60
|
+
<Provider backing={backing}>
|
|
57
61
|
<Probe />
|
|
58
|
-
</
|
|
62
|
+
</Provider>,
|
|
63
|
+
);
|
|
64
|
+
const go = await screen.findByText('go');
|
|
65
|
+
act(() => go.click());
|
|
66
|
+
await waitFor(() =>
|
|
67
|
+
expect(backing.get(`${labPrefix('test')}trial:test`)).toMatchObject({
|
|
68
|
+
config: { width: 200 },
|
|
69
|
+
}),
|
|
59
70
|
);
|
|
60
|
-
act(() => {
|
|
61
|
-
getByText('go').click();
|
|
62
|
-
});
|
|
63
|
-
act(() => {
|
|
64
|
-
vi.advanceTimersByTime(400);
|
|
65
|
-
});
|
|
66
|
-
const raw = storage.read(labDocumentKey('test'));
|
|
67
|
-
expect(raw).toBeTruthy();
|
|
68
|
-
expect(raw).toContain('"width":200');
|
|
69
71
|
});
|
|
70
72
|
|
|
71
|
-
it('rehydrates from storage on mount', () => {
|
|
72
|
-
const
|
|
73
|
-
// A pre-document lab, which
|
|
74
|
-
|
|
73
|
+
it('rehydrates from storage on mount', async () => {
|
|
74
|
+
const backing = new Map<string, unknown>();
|
|
75
|
+
// A pre-document lab, which opening folds forward.
|
|
76
|
+
backing.set(
|
|
75
77
|
'lk:test:workspaces',
|
|
76
78
|
JSON.stringify([
|
|
77
79
|
{
|
|
@@ -84,18 +86,10 @@ describe('SingletonExperimentProvider', () => {
|
|
|
84
86
|
]),
|
|
85
87
|
);
|
|
86
88
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
87
|
-
<
|
|
88
|
-
id="test"
|
|
89
|
-
initialConfig={{ width: 100, bg: '#000' }}
|
|
90
|
-
initialState={{ zoom: 1 }}
|
|
91
|
-
storage={storage}
|
|
92
|
-
storageKey="test"
|
|
93
|
-
>
|
|
94
|
-
{children}
|
|
95
|
-
</SingletonExperimentProvider>
|
|
89
|
+
<Provider backing={backing}>{children}</Provider>
|
|
96
90
|
);
|
|
97
91
|
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
98
|
-
expect(result.current
|
|
92
|
+
await waitFor(() => expect(result.current?.config).toEqual({ width: 999, bg: '#fff' }));
|
|
99
93
|
expect(result.current.state).toEqual({ zoom: 2 });
|
|
100
94
|
});
|
|
101
95
|
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { defaultStorage } from './adapters';
|
|
2
3
|
import { LabStoreProvider, TrialIdProvider } from './context';
|
|
3
|
-
import {
|
|
4
|
+
import { openLabStore } from './openLabStore';
|
|
5
|
+
import { PersistenceContext } from './Persistence';
|
|
4
6
|
import type { StorageAdapter } from './types';
|
|
7
|
+
import { useOpenOnce, useWarnIgnoredChange } from './useOpenOnce';
|
|
5
8
|
|
|
6
9
|
const SINGLETON_INSTRUMENT = '__singleton__';
|
|
7
10
|
|
|
@@ -12,8 +15,11 @@ export interface SingletonExperimentProviderProps<TS, TC> {
|
|
|
12
15
|
id: string;
|
|
13
16
|
initialConfig: TC;
|
|
14
17
|
initialState: TS;
|
|
15
|
-
storage: StorageAdapter;
|
|
16
18
|
storageKey: string;
|
|
19
|
+
/** Default: IndexedDB, or localStorage where IndexedDB will not open. */
|
|
20
|
+
storage?: StorageAdapter;
|
|
21
|
+
/** Rendered until the stored experiment has loaded. Default: nothing. */
|
|
22
|
+
fallback?: ReactNode;
|
|
17
23
|
children: ReactNode;
|
|
18
24
|
}
|
|
19
25
|
|
|
@@ -21,7 +27,8 @@ export interface SingletonExperimentProviderProps<TS, TC> {
|
|
|
21
27
|
* One-trial `<Lab>` substitute for single-screen experiments. Mounts
|
|
22
28
|
* a `LabStoreProvider` + `TrialIdProvider` with one synthetic
|
|
23
29
|
* trial, so `useTrialState` works without going through the
|
|
24
|
-
* full `<Lab instruments={...}>` runtime.
|
|
30
|
+
* full `<Lab instruments={...}>` runtime. `storageKey` and `storage` are read
|
|
31
|
+
* once, at mount.
|
|
25
32
|
*/
|
|
26
33
|
export function SingletonExperimentProvider<TS, TC>({
|
|
27
34
|
id,
|
|
@@ -29,13 +36,14 @@ export function SingletonExperimentProvider<TS, TC>({
|
|
|
29
36
|
initialState,
|
|
30
37
|
storage,
|
|
31
38
|
storageKey,
|
|
39
|
+
fallback = null,
|
|
32
40
|
children,
|
|
33
41
|
}: SingletonExperimentProviderProps<TS, TC>) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
if (!store.getState().trials.some((w) => w.id === id)) {
|
|
38
|
-
store.getState().addTrial({
|
|
42
|
+
useWarnIgnoredChange('<SingletonExperimentProvider>', { storageKey, storage });
|
|
43
|
+
const opened = useOpenOnce(async () => {
|
|
44
|
+
const lab = await openLabStore({ storageKey, storage: storage ?? (await defaultStorage()) });
|
|
45
|
+
if (!lab.store.getState().trials.some((w) => w.id === id)) {
|
|
46
|
+
lab.store.getState().addTrial({
|
|
39
47
|
id,
|
|
40
48
|
instrumentName: SINGLETON_INSTRUMENT,
|
|
41
49
|
config: initialConfig,
|
|
@@ -43,11 +51,14 @@ export function SingletonExperimentProvider<TS, TC>({
|
|
|
43
51
|
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
44
52
|
});
|
|
45
53
|
}
|
|
46
|
-
|
|
47
|
-
}
|
|
54
|
+
return lab;
|
|
55
|
+
});
|
|
56
|
+
if (!opened) return <>{fallback}</>;
|
|
48
57
|
return (
|
|
49
|
-
<LabStoreProvider store={
|
|
50
|
-
<
|
|
58
|
+
<LabStoreProvider store={opened.store}>
|
|
59
|
+
<PersistenceContext.Provider value={opened.records}>
|
|
60
|
+
<TrialIdProvider trialId={id}>{children}</TrialIdProvider>
|
|
61
|
+
</PersistenceContext.Provider>
|
|
51
62
|
</LabStoreProvider>
|
|
52
63
|
);
|
|
53
64
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { StorageAdapter, StorageChange } from './types';
|
|
3
|
+
|
|
4
|
+
/** How the contract suite drives one adapter. */
|
|
5
|
+
export interface AdapterHarness {
|
|
6
|
+
adapter: StorageAdapter;
|
|
7
|
+
/** Whether a `Uint8Array` survives the round trip unchanged. */
|
|
8
|
+
binary: boolean;
|
|
9
|
+
/** Make a change the way another writer would, or omit when the adapter
|
|
10
|
+
* has no `subscribe`. */
|
|
11
|
+
foreign?: {
|
|
12
|
+
set(key: string, value: unknown): Promise<void> | void;
|
|
13
|
+
delete(key: string): Promise<void> | void;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function until(check: () => boolean, ms = 1000): Promise<void> {
|
|
18
|
+
const end = Date.now() + ms;
|
|
19
|
+
while (!check()) {
|
|
20
|
+
if (Date.now() > end) throw new Error('timed out waiting for a change');
|
|
21
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const settle = (): Promise<void> => new Promise((r) => setTimeout(r, 50));
|
|
26
|
+
|
|
27
|
+
/** Every shipped adapter answers to these. `setup` runs once per test. */
|
|
28
|
+
export function describeAdapterContract(name: string, setup: () => AdapterHarness): void {
|
|
29
|
+
describe(`${name} — the adapter contract`, () => {
|
|
30
|
+
it('reads back what it stored, as a copy', async () => {
|
|
31
|
+
const { adapter } = setup();
|
|
32
|
+
const value = { trial: 'a', nested: { n: 1, list: [1, 'two', null] } };
|
|
33
|
+
await adapter.set('lk:x:one', value);
|
|
34
|
+
value.nested.n = 99;
|
|
35
|
+
expect(await adapter.get('lk:x:one')).toEqual({
|
|
36
|
+
trial: 'a',
|
|
37
|
+
nested: { n: 1, list: [1, 'two', null] },
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('answers undefined for a key it never held', async () => {
|
|
42
|
+
const { adapter } = setup();
|
|
43
|
+
expect(await adapter.get('lk:x:absent')).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('lists only the keys under a prefix', async () => {
|
|
47
|
+
const { adapter } = setup();
|
|
48
|
+
await adapter.set('lk:x:a', 1);
|
|
49
|
+
await adapter.set('lk:x:b', 2);
|
|
50
|
+
await adapter.set('lk:y:a', 3);
|
|
51
|
+
const listed = await adapter.list('lk:x:');
|
|
52
|
+
expect(new Map(listed)).toEqual(
|
|
53
|
+
new Map<string, unknown>([
|
|
54
|
+
['lk:x:a', 1],
|
|
55
|
+
['lk:x:b', 2],
|
|
56
|
+
]),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('deletes a key', async () => {
|
|
61
|
+
const { adapter } = setup();
|
|
62
|
+
await adapter.set('lk:x:gone', { a: 1 });
|
|
63
|
+
await adapter.delete('lk:x:gone');
|
|
64
|
+
expect(await adapter.get('lk:x:gone')).toBeUndefined();
|
|
65
|
+
expect(await adapter.list('lk:x:')).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps binary values exactly when it says it can', async () => {
|
|
69
|
+
const { adapter, binary } = setup();
|
|
70
|
+
if (!binary) return;
|
|
71
|
+
await adapter.set('lk:x:bytes', new Uint8Array([1, 2, 255]));
|
|
72
|
+
const back = await adapter.get('lk:x:bytes');
|
|
73
|
+
// Not `toBeInstanceOf`: under jsdom, structuredClone hands back Node's
|
|
74
|
+
// Uint8Array, which is not the test realm's.
|
|
75
|
+
expect(Object.prototype.toString.call(back)).toBe('[object Uint8Array]');
|
|
76
|
+
expect([...(back as Uint8Array)]).toEqual([1, 2, 255]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("reports another writer's changes under its prefix, and nothing else", async () => {
|
|
80
|
+
const { adapter, foreign } = setup();
|
|
81
|
+
if (!foreign) {
|
|
82
|
+
expect(adapter.subscribe).toBeUndefined();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!adapter.subscribe) throw new Error(`${name} has a foreign writer but no subscribe`);
|
|
86
|
+
const heard: StorageChange[] = [];
|
|
87
|
+
const off = adapter.subscribe('lk:x:', (changes) => heard.push(...changes));
|
|
88
|
+
try {
|
|
89
|
+
await adapter.set('lk:x:own', 'mine');
|
|
90
|
+
await foreign.set('lk:y:elsewhere', 'not this prefix');
|
|
91
|
+
await settle();
|
|
92
|
+
expect(heard).toEqual([]);
|
|
93
|
+
|
|
94
|
+
await foreign.set('lk:x:theirs', { from: 'peer' });
|
|
95
|
+
await until(() => heard.length > 0);
|
|
96
|
+
expect(heard).toEqual([['lk:x:theirs', { from: 'peer' }]]);
|
|
97
|
+
|
|
98
|
+
await foreign.delete('lk:x:theirs');
|
|
99
|
+
await until(() => heard.length > 1);
|
|
100
|
+
expect(heard[1]).toEqual(['lk:x:theirs', undefined]);
|
|
101
|
+
} finally {
|
|
102
|
+
off();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('stops reporting once unsubscribed', async () => {
|
|
107
|
+
const { adapter, foreign } = setup();
|
|
108
|
+
if (!foreign || !adapter.subscribe) return;
|
|
109
|
+
const heard: StorageChange[] = [];
|
|
110
|
+
adapter.subscribe('lk:x:', (changes) => heard.push(...changes))();
|
|
111
|
+
await foreign.set('lk:x:late', 1);
|
|
112
|
+
await settle();
|
|
113
|
+
expect(heard).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -1,94 +1,146 @@
|
|
|
1
|
-
import
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { describeAdapterContract } from './adapterContract';
|
|
2
4
|
import {
|
|
5
|
+
createIndexedDbAdapter,
|
|
3
6
|
createMemoryAdapter,
|
|
7
|
+
defaultStorage,
|
|
8
|
+
indexedDbAdapter,
|
|
4
9
|
localStorageAdapter,
|
|
5
10
|
noneAdapter,
|
|
11
|
+
resetDefaultStorage,
|
|
6
12
|
sessionStorageAdapter,
|
|
7
13
|
urlHashAdapter,
|
|
8
14
|
} from './adapters';
|
|
15
|
+
import { decodeUrlHash, encodeUrlHash } from './helpers';
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
describeAdapterContract('createMemoryAdapter', () => {
|
|
18
|
+
const backing = new Map<string, unknown>();
|
|
19
|
+
const peer = createMemoryAdapter(backing);
|
|
20
|
+
return { adapter: createMemoryAdapter(backing), binary: true, foreign: peer };
|
|
21
|
+
});
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
let database = 0;
|
|
24
|
+
describeAdapterContract('createIndexedDbAdapter', () => {
|
|
25
|
+
const name = `contract-${database++}`;
|
|
26
|
+
return {
|
|
27
|
+
adapter: createIndexedDbAdapter({ database: name }),
|
|
28
|
+
binary: true,
|
|
29
|
+
foreign: createIndexedDbAdapter({ database: name }),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
17
32
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
/** jsdom has one window, so no `storage` event ever arrives from another tab.
|
|
34
|
+
* This writes the key and dispatches the event another tab's write would —
|
|
35
|
+
* a stand-in for the browser, not a test of it. */
|
|
36
|
+
function foreignWebStorage(area: Storage) {
|
|
37
|
+
const fire = (key: string, newValue: string | null): void => {
|
|
38
|
+
window.dispatchEvent(new StorageEvent('storage', { key, newValue, storageArea: area }));
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
set: (key: string, value: unknown) => {
|
|
42
|
+
const raw = JSON.stringify(value);
|
|
43
|
+
area.setItem(key, raw);
|
|
44
|
+
fire(key, raw);
|
|
45
|
+
},
|
|
46
|
+
delete: (key: string) => {
|
|
47
|
+
area.removeItem(key);
|
|
48
|
+
fire(key, null);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
21
52
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
});
|
|
53
|
+
describeAdapterContract('localStorageAdapter', () => {
|
|
54
|
+
localStorage.clear();
|
|
55
|
+
return { adapter: localStorageAdapter, binary: false, foreign: foreignWebStorage(localStorage) };
|
|
56
|
+
});
|
|
27
57
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
33
|
-
expect(() => localStorageAdapter.write('k', 'v')).not.toThrow();
|
|
34
|
-
expect(warn).toHaveBeenCalled();
|
|
35
|
-
warn.mockRestore();
|
|
36
|
-
});
|
|
58
|
+
describeAdapterContract('sessionStorageAdapter', () => {
|
|
59
|
+
sessionStorage.clear();
|
|
60
|
+
return { adapter: sessionStorageAdapter, binary: false };
|
|
37
61
|
});
|
|
38
62
|
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
function foreignHash() {
|
|
64
|
+
const edit = (change: (map: Record<string, string>) => void): void => {
|
|
65
|
+
const raw = decodeUrlHash(window.location.hash.replace(/^#/, ''));
|
|
66
|
+
const map = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
|
67
|
+
change(map);
|
|
68
|
+
window.location.hash = encodeUrlHash(JSON.stringify(map));
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
set: (key: string, value: unknown) =>
|
|
72
|
+
edit((map) => {
|
|
73
|
+
map[key] = JSON.stringify(value);
|
|
74
|
+
}),
|
|
75
|
+
delete: (key: string) =>
|
|
76
|
+
edit((map) => {
|
|
77
|
+
delete map[key];
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
41
81
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
82
|
+
describeAdapterContract('urlHashAdapter', () => {
|
|
83
|
+
window.history.replaceState(null, '', '#');
|
|
84
|
+
return { adapter: urlHashAdapter, binary: false, foreign: foreignHash() };
|
|
46
85
|
});
|
|
47
86
|
|
|
48
|
-
describe('
|
|
49
|
-
beforeEach(() =>
|
|
50
|
-
window.location.hash = '';
|
|
51
|
-
});
|
|
87
|
+
describe('localStorageAdapter', () => {
|
|
88
|
+
beforeEach(() => localStorage.clear());
|
|
52
89
|
|
|
53
|
-
it('
|
|
54
|
-
|
|
55
|
-
|
|
90
|
+
it('reads a value stored before records existed, raw or JSON', async () => {
|
|
91
|
+
localStorage.setItem('lk:old:theme', 'dark');
|
|
92
|
+
localStorage.setItem('lk:old:doc', '{"version":3}');
|
|
93
|
+
expect(await localStorageAdapter.get('lk:old:theme')).toBe('dark');
|
|
94
|
+
expect(await localStorageAdapter.get('lk:old:doc')).toEqual({ version: 3 });
|
|
56
95
|
});
|
|
57
96
|
|
|
58
|
-
it('
|
|
59
|
-
|
|
97
|
+
it('rejects a write that does not land', async () => {
|
|
98
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => {
|
|
99
|
+
throw new DOMException('QuotaExceeded', 'QuotaExceededError');
|
|
100
|
+
});
|
|
101
|
+
await expect(localStorageAdapter.set('k', 'v')).rejects.toThrow('QuotaExceeded');
|
|
60
102
|
});
|
|
61
103
|
|
|
62
|
-
it('
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
104
|
+
it('warns once per key when JSON would change the value, and stores what JSON keeps', async () => {
|
|
105
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
106
|
+
await localStorageAdapter.set('lk:w:a', { at: new Set([1]) });
|
|
107
|
+
await localStorageAdapter.set('lk:w:a', { at: new Set([2]) });
|
|
108
|
+
await localStorageAdapter.set('lk:w:b', { fine: [1, 'x', null], skipped: undefined });
|
|
109
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(await localStorageAdapter.get('lk:w:a')).toEqual({ at: {} });
|
|
111
|
+
warn.mockRestore();
|
|
67
112
|
});
|
|
68
113
|
});
|
|
69
114
|
|
|
70
|
-
describe('
|
|
71
|
-
it('
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
expect(b.read('k')).toBeNull();
|
|
115
|
+
describe('noneAdapter', () => {
|
|
116
|
+
it('stores nothing', async () => {
|
|
117
|
+
await noneAdapter.set('k', 'v');
|
|
118
|
+
expect(await noneAdapter.get('k')).toBeUndefined();
|
|
119
|
+
expect(await noneAdapter.list('')).toEqual([]);
|
|
76
120
|
});
|
|
121
|
+
});
|
|
77
122
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(m.read('k')).toBeNull();
|
|
123
|
+
describe('defaultStorage', () => {
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
resetDefaultStorage();
|
|
126
|
+
vi.unstubAllGlobals();
|
|
83
127
|
});
|
|
84
|
-
});
|
|
85
128
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
expect(noneAdapter.read('anything')).toBeNull();
|
|
129
|
+
it('is IndexedDB where it opens', async () => {
|
|
130
|
+
expect(await defaultStorage()).toBe(indexedDbAdapter);
|
|
89
131
|
});
|
|
90
132
|
|
|
91
|
-
it('
|
|
92
|
-
|
|
133
|
+
it('falls back to localStorage, with a warning, where it will not', async () => {
|
|
134
|
+
vi.stubGlobal('indexedDB', undefined);
|
|
135
|
+
// The shared adapter may already hold an open database from the case above.
|
|
136
|
+
vi.resetModules();
|
|
137
|
+
const fresh = await import('./adapters');
|
|
138
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
139
|
+
expect(await fresh.defaultStorage()).toBe(fresh.localStorageAdapter);
|
|
140
|
+
expect(warn).toHaveBeenCalledWith(
|
|
141
|
+
expect.stringContaining('IndexedDB would not open'),
|
|
142
|
+
expect.anything(),
|
|
143
|
+
);
|
|
144
|
+
warn.mockRestore();
|
|
93
145
|
});
|
|
94
146
|
});
|