@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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { configMigrationsOf } from './serializers';
|
|
3
|
+
import type { InstrumentList } from './types';
|
|
4
|
+
|
|
5
|
+
const instrument = (name: string, extra: Record<string, unknown> = {}) => ({
|
|
6
|
+
name,
|
|
7
|
+
defaultConfig: () => ({}),
|
|
8
|
+
initialState: () => ({}),
|
|
9
|
+
render: () => null,
|
|
10
|
+
...extra,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('configMigrationsOf', () => {
|
|
14
|
+
it('keys each declared migration by instrument name, and skips the rest', () => {
|
|
15
|
+
const migrations = configMigrationsOf([
|
|
16
|
+
instrument('moves', { migrateConfig: (c: unknown) => ({ moved: c }) }),
|
|
17
|
+
instrument('stays'),
|
|
18
|
+
] as InstrumentList);
|
|
19
|
+
expect(Object.keys(migrations)).toEqual(['moves']);
|
|
20
|
+
expect(migrations.moves?.({ a: 1 })).toEqual({ moved: { a: 1 } });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -12,6 +12,17 @@ export function configDefaultsOf(instruments: InstrumentList): Record<string, ()
|
|
|
12
12
|
return out;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Each instrument's `migrateConfig`, keyed by name, for those that declare one. */
|
|
16
|
+
export function configMigrationsOf(
|
|
17
|
+
instruments: InstrumentList,
|
|
18
|
+
): Record<string, (stored: unknown) => unknown> {
|
|
19
|
+
const out: Record<string, (stored: unknown) => unknown> = {};
|
|
20
|
+
for (const { name, migrateConfig } of instruments) {
|
|
21
|
+
if (migrateConfig) out[name] = (stored) => migrateConfig(stored);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
/**
|
|
16
27
|
* The `serialize` / `deserialize` an instrument list declares, keyed by
|
|
17
28
|
* instrument name — what `createLabStore` needs to rebuild a trial's state
|
package/src/instrument/types.ts
CHANGED
|
@@ -67,6 +67,19 @@ export interface CanvasCapability<TS = unknown, TC = unknown> {
|
|
|
67
67
|
maxZoom?: number;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Declares that an instrument's DOM is content of a fixed size, which the
|
|
71
|
+
* trial pans and zooms the way it does a canvas's layers. Not combined with
|
|
72
|
+
* `canvas`: there the DOM is an unzoomed overlay, and `canvas` wins. */
|
|
73
|
+
export interface StageCapability {
|
|
74
|
+
/** The content's own size in CSS pixels, at zoom 1. */
|
|
75
|
+
size: ViewportSize;
|
|
76
|
+
/** Where the view starts. Omitted, the content opens centered and shrunk to
|
|
77
|
+
* fit (`fitStage`). A function is called once the stage knows its size. */
|
|
78
|
+
initialView?: ViewTransform | ((viewport: ViewportSize) => ViewTransform);
|
|
79
|
+
minZoom?: number;
|
|
80
|
+
maxZoom?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
/** Declares which of an instrument's layers the trial should offer
|
|
71
84
|
* show/hide controls for. */
|
|
72
85
|
export interface LayerCapability {
|
|
@@ -99,7 +112,8 @@ export type SystemEvent = string;
|
|
|
99
112
|
export type Point = { x: number; y: number };
|
|
100
113
|
/** What a hit-test found, and where. */
|
|
101
114
|
export type HitResult = { hit: boolean; layerId?: string; pointId?: string };
|
|
102
|
-
/** A trial's camera.
|
|
115
|
+
/** A trial's camera. `zoom` is always positive and finite once labkit holds
|
|
116
|
+
* it — see `normalize2DView`. */
|
|
103
117
|
export type ViewTransform = { zoom: number; pan: Point };
|
|
104
118
|
/** A layer as the layer list shows it. `alwaysOn` layers cannot be hidden. */
|
|
105
119
|
export type LayerDescriptor = { id: string; label: string; alwaysOn?: boolean };
|
|
@@ -119,7 +133,10 @@ export type DragFeedback = { ok: boolean; reason?: string };
|
|
|
119
133
|
* is what makes the trial provide the corresponding chrome.
|
|
120
134
|
*/
|
|
121
135
|
export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
|
|
136
|
+
/** The id a trial record names its instrument by. */
|
|
122
137
|
name: string;
|
|
138
|
+
/** What a trial of this instrument and the add-trial menu read. Default: `name`. */
|
|
139
|
+
title?: string;
|
|
123
140
|
defaultConfig: () => TC;
|
|
124
141
|
initialState: (config: TC) => TS;
|
|
125
142
|
/** The instrument's config, declared once: values, types and controls.
|
|
@@ -133,9 +150,16 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
|
|
|
133
150
|
* layers rather than instead of them; return `null` for canvas only. */
|
|
134
151
|
render: (ctx: RenderContext<TS, TC>) => ReactNode;
|
|
135
152
|
onConfigChange?: (config: TC, prev: TC, state: TS) => TS;
|
|
153
|
+
/** Moves a stored config's values to where the current schema keeps them —
|
|
154
|
+
* `gridSize` to `grid.size` — before the defaults fill its gaps, so it
|
|
155
|
+
* returns only what it moved and leaves the rest to them. It runs on every
|
|
156
|
+
* read of a stored config, including one already moved, so it must hand
|
|
157
|
+
* back a current config unchanged. */
|
|
158
|
+
migrateConfig?: (stored: unknown) => unknown;
|
|
136
159
|
serialize?: (state: TS) => unknown;
|
|
137
160
|
deserialize?: (data: unknown, config: TC) => TS;
|
|
138
161
|
canvas?: CanvasCapability<TS, TC>;
|
|
162
|
+
stage?: StageCapability;
|
|
139
163
|
layers?: LayerCapability;
|
|
140
164
|
dragDrop?: DragDropCapability<TS, TC>;
|
|
141
165
|
undo?: UndoCapability;
|
package/src/lab/Lab.less
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// mounts no lab is untouched, and stopping at the lab's own parent so an
|
|
6
|
-
// embedded lab cannot resize its host's layout.
|
|
7
|
-
html:has(.lk-lab),
|
|
8
|
-
body:has(.lk-lab) {
|
|
1
|
+
// A fullscreen lab's page is the viewport, and body's default margin would
|
|
2
|
+
// scroll it. `<LabShell>` alone fills the viewport too. `:has` leaves a page
|
|
3
|
+
// that mounts neither untouched.
|
|
4
|
+
body:has(.lk-lab, .lk-shell) {
|
|
9
5
|
margin: 0;
|
|
10
|
-
height: 100%;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
body:has(.lk-lab) > :has(> .lk-lab) {
|
|
14
|
-
height: 100%;
|
|
15
6
|
}
|
|
16
7
|
|
|
17
8
|
.lk-lab {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
2
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { StrictMode } from 'react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import type { Instrument } from '../instrument/types';
|
|
6
|
+
import { createMemoryAdapter, indexedDbAdapter, resetDefaultStorage } from '../state/adapters';
|
|
7
|
+
import { labPrefix } from '../state/labRecords';
|
|
8
|
+
import type { StorageAdapter } from '../state/types';
|
|
9
|
+
import { Lab } from './Lab';
|
|
10
|
+
import { LabContext, type LabContextValue } from './LabContext';
|
|
11
|
+
|
|
12
|
+
const stub: Instrument = {
|
|
13
|
+
name: 'Stub',
|
|
14
|
+
defaultConfig: () => ({}),
|
|
15
|
+
initialState: () => ({}),
|
|
16
|
+
render: () => <div data-testid="stub-content">stub</div>,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let labRef: LabContextValue | null = null;
|
|
20
|
+
function CaptureLab() {
|
|
21
|
+
return (
|
|
22
|
+
<LabContext.Consumer>
|
|
23
|
+
{(value) => {
|
|
24
|
+
labRef = value;
|
|
25
|
+
return null;
|
|
26
|
+
}}
|
|
27
|
+
</LabContext.Consumer>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A memory adapter whose first `list` waits until released. */
|
|
32
|
+
function gated() {
|
|
33
|
+
const memory = createMemoryAdapter();
|
|
34
|
+
let release = (): void => {};
|
|
35
|
+
const opened = new Promise<void>((resolve) => {
|
|
36
|
+
release = resolve;
|
|
37
|
+
});
|
|
38
|
+
const storage: StorageAdapter = {
|
|
39
|
+
...memory,
|
|
40
|
+
list: async (prefix) => {
|
|
41
|
+
await opened;
|
|
42
|
+
return memory.list(prefix);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return { storage, release };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
resetDefaultStorage();
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('<Lab> with a storageKey', () => {
|
|
54
|
+
it('renders its fallback until the lab has loaded', async () => {
|
|
55
|
+
const { storage, release } = gated();
|
|
56
|
+
render(
|
|
57
|
+
<Lab
|
|
58
|
+
instruments={[stub]}
|
|
59
|
+
defaultInstrument="Stub"
|
|
60
|
+
storageKey="gate"
|
|
61
|
+
storage={storage}
|
|
62
|
+
fallback={<p>loading</p>}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByText('loading')).toBeInTheDocument();
|
|
66
|
+
expect(screen.queryByTestId('stub-content')).toBeNull();
|
|
67
|
+
await act(async () => release());
|
|
68
|
+
expect(await screen.findByTestId('stub-content')).toBeInTheDocument();
|
|
69
|
+
expect(screen.queryByText('loading')).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("falls back to the lab's empty shell by default", () => {
|
|
73
|
+
const { storage } = gated();
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<Lab
|
|
76
|
+
instruments={[stub]}
|
|
77
|
+
defaultInstrument="Stub"
|
|
78
|
+
storageKey="gate"
|
|
79
|
+
storage={storage}
|
|
80
|
+
title="Waiting"
|
|
81
|
+
/>,
|
|
82
|
+
);
|
|
83
|
+
expect(container.querySelector('.lk-lab .lk-shell')).not.toBeNull();
|
|
84
|
+
expect(screen.getByText('Waiting')).toBeInTheDocument();
|
|
85
|
+
expect(screen.queryByRole('region', { name: /trial/i })).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('opens the lab once under StrictMode', async () => {
|
|
89
|
+
const storage = createMemoryAdapter();
|
|
90
|
+
const list = vi.spyOn(storage, 'list');
|
|
91
|
+
render(
|
|
92
|
+
<StrictMode>
|
|
93
|
+
<Lab instruments={[stub]} defaultInstrument="Stub" storageKey="strict" storage={storage} />
|
|
94
|
+
</StrictMode>,
|
|
95
|
+
);
|
|
96
|
+
await screen.findByTestId('stub-content');
|
|
97
|
+
expect(list).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('persists to IndexedDB when given no storage', async () => {
|
|
101
|
+
const view = render(
|
|
102
|
+
<Lab instruments={[stub]} defaultInstrument="Stub" storageKey="idb-lab">
|
|
103
|
+
<CaptureLab />
|
|
104
|
+
</Lab>,
|
|
105
|
+
);
|
|
106
|
+
await screen.findByTestId('stub-content');
|
|
107
|
+
act(() => labRef?.setMode('light'));
|
|
108
|
+
view.unmount();
|
|
109
|
+
await waitFor(async () => {
|
|
110
|
+
const records = new Map(await indexedDbAdapter.list(labPrefix('idb-lab')));
|
|
111
|
+
expect(records.get(`${labPrefix('idb-lab')}meta`)).toMatchObject({ mode: 'light' });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('warns when its storageKey changes after mount, and keeps the first', async () => {
|
|
116
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
117
|
+
const storage = createMemoryAdapter();
|
|
118
|
+
const view = render(
|
|
119
|
+
<Lab instruments={[stub]} defaultInstrument="Stub" storageKey="one" storage={storage} />,
|
|
120
|
+
);
|
|
121
|
+
await screen.findByTestId('stub-content');
|
|
122
|
+
view.rerender(
|
|
123
|
+
<Lab instruments={[stub]} defaultInstrument="Stub" storageKey="two" storage={storage} />,
|
|
124
|
+
);
|
|
125
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('`storageKey`'));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('closes the lab on unmount, sending what was queued', async () => {
|
|
129
|
+
const backing = new Map<string, unknown>();
|
|
130
|
+
const view = render(
|
|
131
|
+
<Lab
|
|
132
|
+
instruments={[stub]}
|
|
133
|
+
defaultInstrument="Stub"
|
|
134
|
+
storageKey="close"
|
|
135
|
+
storage={createMemoryAdapter(backing)}
|
|
136
|
+
>
|
|
137
|
+
<CaptureLab />
|
|
138
|
+
</Lab>,
|
|
139
|
+
);
|
|
140
|
+
await screen.findByTestId('stub-content');
|
|
141
|
+
act(() => labRef?.setMode('dark'));
|
|
142
|
+
view.unmount();
|
|
143
|
+
await waitFor(() =>
|
|
144
|
+
expect(backing.get(`${labPrefix('close')}meta`)).toMatchObject({ mode: 'dark' }),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/lab/Lab.stories.tsx
CHANGED
|
@@ -27,7 +27,6 @@ export const Default: Story = {
|
|
|
27
27
|
instruments: [StubInstrument],
|
|
28
28
|
defaultInstrument: 'Stub',
|
|
29
29
|
title: 'Default Lab',
|
|
30
|
-
storage: null,
|
|
31
30
|
},
|
|
32
31
|
};
|
|
33
32
|
|
|
@@ -44,7 +43,6 @@ export const TwoTrials: Story = {
|
|
|
44
43
|
instruments: [StubInstrument],
|
|
45
44
|
defaultInstrument: 'Stub',
|
|
46
45
|
title: 'Two Trials',
|
|
47
|
-
storage: null,
|
|
48
46
|
children: <AddSecondTrial />,
|
|
49
47
|
},
|
|
50
48
|
};
|
|
@@ -12,7 +12,7 @@ import type { ReactNode } from 'react';
|
|
|
12
12
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { defineInstrument } from '../instrument/defineInstrument';
|
|
14
14
|
import { SurfaceContext } from '../surface/SurfaceContext';
|
|
15
|
-
import { useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
|
|
15
|
+
import { useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
|
|
16
16
|
import type { SurfaceFrame, SurfaceHandle } from '../surface/useTiledSurface';
|
|
17
17
|
import { useTiledSurface } from '../surface/useTiledSurface';
|
|
18
18
|
import { Lab } from './Lab';
|
|
@@ -43,9 +43,21 @@ let seen: SurfaceHandle | null | 'not-rendered' = 'not-rendered';
|
|
|
43
43
|
* trial is scoped by it, and the trial's id is minted by the store. */
|
|
44
44
|
let paneTileId = 'pane';
|
|
45
45
|
|
|
46
|
+
interface SeenCanvases {
|
|
47
|
+
over: HTMLCanvasElement | null;
|
|
48
|
+
under: HTMLCanvasElement | null;
|
|
49
|
+
byDefault: HTMLCanvasElement | null;
|
|
50
|
+
}
|
|
51
|
+
let seenCanvases: SeenCanvases = { over: null, under: null, byDefault: null };
|
|
52
|
+
|
|
46
53
|
function Probe() {
|
|
47
54
|
seen = useSurfaceOptional();
|
|
48
55
|
paneTileId = useTileId('pane');
|
|
56
|
+
seenCanvases = {
|
|
57
|
+
over: useSurfaceCanvas('over'),
|
|
58
|
+
under: useSurfaceCanvas('under'),
|
|
59
|
+
byDefault: useSurfaceCanvas(),
|
|
60
|
+
};
|
|
49
61
|
const tile = useSurfaceTile('pane');
|
|
50
62
|
return <div data-testid="pane" ref={tile} />;
|
|
51
63
|
}
|
|
@@ -66,10 +78,34 @@ describe('<Lab> surface provider', () => {
|
|
|
66
78
|
expect(typeof (seen as unknown as SurfaceHandle).registerTile).toBe('function');
|
|
67
79
|
});
|
|
68
80
|
|
|
69
|
-
it('
|
|
81
|
+
it('stacks an under and an over buffer inside the lab body', () => {
|
|
70
82
|
const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
|
|
71
83
|
const canvases = container.querySelectorAll('.lk-lab__body > canvas.lk-lab__surface');
|
|
72
|
-
expect(canvases).toHaveLength(
|
|
84
|
+
expect(canvases).toHaveLength(2);
|
|
85
|
+
// Source order, because the z-index that separates them is in a stylesheet
|
|
86
|
+
// jsdom does not resolve — this is a proxy for the stacking, not proof of
|
|
87
|
+
// it. The real check is a browser.
|
|
88
|
+
expect([...canvases].map((c) => c.className)).toEqual([
|
|
89
|
+
'lk-lab__surface lk-lab__surface--under',
|
|
90
|
+
'lk-lab__surface lk-lab__surface--over',
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('hands a tenant whichever of the two it asks for', () => {
|
|
95
|
+
const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
|
|
96
|
+
const [underEl, overEl] = [...container.querySelectorAll('canvas.lk-lab__surface')];
|
|
97
|
+
expect(seenCanvases.over).toBe(overEl);
|
|
98
|
+
expect(seenCanvases.under).toBe(underEl);
|
|
99
|
+
expect(overEl).not.toBe(underEl);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('defaults a tenant to the buffer over the trials', () => {
|
|
103
|
+
// Every tenant predating the split wants the over one: the surface was
|
|
104
|
+
// built for marks that annotate an instrument.
|
|
105
|
+
const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
|
|
106
|
+
const [underEl, overEl] = [...container.querySelectorAll('canvas.lk-lab__surface')];
|
|
107
|
+
expect(seenCanvases.byDefault).toBe(overEl);
|
|
108
|
+
expect(seenCanvases.byDefault).not.toBe(underEl);
|
|
73
109
|
});
|
|
74
110
|
|
|
75
111
|
it('mounts no buffer of its own when a host already owns the surface', () => {
|
|
@@ -165,3 +201,30 @@ describe('<Lab> tile round trip', () => {
|
|
|
165
201
|
expect(rect).toEqual({ x: 240, y: 100, w: 320, h: 200 });
|
|
166
202
|
});
|
|
167
203
|
});
|
|
204
|
+
|
|
205
|
+
describe('<Lab> pages', () => {
|
|
206
|
+
it('gives the title a way back to the project\'s other labs', () => {
|
|
207
|
+
const { getByRole } = render(
|
|
208
|
+
<Lab
|
|
209
|
+
instruments={[probeInstrument]}
|
|
210
|
+
defaultInstrument="Probe"
|
|
211
|
+
title="kliegsminister"
|
|
212
|
+
path="/kliegsminister"
|
|
213
|
+
pages={[
|
|
214
|
+
{ href: '/kliegsminister', label: 'kliegsminister' },
|
|
215
|
+
{ href: '/stats', label: 'corpus stats' },
|
|
216
|
+
]}
|
|
217
|
+
/>,
|
|
218
|
+
);
|
|
219
|
+
// A switcher, not a bare heading: the lab it came from has to be reachable.
|
|
220
|
+
expect(getByRole('button', { name: /kliegsminister/ })).toBeTruthy();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('leaves the title a plain heading when there are no other labs', () => {
|
|
224
|
+
const { getByRole, queryByRole } = render(
|
|
225
|
+
<Lab instruments={[probeInstrument]} defaultInstrument="Probe" title="solo" />,
|
|
226
|
+
);
|
|
227
|
+
expect(getByRole('heading', { name: 'solo' })).toBeTruthy();
|
|
228
|
+
expect(queryByRole('button', { name: /solo/ })).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
});
|
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { act, render, screen } from '@testing-library/react';
|
|
2
|
-
import type
|
|
3
|
-
import { describe, expect, it
|
|
1
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { type ReactNode, useEffect } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import type { Instrument } from '../instrument/types';
|
|
5
5
|
import { createMemoryAdapter } from '../state/adapters';
|
|
6
|
-
import {
|
|
7
|
-
import { Lab } from './Lab';
|
|
6
|
+
import { Lab, type LabProps } from './Lab';
|
|
8
7
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
9
8
|
|
|
10
9
|
const stub: Instrument = {
|
|
@@ -33,10 +32,10 @@ function CaptureLab({ children }: { children?: ReactNode }) {
|
|
|
33
32
|
);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
function mountLab(props: Partial<
|
|
35
|
+
function mountLab(props: Partial<Record<keyof LabProps, unknown>> = {}) {
|
|
37
36
|
labRef = null;
|
|
38
37
|
return render(
|
|
39
|
-
<Lab instruments
|
|
38
|
+
<Lab {...({ instruments: [stub, stubB], defaultInstrument: 'Stub', ...props } as LabProps)}>
|
|
40
39
|
<CaptureLab />
|
|
41
40
|
</Lab>,
|
|
42
41
|
);
|
|
@@ -142,9 +141,8 @@ describe('<Lab>', () => {
|
|
|
142
141
|
// the only thing positioned to collect them off the instruments. They went
|
|
143
142
|
// uncollected for months, and an instrument holding anything JSON drops
|
|
144
143
|
// lost it with no error.
|
|
145
|
-
it('gives the store the serializers its instruments declare', () => {
|
|
146
|
-
|
|
147
|
-
const storage = createMemoryAdapter();
|
|
144
|
+
it('gives the store the serializers its instruments declare', async () => {
|
|
145
|
+
const backing = new Map<string, unknown>();
|
|
148
146
|
const mapped: Instrument = {
|
|
149
147
|
...stub,
|
|
150
148
|
name: 'Mapped',
|
|
@@ -152,15 +150,91 @@ describe('<Lab>', () => {
|
|
|
152
150
|
serialize: (state) => ({ seen: [...(state as { seen: Set<string> }).seen] }),
|
|
153
151
|
deserialize: (data) => ({ seen: new Set((data as { seen: string[] }).seen) }),
|
|
154
152
|
};
|
|
153
|
+
const storage = () => createMemoryAdapter(backing);
|
|
155
154
|
|
|
156
|
-
render(
|
|
157
|
-
<Lab instruments={[mapped]} defaultInstrument="Mapped" storage={storage} storageKey="s" />,
|
|
155
|
+
const first = render(
|
|
156
|
+
<Lab instruments={[mapped]} defaultInstrument="Mapped" storage={storage()} storageKey="s" />,
|
|
158
157
|
);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
158
|
+
await waitFor(() => expect(JSON.stringify([...backing.values()])).toContain('"seen":["a"]'));
|
|
159
|
+
first.unmount();
|
|
160
|
+
|
|
161
|
+
mountLab({
|
|
162
|
+
instruments: [mapped],
|
|
163
|
+
defaultInstrument: 'Mapped',
|
|
164
|
+
storage: storage(),
|
|
165
|
+
storageKey: 's',
|
|
166
|
+
});
|
|
167
|
+
await waitFor(() => expect(labRef?.trials[0]).toBeDefined());
|
|
164
168
|
expect((labRef?.trials[0]?.state as { seen: Set<string> }).seen).toEqual(new Set(['a']));
|
|
165
169
|
});
|
|
166
170
|
});
|
|
171
|
+
|
|
172
|
+
describe('instruments that change while mounted', () => {
|
|
173
|
+
it('fills an open trial’s config before its replaced instrument renders', () => {
|
|
174
|
+
const v1: Instrument = {
|
|
175
|
+
name: 'Grow',
|
|
176
|
+
defaultConfig: () => ({ size: 1 }),
|
|
177
|
+
initialState: () => ({}),
|
|
178
|
+
render: () => null,
|
|
179
|
+
};
|
|
180
|
+
const seenByV2: Array<{ color?: string }> = [];
|
|
181
|
+
const v2: Instrument = {
|
|
182
|
+
...v1,
|
|
183
|
+
defaultConfig: () => ({ size: 1, color: 'red' }),
|
|
184
|
+
render: ({ config }) => {
|
|
185
|
+
seenByV2.push(config as { color?: string });
|
|
186
|
+
return null;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const view = render(
|
|
190
|
+
<Lab instruments={[v1]} defaultInstrument="Grow">
|
|
191
|
+
<CaptureLab />
|
|
192
|
+
</Lab>,
|
|
193
|
+
);
|
|
194
|
+
view.rerender(
|
|
195
|
+
<Lab instruments={[v2]} defaultInstrument="Grow">
|
|
196
|
+
<CaptureLab />
|
|
197
|
+
</Lab>,
|
|
198
|
+
);
|
|
199
|
+
expect(labRef?.trials[0]?.config).toEqual({ size: 1, color: 'red' });
|
|
200
|
+
expect(seenByV2.length).toBeGreaterThan(0);
|
|
201
|
+
expect(seenByV2.every((c) => c.color === 'red')).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('opens a trial of an instrument added after mount', () => {
|
|
205
|
+
const later: Instrument = { ...stub, name: 'Later' };
|
|
206
|
+
const view = render(
|
|
207
|
+
<Lab instruments={[stub]} defaultInstrument="Stub">
|
|
208
|
+
<CaptureLab />
|
|
209
|
+
</Lab>,
|
|
210
|
+
);
|
|
211
|
+
view.rerender(
|
|
212
|
+
<Lab instruments={[stub, later]} defaultInstrument="Stub">
|
|
213
|
+
<CaptureLab />
|
|
214
|
+
</Lab>,
|
|
215
|
+
);
|
|
216
|
+
act(() => labRef?.addTrial('Later'));
|
|
217
|
+
expect(labRef?.trials.map((t) => t.instrumentName)).toEqual(['Stub', 'Later']);
|
|
218
|
+
expect(screen.queryByText(/Unknown instrument/)).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('keeps a trial’s content mounted across an instrument swap', () => {
|
|
222
|
+
let mounts = 0;
|
|
223
|
+
function Probe() {
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
mounts += 1;
|
|
226
|
+
}, []);
|
|
227
|
+
return <p>probe</p>;
|
|
228
|
+
}
|
|
229
|
+
const v1: Instrument = {
|
|
230
|
+
name: 'Keep',
|
|
231
|
+
defaultConfig: () => ({}),
|
|
232
|
+
initialState: () => ({}),
|
|
233
|
+
render: () => <Probe />,
|
|
234
|
+
};
|
|
235
|
+
const v2: Instrument = { ...v1, defaultConfig: () => ({ extra: 1 }) };
|
|
236
|
+
const view = render(<Lab instruments={[v1]} defaultInstrument="Keep" />);
|
|
237
|
+
view.rerender(<Lab instruments={[v2]} defaultInstrument="Keep" />);
|
|
238
|
+
expect(mounts).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
});
|