@weasel-js/labkit 1.1.0 → 1.3.0-pre.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 +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
- package/dist/canvas/index.d.ts +8 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/chunk-HFDVGF4X.js +170 -0
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +5 -2
- package/dist/index.d.ts +307 -107
- package/dist/index.js +797 -540
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3804 -3661
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +543 -144
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +4 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +5 -2
- package/package.json +24 -7
- package/src/canvas/AGENTS.md +12 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.test.ts +18 -0
- package/src/index.ts +38 -3
- package/src/instrument/SineWave.smoke.test.tsx +9 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +38 -3
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.stories.tsx +1 -2
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +19 -9
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +16 -2
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +8 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +6 -0
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +161 -0
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +73 -0
- package/src/trial/Trial.less +121 -4
- package/src/trial/Trial.stories.tsx +9 -1
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +112 -45
- package/src/trial/TrialChrome.tsx +110 -64
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js +0 -5986
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
- package/dist/chunk-NRKWVTVT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -53
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TrialContribution } from '../chrome/types';
|
|
4
|
+
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { Lab } from './Lab';
|
|
6
|
+
|
|
7
|
+
const Glyph = () => <svg />;
|
|
8
|
+
const bare: Instrument = {
|
|
9
|
+
name: 'Bare',
|
|
10
|
+
defaultConfig: () => ({}),
|
|
11
|
+
initialState: () => ({}),
|
|
12
|
+
render: () => null,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** React logs the error it re-throws from a failed render; the assertions are
|
|
16
|
+
* on the throw itself, so the console noise is not informative. */
|
|
17
|
+
function silenceRenderError(): void {
|
|
18
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('<Lab> chrome', () => {
|
|
22
|
+
it('renders a consumer contribution', () => {
|
|
23
|
+
const extra: TrialContribution = {
|
|
24
|
+
id: 'export',
|
|
25
|
+
region: 'toolbar',
|
|
26
|
+
item: { icon: Glyph, label: 'Export', onActivate: () => {} },
|
|
27
|
+
};
|
|
28
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[extra]} />);
|
|
29
|
+
expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('suppresses a built-in by id', () => {
|
|
33
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['snapshot']} />);
|
|
34
|
+
expect(screen.queryByRole('button', { name: 'Save snapshot' })).toBeNull();
|
|
35
|
+
expect(screen.getByRole('button', { name: 'Clone trial' })).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('throws when a consumer id collides with a built-in', () => {
|
|
39
|
+
silenceRenderError();
|
|
40
|
+
const clash: TrialContribution = {
|
|
41
|
+
id: 'clone',
|
|
42
|
+
region: 'toolbar',
|
|
43
|
+
item: { icon: Glyph, label: 'Mine', onActivate: () => {} },
|
|
44
|
+
};
|
|
45
|
+
expect(() =>
|
|
46
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[clash]} />),
|
|
47
|
+
).toThrow(/duplicate contribution id "clone"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('throws when suppressing an id that is not there', () => {
|
|
51
|
+
silenceRenderError();
|
|
52
|
+
expect(() =>
|
|
53
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['nope']} />),
|
|
54
|
+
).toThrow(/cannot suppress "nope"/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('renders an instrument-declared contribution', () => {
|
|
58
|
+
const withChrome: Instrument = {
|
|
59
|
+
...bare,
|
|
60
|
+
chrome: [{ id: 'mine', region: 'status', item: { text: 'ready' } }],
|
|
61
|
+
};
|
|
62
|
+
render(<Lab title="T" instruments={[withChrome]} defaultInstrument="Bare" />);
|
|
63
|
+
expect(screen.getByText('ready')).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/lab/Lab.stories.tsx
CHANGED
|
@@ -33,10 +33,9 @@ export const Default: Story = {
|
|
|
33
33
|
|
|
34
34
|
function AddSecondTrial() {
|
|
35
35
|
const ctx = useLabContext();
|
|
36
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
|
|
37
36
|
useEffect(() => {
|
|
38
37
|
if (ctx.trials.length < 2) ctx.addTrial('Stub');
|
|
39
|
-
}, []);
|
|
38
|
+
}, [ctx]);
|
|
40
39
|
return null;
|
|
41
40
|
}
|
|
42
41
|
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { ThemeProvider } from '@weasel-js/theme/react';
|
|
2
2
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { useStore } from 'zustand/react';
|
|
4
|
+
import type { TrialContribution } from '../chrome/types';
|
|
5
|
+
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
4
6
|
import type { InstrumentList } from '../instrument/types';
|
|
5
7
|
import { noneAdapter } from '../state/adapters';
|
|
6
8
|
import { LabStoreContext } from '../state/context';
|
|
7
9
|
import { createLabStore, type LabStore } from '../state/store';
|
|
8
10
|
import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
|
|
9
11
|
import { interstellarTheme } from '../theme/interstellar';
|
|
12
|
+
import type { TrialTool } from '../tools/types';
|
|
10
13
|
import { Trial } from '../trial/Trial';
|
|
11
14
|
import {
|
|
12
15
|
addTrial as addTrialOp,
|
|
@@ -16,6 +19,8 @@ import {
|
|
|
16
19
|
resetTrial as resetTrialOp,
|
|
17
20
|
} from '../trial/trialOps';
|
|
18
21
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
22
|
+
import { LabHeader } from './LabHeader';
|
|
23
|
+
import { LabPalette } from './LabPalette';
|
|
19
24
|
import { LabShell } from './LabShell';
|
|
20
25
|
import { useResolvedMode } from './useSystemMode';
|
|
21
26
|
import { type TrialLayout, Workspace } from './Workspace';
|
|
@@ -35,6 +40,22 @@ export interface LabProps {
|
|
|
35
40
|
*/
|
|
36
41
|
nebula?: readonly string[];
|
|
37
42
|
title?: string;
|
|
43
|
+
/** Rendered in the shell's footer, below the workspace. */
|
|
44
|
+
footer?: ReactNode;
|
|
45
|
+
/** Contributions added to every trial's chrome, after the instrument's own. */
|
|
46
|
+
chrome?: readonly TrialContribution[];
|
|
47
|
+
/** Built-in contribution ids to drop. Throws on an id that is not there. */
|
|
48
|
+
suppress?: readonly string[];
|
|
49
|
+
/** Tools offered lab-wide. A trial whose instrument declares none of its own
|
|
50
|
+
* reflects and writes this slot. */
|
|
51
|
+
tools?: readonly TrialTool[];
|
|
52
|
+
/** Rules run over every instrument's config leaves, before labkit's own
|
|
53
|
+
* inference — where a lab states a convention once instead of annotating
|
|
54
|
+
* each field. Memoize or hoist. */
|
|
55
|
+
configRules?: readonly ConfigRule[];
|
|
56
|
+
/** Control overrides and app-defined kinds for every trial's settings panel.
|
|
57
|
+
* Keys are config paths (checked first) or leaf kinds. Memoize or hoist. */
|
|
58
|
+
controls?: Record<string, ControlRenderer>;
|
|
38
59
|
children?: ReactNode;
|
|
39
60
|
}
|
|
40
61
|
|
|
@@ -88,6 +109,12 @@ export function Lab({
|
|
|
88
109
|
mode,
|
|
89
110
|
nebula,
|
|
90
111
|
title,
|
|
112
|
+
footer,
|
|
113
|
+
chrome,
|
|
114
|
+
suppress,
|
|
115
|
+
tools,
|
|
116
|
+
configRules,
|
|
117
|
+
controls,
|
|
91
118
|
children,
|
|
92
119
|
}: LabProps) {
|
|
93
120
|
if (process.env.NODE_ENV !== 'production' && instruments.length === 0) {
|
|
@@ -160,7 +187,8 @@ export function Lab({
|
|
|
160
187
|
},
|
|
161
188
|
savedSnapshots,
|
|
162
189
|
saveSnapshot: (trialId, name) => {
|
|
163
|
-
store.getState().
|
|
190
|
+
const taken = store.getState().savedSnapshots.filter((s) => s.trialId === trialId).length;
|
|
191
|
+
store.getState().saveSnapshot(trialId, name ?? `Snapshot ${taken + 1}`);
|
|
164
192
|
},
|
|
165
193
|
loadSnapshot: (trialId, snapshotId) => {
|
|
166
194
|
store.getState().loadSnapshot(snapshotId, trialId);
|
|
@@ -172,8 +200,10 @@ export function Lab({
|
|
|
172
200
|
setMode: (m) => {
|
|
173
201
|
store.getState().setMode(m);
|
|
174
202
|
},
|
|
203
|
+
configRules,
|
|
204
|
+
controls,
|
|
175
205
|
};
|
|
176
|
-
}, [instruments, trials, savedSnapshots, modeValue, store]);
|
|
206
|
+
}, [instruments, trials, savedSnapshots, modeValue, store, configRules, controls]);
|
|
177
207
|
|
|
178
208
|
// Only override the backdrop in dark, where there is one to override.
|
|
179
209
|
// Setting a CSS custom property is the sanctioned use of inline style.
|
|
@@ -191,19 +221,32 @@ export function Lab({
|
|
|
191
221
|
className="lk-lab"
|
|
192
222
|
style={backdropStyle}
|
|
193
223
|
>
|
|
194
|
-
<LabShell
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
224
|
+
<LabShell
|
|
225
|
+
title={title ?? 'Labkit'}
|
|
226
|
+
mode={modeValue}
|
|
227
|
+
footer={footer}
|
|
228
|
+
header={
|
|
229
|
+
<>
|
|
230
|
+
<LabHeader />
|
|
231
|
+
{children}
|
|
232
|
+
</>
|
|
233
|
+
}
|
|
234
|
+
>
|
|
235
|
+
<div className="lk-lab__body">
|
|
236
|
+
{tools ? <LabPalette tools={tools} /> : null}
|
|
237
|
+
<Workspace
|
|
238
|
+
ids={trials.map((w) => w.id)}
|
|
239
|
+
resizable
|
|
240
|
+
reorderable
|
|
241
|
+
onReorder={(ids) => contextValue.reorderTrials(ids)}
|
|
242
|
+
layout={layout as TrialLayout}
|
|
243
|
+
onLayoutChange={(next) => store.getState().setLayout(next)}
|
|
244
|
+
>
|
|
245
|
+
{trials.map((w) => (
|
|
246
|
+
<Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
|
|
247
|
+
))}
|
|
248
|
+
</Workspace>
|
|
249
|
+
</div>
|
|
207
250
|
</LabShell>
|
|
208
251
|
</ThemeProvider>
|
|
209
252
|
</LabContext.Provider>
|
package/src/lab/LabContext.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
|
+
import type { ConfigRule, ControlRenderer } from '../config/types';
|
|
2
3
|
import type { InstrumentList } from '../instrument/types';
|
|
3
4
|
import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
|
|
4
5
|
|
|
@@ -19,6 +20,11 @@ export interface LabContextValue {
|
|
|
19
20
|
deleteSnapshot: (snapshotId: string) => void;
|
|
20
21
|
mode: LabMode;
|
|
21
22
|
setMode: (m: LabMode) => void;
|
|
23
|
+
/** Lab-wide rules run over every instrument's config leaves, before
|
|
24
|
+
* labkit's own inference. */
|
|
25
|
+
configRules?: readonly ConfigRule[];
|
|
26
|
+
/** Lab-wide control overrides, keyed by config path or by leaf kind. */
|
|
27
|
+
controls?: Record<string, ControlRenderer>;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/** Context carrying the surrounding lab. Prefer `useLabContext`. */
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import type { ConfigField } from '../controls/types';
|
|
4
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
5
|
+
import type { Instrument, RenderContext } from '../instrument/types';
|
|
6
|
+
import type { JobEvent } from '../job/types';
|
|
7
|
+
import { Lab } from './Lab';
|
|
8
|
+
import { useLabContext } from './LabContext';
|
|
9
|
+
|
|
10
|
+
interface ScanConfig {
|
|
11
|
+
gain: number;
|
|
12
|
+
integrationMs: number;
|
|
13
|
+
source: string;
|
|
14
|
+
channel: string;
|
|
15
|
+
traceColor: string;
|
|
16
|
+
autoBaseline: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ScanState {
|
|
20
|
+
samples: number[];
|
|
21
|
+
marks: number[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PLOT = { w: 240, h: 120 };
|
|
25
|
+
|
|
26
|
+
function scanSchema(traceColor: string): ConfigField[] {
|
|
27
|
+
return [
|
|
28
|
+
{ key: 'gain', label: 'Gain', type: 'slider', default: 1.4, min: 0.1, max: 4, step: 0.1 },
|
|
29
|
+
{
|
|
30
|
+
key: 'integrationMs',
|
|
31
|
+
label: 'Integration (ms)',
|
|
32
|
+
type: 'number',
|
|
33
|
+
default: 250,
|
|
34
|
+
min: 10,
|
|
35
|
+
max: 2000,
|
|
36
|
+
step: 10,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: 'source',
|
|
40
|
+
label: 'Source',
|
|
41
|
+
type: 'select',
|
|
42
|
+
default: 'deuterium',
|
|
43
|
+
options: [
|
|
44
|
+
{ value: 'deuterium', label: 'Deuterium' },
|
|
45
|
+
{ value: 'tungsten', label: 'Tungsten' },
|
|
46
|
+
{ value: 'xenon', label: 'Xenon flash' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{ key: 'channel', label: 'Channel', type: 'text', default: 'CH-1', maxLength: 12 },
|
|
50
|
+
{ key: 'traceColor', label: 'Trace', type: 'color', default: traceColor },
|
|
51
|
+
{ key: 'autoBaseline', label: 'Auto baseline', type: 'checkbox', default: true },
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function wait(ms: number, signal: AbortSignal): Promise<void> {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const timer = setTimeout(resolve, ms);
|
|
58
|
+
signal.addEventListener(
|
|
59
|
+
'abort',
|
|
60
|
+
() => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
resolve();
|
|
63
|
+
},
|
|
64
|
+
{ once: true },
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface SweepSpec {
|
|
70
|
+
count: number;
|
|
71
|
+
everyMs: number;
|
|
72
|
+
/** Frame indices that report a `failed` event instead of a sample. */
|
|
73
|
+
dropped?: number[];
|
|
74
|
+
/** Frame index at which the whole run throws, and what it throws. */
|
|
75
|
+
faultAt?: number;
|
|
76
|
+
fault?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function* sweep(spec: SweepSpec, signal: AbortSignal): AsyncGenerator<JobEvent<number>> {
|
|
80
|
+
yield { kind: 'total', total: spec.count };
|
|
81
|
+
for (let i = 0; i < spec.count; i++) {
|
|
82
|
+
await wait(spec.everyMs, signal);
|
|
83
|
+
if (signal.aborted) return;
|
|
84
|
+
if (i === spec.faultAt) throw new Error(spec.fault ?? 'Sweep failed');
|
|
85
|
+
if (spec.dropped?.includes(i)) {
|
|
86
|
+
yield { kind: 'failed', index: i, error: `Frame ${i}: detector saturated` };
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
yield { kind: 'item', item: Math.sin(i * 0.55) * 42 + 58 };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Rendered for its effect alone: a mark seeded on mount gives every trial one
|
|
94
|
+
// undoable edit, so Undo reads live instead of starting greyed out.
|
|
95
|
+
function SeedMarks({ ctx }: { ctx: RenderContext<ScanState, ScanConfig> }) {
|
|
96
|
+
const seeded = useRef(false);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (seeded.current) return;
|
|
99
|
+
seeded.current = true;
|
|
100
|
+
ctx.setState((prev) => ({ ...prev, marks: [...prev.marks, 64, 152] }));
|
|
101
|
+
}, [ctx]);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scanInstrument(
|
|
106
|
+
name: string,
|
|
107
|
+
traceColor: string,
|
|
108
|
+
spec: SweepSpec,
|
|
109
|
+
): Instrument<ScanState, ScanConfig, number> {
|
|
110
|
+
return defineInstrument<ScanState, ScanConfig, number>({
|
|
111
|
+
name,
|
|
112
|
+
defaultConfig: () => ({
|
|
113
|
+
gain: 1.4,
|
|
114
|
+
integrationMs: 250,
|
|
115
|
+
source: 'deuterium',
|
|
116
|
+
channel: 'CH-1',
|
|
117
|
+
traceColor,
|
|
118
|
+
autoBaseline: true,
|
|
119
|
+
}),
|
|
120
|
+
initialState: () => ({ samples: [], marks: [] }),
|
|
121
|
+
configSchema: () => scanSchema(traceColor),
|
|
122
|
+
render: (ctx) => <SeedMarks ctx={ctx} />,
|
|
123
|
+
canvas: {
|
|
124
|
+
initialView: { zoom: 1, pan: { x: 24, y: 24 } },
|
|
125
|
+
layers: [
|
|
126
|
+
{
|
|
127
|
+
id: 'grid',
|
|
128
|
+
draw: (c, { zoom }) => {
|
|
129
|
+
c.strokeStyle = '#8894a8';
|
|
130
|
+
c.globalAlpha = 0.3;
|
|
131
|
+
c.lineWidth = 1 / zoom;
|
|
132
|
+
for (let x = 0; x <= PLOT.w; x += 20) {
|
|
133
|
+
c.beginPath();
|
|
134
|
+
c.moveTo(x, 0);
|
|
135
|
+
c.lineTo(x, PLOT.h);
|
|
136
|
+
c.stroke();
|
|
137
|
+
}
|
|
138
|
+
for (let y = 0; y <= PLOT.h; y += 20) {
|
|
139
|
+
c.beginPath();
|
|
140
|
+
c.moveTo(0, y);
|
|
141
|
+
c.lineTo(PLOT.w, y);
|
|
142
|
+
c.stroke();
|
|
143
|
+
}
|
|
144
|
+
c.globalAlpha = 1;
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'trace',
|
|
149
|
+
draw: (c, { state, config, zoom }) => {
|
|
150
|
+
if (state.samples.length === 0) return;
|
|
151
|
+
c.strokeStyle = config.traceColor;
|
|
152
|
+
c.lineWidth = 2 / zoom;
|
|
153
|
+
c.beginPath();
|
|
154
|
+
state.samples.forEach((sample, i) => {
|
|
155
|
+
const x = i * 10;
|
|
156
|
+
const y = PLOT.h - Math.min(PLOT.h, sample * config.gain * 0.7);
|
|
157
|
+
if (i === 0) c.moveTo(x, y);
|
|
158
|
+
else c.lineTo(x, y);
|
|
159
|
+
});
|
|
160
|
+
c.stroke();
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'marks',
|
|
165
|
+
draw: (c, { state, zoom }) => {
|
|
166
|
+
c.strokeStyle = '#f08c00';
|
|
167
|
+
c.lineWidth = 1 / zoom;
|
|
168
|
+
for (const x of state.marks) {
|
|
169
|
+
c.beginPath();
|
|
170
|
+
c.moveTo(x, 0);
|
|
171
|
+
c.lineTo(x, PLOT.h);
|
|
172
|
+
c.stroke();
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
layers: {
|
|
179
|
+
ids: [
|
|
180
|
+
{ id: 'grid', label: 'Grid', alwaysOn: true },
|
|
181
|
+
{ id: 'trace', label: 'Trace' },
|
|
182
|
+
{ id: 'marks', label: 'Marks' },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
dragDrop: {
|
|
186
|
+
palette: [
|
|
187
|
+
{ id: 'peak', label: 'Peak marker' },
|
|
188
|
+
{ id: 'baseline', label: 'Baseline point' },
|
|
189
|
+
{ id: 'window', label: 'Integration window' },
|
|
190
|
+
],
|
|
191
|
+
onDrop: (worldPos, _item, state) => ({ ...state, marks: [...state.marks, worldPos.x] }),
|
|
192
|
+
},
|
|
193
|
+
undo: { snapshotOn: ['state.change', 'canvas.itemAdded'], maxDepth: 20 },
|
|
194
|
+
job: {
|
|
195
|
+
auto: true,
|
|
196
|
+
run: ({ signal }) => sweep(spec, signal),
|
|
197
|
+
onItem: (item, state) => ({ ...state, samples: [...state.samples, item] }),
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const spectrometer = scanInstrument('Spectrometer', '#5ad1ff', { count: 24, everyMs: 1200 });
|
|
203
|
+
const beamProfile = scanInstrument('Beam profile', '#ffd166', {
|
|
204
|
+
count: 12,
|
|
205
|
+
everyMs: 900,
|
|
206
|
+
dropped: [4, 9],
|
|
207
|
+
});
|
|
208
|
+
const thermalDrift = scanInstrument('Thermal drift', '#ff8fa3', {
|
|
209
|
+
count: 8,
|
|
210
|
+
everyMs: 800,
|
|
211
|
+
faultAt: 3,
|
|
212
|
+
fault: 'Sensor bus timeout on channel 2',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/** Opens the other two trials and saves one snapshot per trial, so every
|
|
216
|
+
* toolbar shows its Load control and the header's Select has somewhere to
|
|
217
|
+
* add to. */
|
|
218
|
+
function SeedTrials() {
|
|
219
|
+
const lab = useLabContext();
|
|
220
|
+
const opened = useRef(false);
|
|
221
|
+
const saved = useRef(false);
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!opened.current) {
|
|
224
|
+
opened.current = true;
|
|
225
|
+
lab.addTrial('Beam profile');
|
|
226
|
+
lab.addTrial('Thermal drift');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!saved.current && lab.trials.length === 3) {
|
|
230
|
+
saved.current = true;
|
|
231
|
+
for (const trial of lab.trials) lab.saveSnapshot(trial.id, 'Baseline');
|
|
232
|
+
}
|
|
233
|
+
}, [lab]);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const runLog = defineInstrument<{ lines: string[] }, Record<string, never>>({
|
|
238
|
+
name: 'Run log',
|
|
239
|
+
defaultConfig: () => ({}),
|
|
240
|
+
initialState: () => ({
|
|
241
|
+
lines: ['09:04 lamp warm', '09:06 slit 50 µm', '09:12 calibrated', '09:14 sweep armed'],
|
|
242
|
+
}),
|
|
243
|
+
render: ({ state }) => (
|
|
244
|
+
<div className="lk-run-log">
|
|
245
|
+
{state.lines.map((line) => (
|
|
246
|
+
<div key={line}>{line}</div>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const meta: Meta<typeof Lab> = {
|
|
253
|
+
title: 'labkit/Lab/FullChrome',
|
|
254
|
+
component: Lab,
|
|
255
|
+
parameters: { layout: 'fullscreen' },
|
|
256
|
+
};
|
|
257
|
+
export default meta;
|
|
258
|
+
|
|
259
|
+
type Story = StoryObj<typeof Lab>;
|
|
260
|
+
|
|
261
|
+
/** Every chrome surface a lab renders, on screen at once: three instruments so
|
|
262
|
+
* the header offers its Select, three trials each carrying a title bar, the
|
|
263
|
+
* full toolbar, a control panel with one of every field type, the palette and
|
|
264
|
+
* layer list, and a status bar with a running job. Starts in dark, where the
|
|
265
|
+
* `nebula` backdrop paints. */
|
|
266
|
+
export const AllChrome: Story = {
|
|
267
|
+
args: {
|
|
268
|
+
instruments: [spectrometer, beamProfile, thermalDrift],
|
|
269
|
+
defaultInstrument: 'Spectrometer',
|
|
270
|
+
title: 'Optics bench',
|
|
271
|
+
storage: null,
|
|
272
|
+
mode: 'dark',
|
|
273
|
+
nebula: ['#3b5bdb', '#7048e8', '#0ca678'],
|
|
274
|
+
children: (
|
|
275
|
+
<>
|
|
276
|
+
<SeedTrials />
|
|
277
|
+
<span className="lk-run-tag">Run 42 · bench 3</span>
|
|
278
|
+
</>
|
|
279
|
+
),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** The chrome the other story cannot show at the same time: one instrument, so
|
|
284
|
+
* the header is an Add trial button; one trial, so Close is disabled; no
|
|
285
|
+
* config schema, canvas, undo or job, so the sidebar falls back to its
|
|
286
|
+
* placeholder and the toolbar keeps only what is left. */
|
|
287
|
+
export const SingleInstrument: Story = {
|
|
288
|
+
args: {
|
|
289
|
+
instruments: [runLog],
|
|
290
|
+
defaultInstrument: 'Run log',
|
|
291
|
+
title: 'Run log',
|
|
292
|
+
storage: null,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/** The shell footer, which `<Lab>` had no prop for — reaching it used to mean
|
|
297
|
+
* building `<LabShell>` yourself. Trial chrome is contributions now; see
|
|
298
|
+
* `labkit/Chrome/Regions` for those. */
|
|
299
|
+
export const WithFooter: Story = {
|
|
300
|
+
args: {
|
|
301
|
+
instruments: [spectrometer, beamProfile],
|
|
302
|
+
defaultInstrument: 'Spectrometer',
|
|
303
|
+
title: 'With footer',
|
|
304
|
+
storage: null,
|
|
305
|
+
mode: 'dark',
|
|
306
|
+
footer: <span className="lk-run-tag">Footer — reached through LabProps.footer</span>,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { Instrument } from '../instrument/types';
|
|
4
|
+
import { Lab } from './Lab';
|
|
5
|
+
|
|
6
|
+
const Stub: Instrument = {
|
|
7
|
+
name: 'Stub',
|
|
8
|
+
defaultConfig: () => ({}),
|
|
9
|
+
initialState: () => ({}),
|
|
10
|
+
render: () => <div>stub</div>,
|
|
11
|
+
};
|
|
12
|
+
const Other: Instrument = { ...Stub, name: 'Other' };
|
|
13
|
+
|
|
14
|
+
describe('<LabHeader>', () => {
|
|
15
|
+
it('offers an add-trial button when the lab has one instrument', () => {
|
|
16
|
+
render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
|
|
17
|
+
expect(screen.getByRole('button', { name: /add trial/i })).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('offers a picker instead when the lab has several', () => {
|
|
21
|
+
render(<Lab instruments={[Stub, Other]} defaultInstrument="Stub" storage={null} />);
|
|
22
|
+
// A picker, not a button — which instrument to add is now a choice.
|
|
23
|
+
expect(screen.queryByRole('button', { name: /^add trial$/i })).toBeNull();
|
|
24
|
+
expect(screen.getByLabelText(/add trial/i)).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('adds a trial when the button is used', async () => {
|
|
28
|
+
const { default: userEvent } = await import('@testing-library/user-event');
|
|
29
|
+
const user = userEvent.setup();
|
|
30
|
+
render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
|
|
31
|
+
expect(screen.getAllByLabelText(/^Trial Stub$/)).toHaveLength(1);
|
|
32
|
+
await user.click(screen.getByRole('button', { name: /add trial/i }));
|
|
33
|
+
expect(screen.getAllByLabelText(/^Trial Stub$/)).toHaveLength(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('exposes the color mode as a three-way choice', () => {
|
|
37
|
+
render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
|
|
38
|
+
for (const label of ['Auto', 'Light', 'Dark']) {
|
|
39
|
+
expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AddIcon } from '@weasel-js/ui';
|
|
2
|
+
import { Select, ToggleBar } from '../passthrough/weasel-ui';
|
|
3
|
+
import type { LabMode } from '../state/types';
|
|
4
|
+
import { useLabContext } from './LabContext';
|
|
5
|
+
|
|
6
|
+
const MODES: { value: LabMode; label: string }[] = [
|
|
7
|
+
{ value: 'auto', label: 'Auto' },
|
|
8
|
+
{ value: 'light', label: 'Light' },
|
|
9
|
+
{ value: 'dark', label: 'Dark' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/** The controls `<Lab>` puts in its header: add a trial, and choose the color
|
|
13
|
+
* mode. Both drive `LabContext`, which carried them with no UI at all — so
|
|
14
|
+
* every consumer rebuilt these two. Rendered before a consumer's own header
|
|
15
|
+
* content, which still lands beside them. */
|
|
16
|
+
export function LabHeader() {
|
|
17
|
+
const lab = useLabContext();
|
|
18
|
+
const only = lab.instruments.length === 1 ? lab.instruments[0] : null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
{only ? (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
className="lk-lab-header__add"
|
|
26
|
+
onClick={() => lab.addTrial(only.name)}
|
|
27
|
+
>
|
|
28
|
+
<AddIcon size={16} />
|
|
29
|
+
<span>Add trial</span>
|
|
30
|
+
</button>
|
|
31
|
+
) : (
|
|
32
|
+
// Held at null so the control stays an "add one" action rather than
|
|
33
|
+
// reading as the current instrument.
|
|
34
|
+
<Select
|
|
35
|
+
className="lk-lab-header__add-select"
|
|
36
|
+
aria-label="Add trial"
|
|
37
|
+
placeholder="Add trial…"
|
|
38
|
+
selectedKey={null}
|
|
39
|
+
options={lab.instruments.map((i) => ({ value: i.name, label: i.name }))}
|
|
40
|
+
onSelectionChange={(name) => {
|
|
41
|
+
if (name != null) lab.addTrial(String(name));
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
<ToggleBar
|
|
47
|
+
className="lk-lab-header__mode"
|
|
48
|
+
ariaLabel="Color mode"
|
|
49
|
+
items={MODES}
|
|
50
|
+
value={lab.mode}
|
|
51
|
+
onChange={(next) => {
|
|
52
|
+
if (next) lab.setMode(next);
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { Instrument } from '../instrument/types';
|
|
4
|
+
import { Lab } from './Lab';
|
|
5
|
+
|
|
6
|
+
const Glyph = () => <svg />;
|
|
7
|
+
const bare: Instrument = {
|
|
8
|
+
name: 'Bare',
|
|
9
|
+
defaultConfig: () => ({}),
|
|
10
|
+
initialState: () => ({}),
|
|
11
|
+
render: () => null,
|
|
12
|
+
};
|
|
13
|
+
const tools = [
|
|
14
|
+
{ id: 'pick', label: 'Pick', icon: Glyph },
|
|
15
|
+
{ id: 'pan', label: 'Pan', icon: Glyph },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe('the lab palette', () => {
|
|
19
|
+
it('does not render when the lab declares no tools', () => {
|
|
20
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" />);
|
|
21
|
+
expect(screen.queryByRole('toolbar', { name: 'Tools' })).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders one button per declared tool', () => {
|
|
25
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" tools={tools} />);
|
|
26
|
+
expect(screen.getByRole('button', { name: 'Pick' })).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByRole('button', { name: 'Pan' })).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('marks the chosen tool current after a click', () => {
|
|
31
|
+
render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" tools={tools} />);
|
|
32
|
+
fireEvent.click(screen.getByRole('button', { name: 'Pan' }));
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Pan' })).toHaveAttribute('aria-current', 'true');
|
|
34
|
+
});
|
|
35
|
+
});
|