@weasel-js/labkit 1.5.0 → 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 +41 -4
- package/dist/_dts/{CanvasStackContext-CqkxkFJW.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-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
- package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
- package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
- package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
- package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
- package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.d.ts} +1 -1
- package/dist/canvas/index.d.ts +5 -5
- package/dist/canvas/index.js +1 -1
- package/dist/chrome/index.d.ts +4 -4
- package/dist/chrome/index.js +7 -7
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
- 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-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
- package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
- package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
- package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +60 -16
- package/dist/config/index.js +6 -4
- package/dist/controls/index.d.ts +3 -3
- package/dist/controls/index.js +4 -4
- package/dist/dragdrop/index.d.ts +3 -3
- package/dist/index.d.ts +177 -53
- package/dist/index.js +1333 -1683
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -4
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +67 -16
- package/dist/loupe/index.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +26 -202
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +3 -3
- package/dist/state/index.d.ts +5 -5
- package/dist/state/index.js +5 -3
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +153 -40
- package/dist/surface/index.js +1 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +3 -3
- package/package.json +9 -9
- package/src/annotations/AnnotationOverlay.tsx +3 -0
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +20 -0
- package/src/annotations/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +3 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- package/src/canvas/usePanZoom.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +11 -4
- package/src/chrome/ChromeRegions.stories.tsx +1 -1
- package/src/chrome/LabChrome.tsx +21 -5
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +1 -0
- package/src/chrome/index.ts +1 -0
- package/src/chrome/labTypes.ts +2 -2
- package/src/chrome/regions/PaletteRegion.tsx +1 -6
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/ToolbarRegion.tsx +11 -7
- package/src/chrome/types.ts +3 -0
- 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 +38 -0
- package/src/config/builder.ts +53 -8
- package/src/config/index.ts +3 -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 +32 -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 +514 -4
- package/src/controls/ControlPanel.tsx +557 -80
- package/src/index.ts +29 -1
- package/src/instrument/serializers.ts +29 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.stories.tsx +1 -1
- package/src/lab/Lab.surface.test.tsx +7 -2
- package/src/lab/Lab.test.tsx +79 -1
- package/src/lab/Lab.tsx +105 -13
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.tsx +4 -4
- package/src/lab/LabFullChrome.stories.tsx +1 -1
- package/src/lab/LabHeader.tsx +0 -1
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +24 -3
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +10 -10
- 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 -1
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +16 -4
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/useLoupe.test.tsx +59 -2
- package/src/loupe/useLoupe.ts +28 -48
- package/src/passthrough/weasel-ui.ts +3 -3
- package/src/primitives/FloatingPanel.stories.tsx +1 -1
- 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.test.tsx +25 -0
- package/src/primitives/Split.tsx +36 -20
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- 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 +4 -4
- package/src/state/store.test.ts +65 -0
- package/src/state/store.ts +32 -8
- package/src/state/types.ts +26 -2
- package/src/state/useTrialState.test.tsx +85 -0
- package/src/state/useTrialState.ts +9 -1
- package/src/styles.less +1 -0
- package/src/surface/index.ts +1 -1
- package/src/surface/useSurfaceTile.test.tsx +1 -1
- package/src/surface/useTiledSurface.test.tsx +65 -10
- package/src/surface/useTiledSurface.ts +23 -11
- package/src/theme/Interstellar.stories.tsx +2 -2
- package/src/theme/interstellar.test.ts +16 -5
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +3 -3
- package/src/trial/Trial.annotations.test.tsx +13 -4
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.less +4 -0
- package/src/trial/Trial.stories.tsx +4 -1
- package/src/trial/Trial.test.tsx +3 -0
- package/src/trial/Trial.tsx +21 -14
- package/src/trial/TrialChrome.tsx +17 -3
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +62 -4
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
- package/dist/chunk-67SJLMC7.js.map +0 -1
- package/dist/chunk-6ZDGOZQV.js.map +0 -1
- package/dist/chunk-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.js.map +0 -1
- package/dist/chunk-EKIICY6X.js.map +0 -1
- package/dist/chunk-NDRYLVOW.js.map +0 -1
- package/dist/chunk-NS64DXMN.js.map +0 -1
- package/dist/chunk-PGETSEDK.js.map +0 -1
- package/dist/chunk-QLSV2N3G.js.map +0 -1
- package/dist/chunk-S2SHZRD7.js +0 -7681
- package/dist/chunk-S2SHZRD7.js.map +0 -1
- package/dist/chunk-TQBBYTAF.js +0 -280
- package/dist/chunk-TQBBYTAF.js.map +0 -1
- package/dist/chunk-ZPH5WTWF.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react';
|
|
1
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
2
3
|
import { TOOL_PREF_KINDS, type ToolPrefKind } from '@weasel-js/core';
|
|
3
4
|
import type { PrefLeaf } from '@weasel-js/ui';
|
|
5
|
+
import { useState } from 'react';
|
|
4
6
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { auto } from '../config/auto';
|
|
5
8
|
import { f } from '../config/builder';
|
|
6
9
|
import { resolveConfigSchema } from '../config/resolve';
|
|
7
10
|
import type { ResolvedConfig } from '../config/types';
|
|
@@ -68,9 +71,12 @@ describe('<ControlPanel> select', () => {
|
|
|
68
71
|
},
|
|
69
72
|
];
|
|
70
73
|
render(<ControlPanel fields={fields} config={{ wave: 'sine' }} setConfig={setConfig} />);
|
|
71
|
-
const
|
|
72
|
-
expect(
|
|
73
|
-
|
|
74
|
+
const trigger = screen.getByRole('button', { name: 'Sine Wave' });
|
|
75
|
+
expect(trigger).toHaveTextContent('Sine');
|
|
76
|
+
act(() => {
|
|
77
|
+
fireEvent.click(trigger);
|
|
78
|
+
});
|
|
79
|
+
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: 'Square' }));
|
|
74
80
|
expect(setConfig).toHaveBeenCalledWith('wave', 'square');
|
|
75
81
|
});
|
|
76
82
|
});
|
|
@@ -174,6 +180,22 @@ describe('<ControlPanel> schema', () => {
|
|
|
174
180
|
expect(setConfig).toHaveBeenCalledWith('showGrid', false);
|
|
175
181
|
});
|
|
176
182
|
|
|
183
|
+
it('draws a switch for a toggle() leaf and a checkbox for a plain one', () => {
|
|
184
|
+
const setConfig = vi.fn();
|
|
185
|
+
const schema = resolveConfigSchema(
|
|
186
|
+
f.schema({ showGrid: f.boolean(true), snap: f.boolean(true).toggle() }),
|
|
187
|
+
[],
|
|
188
|
+
);
|
|
189
|
+
render(
|
|
190
|
+
<ControlPanel schema={schema} config={{ showGrid: true, snap: false }} setConfig={setConfig} />,
|
|
191
|
+
);
|
|
192
|
+
expect(screen.getByRole('checkbox', { name: 'Show grid' })).toBeInTheDocument();
|
|
193
|
+
const sw = screen.getByRole('switch', { name: 'Snap' });
|
|
194
|
+
expect(sw).toBeInTheDocument();
|
|
195
|
+
fireEvent.click(sw);
|
|
196
|
+
expect(setConfig).toHaveBeenCalledWith('snap', true);
|
|
197
|
+
});
|
|
198
|
+
|
|
177
199
|
it('picks a slider for a bounded number and an input for an open one', () => {
|
|
178
200
|
const schema = resolveConfigSchema(
|
|
179
201
|
f.schema({ a: f.number(5).range(0, 10), b: f.number(5) }),
|
|
@@ -460,6 +482,46 @@ describe('<ControlPanel> format', () => {
|
|
|
460
482
|
});
|
|
461
483
|
});
|
|
462
484
|
|
|
485
|
+
describe('<ControlPanel> section layout', () => {
|
|
486
|
+
const schema = resolveConfigSchema(
|
|
487
|
+
f.schema({
|
|
488
|
+
seed: f.number(0).section('Advanced'),
|
|
489
|
+
mode: f
|
|
490
|
+
.enum('a', [
|
|
491
|
+
{ value: 'a', label: 'A' },
|
|
492
|
+
{ value: 'b', label: 'B' },
|
|
493
|
+
])
|
|
494
|
+
.label('Mode')
|
|
495
|
+
.section('Globals', { layout: 'inline', pack: 'one-up' }),
|
|
496
|
+
font: f
|
|
497
|
+
.enum('x', [{ value: 'x', label: 'X' }])
|
|
498
|
+
.label('Font')
|
|
499
|
+
.section('Globals'),
|
|
500
|
+
}),
|
|
501
|
+
[],
|
|
502
|
+
);
|
|
503
|
+
const config = { seed: 0, mode: 'a', font: 'x' };
|
|
504
|
+
// The row class comes through the CSS-module proxy, so this proves which layout the row asked for, not a rule.
|
|
505
|
+
const rowOf = (label: string) => screen.getByText(label).closest('label') as HTMLElement;
|
|
506
|
+
|
|
507
|
+
it('lays out every row in a section the way the section asks, and leaves other sections alone', () => {
|
|
508
|
+
render(<ControlPanel schema={schema} config={config} setConfig={vi.fn()} />);
|
|
509
|
+
expect(rowOf('Mode').className).toMatch(/rowInline/);
|
|
510
|
+
expect(rowOf('Font').className).toMatch(/rowInline/);
|
|
511
|
+
expect(rowOf('Seed').className).not.toMatch(/rowInline/);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('carries a section’s layout into its resolved spec', () => {
|
|
515
|
+
expect(schema.sections.find((section) => section.label === 'Globals')).toMatchObject({
|
|
516
|
+
layout: 'inline',
|
|
517
|
+
pack: 'one-up',
|
|
518
|
+
});
|
|
519
|
+
expect(schema.sections.find((section) => section.label === 'Advanced')).not.toHaveProperty(
|
|
520
|
+
'layout',
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
463
525
|
describe('<ControlPanel> collapsible sections', () => {
|
|
464
526
|
const schema = resolveConfigSchema(
|
|
465
527
|
f.schema({ showGrid: f.boolean(true), seed: f.number(0).section('Advanced') }),
|
|
@@ -611,3 +673,451 @@ describe('<ControlPanel> nested groups', () => {
|
|
|
611
673
|
expect(screen.getByText('size:44')).toBeInTheDocument();
|
|
612
674
|
});
|
|
613
675
|
});
|
|
676
|
+
|
|
677
|
+
describe('<ControlPanel> auto', () => {
|
|
678
|
+
it('renders a pin dot whether or not it was given a set of auto paths', () => {
|
|
679
|
+
render(
|
|
680
|
+
<ControlPanel
|
|
681
|
+
schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
|
|
682
|
+
config={{ gap: 12 }}
|
|
683
|
+
setConfig={() => {}}
|
|
684
|
+
/>,
|
|
685
|
+
);
|
|
686
|
+
expect(screen.getByRole('button', { name: 'Pin Gap' })).toBeInTheDocument();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('hands a custom renderer the auto state and a working toggle', async () => {
|
|
690
|
+
const setConfig = vi.fn();
|
|
691
|
+
render(
|
|
692
|
+
<ControlPanel
|
|
693
|
+
schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
|
|
694
|
+
config={{ gap: 12 }}
|
|
695
|
+
auto={new Set(['gap'])}
|
|
696
|
+
setConfig={setConfig}
|
|
697
|
+
renderers={{
|
|
698
|
+
gap: (ctx) => (
|
|
699
|
+
<button type="button" onClick={() => ctx.setAuto(false)}>
|
|
700
|
+
{ctx.auto ? 'is auto' : 'is pinned'}
|
|
701
|
+
</button>
|
|
702
|
+
),
|
|
703
|
+
}}
|
|
704
|
+
/>,
|
|
705
|
+
);
|
|
706
|
+
const control = screen.getByRole('button', { name: 'is auto' });
|
|
707
|
+
await userEvent.click(control);
|
|
708
|
+
expect(setConfig).toHaveBeenCalledWith('gap', 12);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("reads the resolver for an auto row's readout", () => {
|
|
712
|
+
render(
|
|
713
|
+
<ControlPanel
|
|
714
|
+
schema={resolveConfigSchema(
|
|
715
|
+
f.schema({
|
|
716
|
+
gap: f
|
|
717
|
+
.number(12)
|
|
718
|
+
.auto(() => 18)
|
|
719
|
+
.range(0, 48),
|
|
720
|
+
}),
|
|
721
|
+
[],
|
|
722
|
+
)}
|
|
723
|
+
config={{ gap: 12 }}
|
|
724
|
+
auto={new Set(['gap'])}
|
|
725
|
+
setConfig={() => {}}
|
|
726
|
+
/>,
|
|
727
|
+
);
|
|
728
|
+
expect(screen.getByText('auto · 18')).toBeInTheDocument();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('reads "auto" alone for a path with no resolver', () => {
|
|
732
|
+
render(
|
|
733
|
+
<ControlPanel
|
|
734
|
+
schema={resolveConfigSchema(f.schema({ cols: f.number(3).range(0, 8) }), [])}
|
|
735
|
+
config={{ cols: 3 }}
|
|
736
|
+
auto={new Set(['cols'])}
|
|
737
|
+
setConfig={() => {}}
|
|
738
|
+
/>,
|
|
739
|
+
);
|
|
740
|
+
expect(screen.getByText('auto')).toBeInTheDocument();
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('writes the sentinel when the dot turns a row auto', async () => {
|
|
744
|
+
const setConfig = vi.fn();
|
|
745
|
+
render(
|
|
746
|
+
<ControlPanel
|
|
747
|
+
schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
|
|
748
|
+
config={{ gap: 12 }}
|
|
749
|
+
auto={new Set()}
|
|
750
|
+
setConfig={setConfig}
|
|
751
|
+
/>,
|
|
752
|
+
);
|
|
753
|
+
await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
|
|
754
|
+
expect(setConfig).toHaveBeenCalledWith('gap', auto);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('keeps the sentinel out of a config it is not given an auto set for', async () => {
|
|
758
|
+
const setConfig = vi.fn();
|
|
759
|
+
render(
|
|
760
|
+
<ControlPanel
|
|
761
|
+
schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
|
|
762
|
+
config={{ gap: 12 }}
|
|
763
|
+
setConfig={setConfig}
|
|
764
|
+
/>,
|
|
765
|
+
);
|
|
766
|
+
await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
|
|
767
|
+
expect(setConfig).not.toHaveBeenCalled();
|
|
768
|
+
expect(screen.getByText('auto')).toBeInTheDocument();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('writes the pinned value back when the dot un-autos a row', async () => {
|
|
772
|
+
const setConfig = vi.fn();
|
|
773
|
+
render(
|
|
774
|
+
<ControlPanel
|
|
775
|
+
schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
|
|
776
|
+
config={{ gap: 24 }}
|
|
777
|
+
auto={new Set(['gap'])}
|
|
778
|
+
setConfig={setConfig}
|
|
779
|
+
/>,
|
|
780
|
+
);
|
|
781
|
+
await userEvent.click(screen.getByRole('button', { name: /Gap/ }));
|
|
782
|
+
expect(setConfig).toHaveBeenCalledWith('gap', 24);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('gives a manual row no dot', () => {
|
|
786
|
+
render(
|
|
787
|
+
<ControlPanel
|
|
788
|
+
schema={resolveConfigSchema(f.schema({ seed: f.number(1).manual().range(0, 8) }), [])}
|
|
789
|
+
config={{ seed: 1 }}
|
|
790
|
+
auto={new Set()}
|
|
791
|
+
setConfig={() => {}}
|
|
792
|
+
/>,
|
|
793
|
+
);
|
|
794
|
+
expect(screen.queryByRole('button', { name: /Seed/ })).toBeNull();
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('reports what the instrument reads when a resolver depends on another auto path', () => {
|
|
798
|
+
// `width` is auto with no resolver, so the instrument sees `undefined` and
|
|
799
|
+
// `gap` resolves from that. Re-running gap's resolver against the raw
|
|
800
|
+
// config would instead read the pinned 640 and report a number nobody got.
|
|
801
|
+
render(
|
|
802
|
+
<ControlPanel
|
|
803
|
+
schema={resolveConfigSchema(
|
|
804
|
+
f.schema({
|
|
805
|
+
width: f.number(640),
|
|
806
|
+
gap: f
|
|
807
|
+
.number(12)
|
|
808
|
+
.range(0, 48)
|
|
809
|
+
.auto((c) => (c.width === undefined ? -1 : (c.width as number) / 64)),
|
|
810
|
+
}),
|
|
811
|
+
[],
|
|
812
|
+
)}
|
|
813
|
+
config={{ width: 640, gap: 12 }}
|
|
814
|
+
auto={new Set(['width', 'gap'])}
|
|
815
|
+
setConfig={() => {}}
|
|
816
|
+
/>,
|
|
817
|
+
);
|
|
818
|
+
expect(screen.getByText('auto · -1')).toBeInTheDocument();
|
|
819
|
+
expect(screen.queryByText('auto · 10')).toBeNull();
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('does not repeat a resolved value the control itself already shows', () => {
|
|
823
|
+
render(
|
|
824
|
+
<ControlPanel
|
|
825
|
+
schema={resolveConfigSchema(
|
|
826
|
+
f.schema({ caption: f.string('typed').auto(() => 'eight across') }),
|
|
827
|
+
[],
|
|
828
|
+
)}
|
|
829
|
+
config={{ caption: 'typed' }}
|
|
830
|
+
auto={new Set(['caption'])}
|
|
831
|
+
setConfig={() => {}}
|
|
832
|
+
/>,
|
|
833
|
+
);
|
|
834
|
+
// The field below the readout is already showing it, and the readout slot
|
|
835
|
+
// is one narrow column — a long string wraps it onto two lines.
|
|
836
|
+
expect(screen.getByText('auto')).toBeInTheDocument();
|
|
837
|
+
expect(screen.queryByText(/auto · /)).toBeNull();
|
|
838
|
+
expect(screen.getByDisplayValue('eight across')).toBeInTheDocument();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('draws an auto control at the resolved value, not the value underneath it', () => {
|
|
842
|
+
const { container } = render(
|
|
843
|
+
<ControlPanel
|
|
844
|
+
schema={resolveConfigSchema(
|
|
845
|
+
f.schema({
|
|
846
|
+
gap: f
|
|
847
|
+
.number(12)
|
|
848
|
+
.range(0, 48)
|
|
849
|
+
.auto(() => 18),
|
|
850
|
+
}),
|
|
851
|
+
[],
|
|
852
|
+
)}
|
|
853
|
+
config={{ gap: 12 }}
|
|
854
|
+
auto={new Set(['gap'])}
|
|
855
|
+
setConfig={() => {}}
|
|
856
|
+
/>,
|
|
857
|
+
);
|
|
858
|
+
expect(container.querySelector('input[type=range]')).toHaveValue('18');
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('pins at the value it was drawing, not the stale one underneath', async () => {
|
|
862
|
+
const setConfig = vi.fn();
|
|
863
|
+
render(
|
|
864
|
+
<ControlPanel
|
|
865
|
+
schema={resolveConfigSchema(
|
|
866
|
+
f.schema({
|
|
867
|
+
gap: f
|
|
868
|
+
.number(12)
|
|
869
|
+
.range(0, 48)
|
|
870
|
+
.auto(() => 18),
|
|
871
|
+
}),
|
|
872
|
+
[],
|
|
873
|
+
)}
|
|
874
|
+
config={{ gap: 12 }}
|
|
875
|
+
auto={new Set(['gap'])}
|
|
876
|
+
setConfig={setConfig}
|
|
877
|
+
/>,
|
|
878
|
+
);
|
|
879
|
+
await userEvent.click(screen.getByRole('button', { name: 'Pin Gap' }));
|
|
880
|
+
expect(setConfig).toHaveBeenCalledWith('gap', 18);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// Every kind, because the panel wraps some rows before rendering them and a
|
|
884
|
+
// wrapper that drops the auto props leaves that kind silently unghosted.
|
|
885
|
+
it.each([
|
|
886
|
+
['number', f.number(3).range(0, 8)],
|
|
887
|
+
['slider', f.number(3).range(0, 8).slider()],
|
|
888
|
+
['boolean', f.boolean(true)],
|
|
889
|
+
['enum', f.enum('a', ['a', 'b'])],
|
|
890
|
+
['radio', f.enum('a', ['a', 'b']).radio()],
|
|
891
|
+
['string', f.string('hi')],
|
|
892
|
+
['color', f.color('#3a86ff')],
|
|
893
|
+
])('marks a %s row auto and gives it a dot and a path', (_kind, node) => {
|
|
894
|
+
const { container } = render(
|
|
895
|
+
<ControlPanel
|
|
896
|
+
schema={resolveConfigSchema(f.schema({ thing: node.label('Thing') }), [])}
|
|
897
|
+
config={{ thing: node.default }}
|
|
898
|
+
auto={new Set(['thing'])}
|
|
899
|
+
setConfig={() => {}}
|
|
900
|
+
/>,
|
|
901
|
+
);
|
|
902
|
+
const row = container.querySelector('[data-auto-path="thing"]');
|
|
903
|
+
expect(row).not.toBeNull();
|
|
904
|
+
expect(row?.className).toMatch(/rowAuto/);
|
|
905
|
+
expect(screen.getByRole('button', { name: 'Pin Thing' })).toBeInTheDocument();
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
describe('<ControlPanel> shift-click', () => {
|
|
910
|
+
const panel = (props = {}) =>
|
|
911
|
+
render(
|
|
912
|
+
<ControlPanel
|
|
913
|
+
schema={resolveConfigSchema(f.schema({ gap: f.number(12).range(0, 48) }), [])}
|
|
914
|
+
config={{ gap: 12 }}
|
|
915
|
+
auto={new Set()}
|
|
916
|
+
setConfig={() => {}}
|
|
917
|
+
{...props}
|
|
918
|
+
/>,
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
it('toggles a row on shift-pointerdown and suppresses the control it landed on', () => {
|
|
922
|
+
const setConfig = vi.fn();
|
|
923
|
+
const { container } = panel({ setConfig });
|
|
924
|
+
const track = container.querySelector('input[type=range]') as HTMLInputElement;
|
|
925
|
+
const ev = new PointerEvent('pointerdown', { bubbles: true, cancelable: true, shiftKey: true });
|
|
926
|
+
track.dispatchEvent(ev);
|
|
927
|
+
|
|
928
|
+
expect(setConfig).toHaveBeenCalledWith('gap', auto);
|
|
929
|
+
// A PROXY, not the real claim. jsdom synthesizes neither a label's click
|
|
930
|
+
// retargeting nor a range input's drag, so "the slider did not move" cannot
|
|
931
|
+
// fail here. What is assertable is that the default was suppressed; the
|
|
932
|
+
// browser half is covered by the story.
|
|
933
|
+
expect(ev.defaultPrevented).toBe(true);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('leaves an ordinary pointerdown alone', () => {
|
|
937
|
+
const setConfig = vi.fn();
|
|
938
|
+
const { container } = panel({ setConfig });
|
|
939
|
+
container
|
|
940
|
+
.querySelector('input[type=range]')
|
|
941
|
+
?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
|
|
942
|
+
expect(setConfig).not.toHaveBeenCalled();
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it('ignores shift-pointerdown on a manual row', () => {
|
|
946
|
+
const setConfig = vi.fn();
|
|
947
|
+
const { container } = panel({
|
|
948
|
+
schema: resolveConfigSchema(f.schema({ seed: f.number(1).manual().range(0, 8) }), []),
|
|
949
|
+
config: { seed: 1 },
|
|
950
|
+
setConfig,
|
|
951
|
+
});
|
|
952
|
+
container
|
|
953
|
+
.querySelector('input')
|
|
954
|
+
?.dispatchEvent(
|
|
955
|
+
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, shiftKey: true }),
|
|
956
|
+
);
|
|
957
|
+
expect(setConfig).not.toHaveBeenCalled();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('pins a shift-clicked auto row back at its stored value', () => {
|
|
961
|
+
const setConfig = vi.fn();
|
|
962
|
+
const { container } = panel({ config: { gap: 24 }, auto: new Set(['gap']), setConfig });
|
|
963
|
+
container
|
|
964
|
+
.querySelector('input[type=range]')
|
|
965
|
+
?.dispatchEvent(
|
|
966
|
+
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, shiftKey: true }),
|
|
967
|
+
);
|
|
968
|
+
expect(setConfig).toHaveBeenCalledWith('gap', 24);
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
describe('<ControlPanel> pair', () => {
|
|
973
|
+
const paired = () =>
|
|
974
|
+
resolveConfigSchema(
|
|
975
|
+
f.schema({
|
|
976
|
+
x: f.number(10).label('X').pair('Offset'),
|
|
977
|
+
y: f.number(20).label('Y').pair('Offset'),
|
|
978
|
+
scale: f.number(1).label('Scale'),
|
|
979
|
+
}),
|
|
980
|
+
[],
|
|
981
|
+
);
|
|
982
|
+
const config = { x: 10, y: 20, scale: 1 };
|
|
983
|
+
|
|
984
|
+
it('draws two leaves sharing a pair id as one row named by the pair', () => {
|
|
985
|
+
render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
|
|
986
|
+
const row = screen.getByText('Offset').closest('div') as HTMLElement;
|
|
987
|
+
expect(within(row).getByLabelText('X')).toHaveValue(10);
|
|
988
|
+
expect(within(row).getByLabelText('Y')).toHaveValue(20);
|
|
989
|
+
// The pair names the row, so neither leaf's own name is drawn beside it.
|
|
990
|
+
expect(screen.queryByText('X')).toBeNull();
|
|
991
|
+
expect(screen.queryByText('Y')).toBeNull();
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('writes each cell of a paired row to its own path', () => {
|
|
995
|
+
const setConfig = vi.fn();
|
|
996
|
+
render(<ControlPanel schema={paired()} config={config} setConfig={setConfig} />);
|
|
997
|
+
fireEvent.change(screen.getByLabelText('Y'), { target: { value: '30' } });
|
|
998
|
+
expect(setConfig).toHaveBeenCalledWith('y', 30);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('leaves an unpaired sibling on its own row', () => {
|
|
1002
|
+
render(<ControlPanel schema={paired()} config={config} setConfig={vi.fn()} />);
|
|
1003
|
+
const row = screen.getByText('Offset').closest('div') as HTMLElement;
|
|
1004
|
+
expect(within(row).queryByLabelText('Scale')).toBeNull();
|
|
1005
|
+
expect(screen.getByLabelText('Scale')).toHaveValue(1);
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
describe('<ControlPanel> unit', () => {
|
|
1010
|
+
const degrees = {
|
|
1011
|
+
toDisplay: (stored: number) => (stored * 180) / Math.PI,
|
|
1012
|
+
fromDisplay: (shown: number) => (shown * Math.PI) / 180,
|
|
1013
|
+
suffix: '°',
|
|
1014
|
+
};
|
|
1015
|
+
const angle = (node: ReturnType<typeof f.number>, stored: number, setConfig = vi.fn()) => {
|
|
1016
|
+
render(
|
|
1017
|
+
<ControlPanel
|
|
1018
|
+
schema={resolveConfigSchema(f.schema({ angle: node.label('Angle') }), [])}
|
|
1019
|
+
config={{ angle: stored }}
|
|
1020
|
+
setConfig={setConfig}
|
|
1021
|
+
/>,
|
|
1022
|
+
);
|
|
1023
|
+
return setConfig;
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
it('shows a radians-backed number in its display unit', () => {
|
|
1027
|
+
angle(f.number(0).input().unit(degrees), Math.PI / 2);
|
|
1028
|
+
expect((screen.getByLabelText('Angle') as HTMLInputElement).value).toBe('90');
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it('stores a typed display value back in the canonical unit', () => {
|
|
1032
|
+
const setConfig = angle(f.number(0).input().unit(degrees), Math.PI / 2);
|
|
1033
|
+
fireEvent.change(screen.getByLabelText('Angle'), { target: { value: '45' } });
|
|
1034
|
+
expect(setConfig.mock.calls[0][0]).toBe('angle');
|
|
1035
|
+
expect(setConfig.mock.calls[0][1] as number).toBeCloseTo(Math.PI / 4, 10);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it('round-trips a value edited away and back', () => {
|
|
1039
|
+
// Live config, because the corruption this guards against only shows on
|
|
1040
|
+
// the second edit: what the field stores has to read back as what it showed.
|
|
1041
|
+
function Live() {
|
|
1042
|
+
const [config, setConfig] = useState<Record<string, unknown>>({ angle: Math.PI / 2 });
|
|
1043
|
+
return (
|
|
1044
|
+
<ControlPanel
|
|
1045
|
+
schema={resolveConfigSchema(
|
|
1046
|
+
f.schema({ angle: f.number(0).input().unit(degrees).label('Angle') }),
|
|
1047
|
+
[],
|
|
1048
|
+
)}
|
|
1049
|
+
config={config}
|
|
1050
|
+
setConfig={(path, value) => setConfig((prev) => ({ ...prev, [path]: value }))}
|
|
1051
|
+
/>
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
render(<Live />);
|
|
1055
|
+
const field = () => screen.getByLabelText('Angle') as HTMLInputElement;
|
|
1056
|
+
expect(field().value).toBe('90');
|
|
1057
|
+
fireEvent.change(field(), { target: { value: '45' } });
|
|
1058
|
+
expect(field().value).toBe('45');
|
|
1059
|
+
fireEvent.change(field(), { target: { value: '90' } });
|
|
1060
|
+
expect(field().value).toBe('90');
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('converts a slider’s bounds into the display unit', () => {
|
|
1064
|
+
angle(f.number(0).range(0, Math.PI).unit(degrees), Math.PI / 2);
|
|
1065
|
+
const slider = screen.getByRole('slider') as HTMLInputElement;
|
|
1066
|
+
expect(slider.min).toBe('0');
|
|
1067
|
+
expect(slider.max).toBe('180');
|
|
1068
|
+
expect(slider.value).toBe('90');
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('clamps a typed value against the bounds in display space', () => {
|
|
1072
|
+
// 100 is inside 0..180 degrees and outside 0..π radians, so clamping
|
|
1073
|
+
// against the stored bounds would cut a legal angle down to π.
|
|
1074
|
+
const setConfig = angle(f.number(0).range(0, Math.PI).input().unit(degrees), Math.PI / 2);
|
|
1075
|
+
fireEvent.change(screen.getByLabelText('Angle'), { target: { value: '100' } });
|
|
1076
|
+
expect(setConfig.mock.calls[0][1] as number).toBeCloseTo((100 * Math.PI) / 180, 10);
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
describe('<ControlPanel> color alpha', () => {
|
|
1081
|
+
const panel = (setConfig = vi.fn(), stored = '#336699cc') => {
|
|
1082
|
+
render(
|
|
1083
|
+
<ControlPanel
|
|
1084
|
+
schema={resolveConfigSchema(
|
|
1085
|
+
f.schema({ tint: f.color('#000000ff').alpha().label('Tint') }),
|
|
1086
|
+
[],
|
|
1087
|
+
)}
|
|
1088
|
+
config={{ tint: stored }}
|
|
1089
|
+
setConfig={setConfig}
|
|
1090
|
+
/>,
|
|
1091
|
+
);
|
|
1092
|
+
return setConfig;
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
it('splits the stored hex into a swatch and an alpha track', () => {
|
|
1096
|
+
panel();
|
|
1097
|
+
expect((screen.getByLabelText('Tint') as HTMLInputElement).value).toBe('#336699');
|
|
1098
|
+
expect((screen.getByLabelText('Tint opacity') as HTMLInputElement).value).toBe('0.8');
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it('keeps the stored alpha when the swatch moves', () => {
|
|
1102
|
+
const setConfig = panel();
|
|
1103
|
+
fireEvent.change(screen.getByLabelText('Tint'), { target: { value: '#00ff00' } });
|
|
1104
|
+
expect(setConfig).toHaveBeenCalledWith('tint', '#00ff00cc');
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it('keeps the stored color when the alpha track moves', () => {
|
|
1108
|
+
const setConfig = panel();
|
|
1109
|
+
fireEvent.change(screen.getByLabelText('Tint opacity'), { target: { value: '0.5' } });
|
|
1110
|
+
expect(setConfig).toHaveBeenCalledWith('tint', '#33669980');
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it('leaves a color leaf without alpha as a bare swatch', () => {
|
|
1114
|
+
render(
|
|
1115
|
+
<ControlPanel
|
|
1116
|
+
schema={resolveConfigSchema(f.schema({ tint: f.color('#336699').label('Tint') }), [])}
|
|
1117
|
+
config={{ tint: '#336699' }}
|
|
1118
|
+
setConfig={vi.fn()}
|
|
1119
|
+
/>,
|
|
1120
|
+
);
|
|
1121
|
+
expect(screen.queryByLabelText('Tint opacity')).toBeNull();
|
|
1122
|
+
});
|
|
1123
|
+
});
|