@weasel-js/labkit 1.7.1 → 1.7.2

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.
Files changed (67) hide show
  1. package/dist/chrome/index.js +4 -4
  2. package/dist/{chunk-GAHELLJT.js → chunk-RODIY7HI.js} +7 -7
  3. package/dist/{chunk-GAHELLJT.js.map → chunk-RODIY7HI.js.map} +1 -1
  4. package/dist/{chunk-4KSC7NXL.js → chunk-U6UQVGFP.js} +1984 -1892
  5. package/dist/chunk-U6UQVGFP.js.map +1 -0
  6. package/dist/chunk-WFALF2X2.js +302 -0
  7. package/dist/chunk-WFALF2X2.js.map +1 -0
  8. package/dist/{chunk-JFOJESJF.js → chunk-WWJDPNZZ.js} +22 -22
  9. package/dist/chunk-WWJDPNZZ.js.map +1 -0
  10. package/dist/{chunk-CEGFXTBD.js → chunk-XRX5S5NT.js} +4 -4
  11. package/dist/{chunk-CEGFXTBD.js.map → chunk-XRX5S5NT.js.map} +1 -1
  12. package/dist/{chunk-FB4MIZOO.js → chunk-XZDCEMEL.js} +6 -6
  13. package/dist/chunk-XZDCEMEL.js.map +1 -0
  14. package/dist/controls/index.d.ts +55 -3
  15. package/dist/controls/index.js +3 -2
  16. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +135 -134
  19. package/dist/index.js.map +1 -1
  20. package/dist/layers/index.js +1 -1
  21. package/dist/passthrough/weasel-ui.js +1 -1
  22. package/dist/primitives/index.js +3 -3
  23. package/dist/styles.css +228 -11
  24. package/package.json +11 -11
  25. package/src/annotations/capture.browser.test.ts +9 -3
  26. package/src/canvas/Stage.test.tsx +34 -25
  27. package/src/chrome/labRegions.test.tsx +37 -26
  28. package/src/chrome/regions/PaletteRegion.test.tsx +2 -2
  29. package/src/controls/ControlMatrix.less +245 -0
  30. package/src/controls/ControlMatrix.stories.less +13 -0
  31. package/src/controls/ControlMatrix.stories.tsx +135 -0
  32. package/src/controls/ControlMatrix.test.tsx +206 -0
  33. package/src/controls/ControlMatrix.tsx +446 -0
  34. package/src/controls/ControlPanel.tsx +4 -2
  35. package/src/controls/index.ts +6 -0
  36. package/src/dragdrop/dragDrop.test.tsx +34 -33
  37. package/src/index.ts +6 -0
  38. package/src/lab/Lab.chrome.test.tsx +15 -10
  39. package/src/lab/Lab.nebula.test.tsx +5 -5
  40. package/src/lab/Lab.surface.test.tsx +30 -21
  41. package/src/lab/Lab.test.tsx +75 -74
  42. package/src/lab/Lab.undock.test.tsx +15 -10
  43. package/src/lab/LabHeader.test.tsx +15 -14
  44. package/src/lab/LabPalette.test.tsx +12 -7
  45. package/src/lab/LabShell.less +1 -2
  46. package/src/lab/LabZoom.test.tsx +55 -44
  47. package/src/lab/Workspace.surface.test.tsx +16 -13
  48. package/src/lab/Workspace.test.tsx +32 -27
  49. package/src/overview/OverviewMarks.test.tsx +9 -8
  50. package/src/overview/TrialOverview.test.tsx +18 -17
  51. package/src/primitives/FpsMeter.less +1 -2
  52. package/src/primitives/JobProgress.less +1 -2
  53. package/src/primitives/ScaleIndicator.less +1 -1
  54. package/src/primitives/StatusBar.less +1 -1
  55. package/src/specimen/Specimen.less +1 -0
  56. package/src/styles.less +3 -0
  57. package/src/trial/Trial.annotations.persist.test.tsx +20 -9
  58. package/src/trial/Trial.annotations.test.tsx +46 -41
  59. package/src/trial/Trial.canvas.test.tsx +21 -17
  60. package/src/trial/Trial.config.test.tsx +34 -27
  61. package/src/trial/Trial.loupe.test.tsx +27 -18
  62. package/src/trial/Trial.targets.test.tsx +9 -8
  63. package/src/trial/Trial.test.tsx +51 -46
  64. package/src/trial/Trial.trialId.test.tsx +7 -6
  65. package/dist/chunk-4KSC7NXL.js.map +0 -1
  66. package/dist/chunk-FB4MIZOO.js.map +0 -1
  67. package/dist/chunk-JFOJESJF.js.map +0 -1
@@ -1,9 +1,9 @@
1
1
  .lk-fps-meter {
2
+ .numeric();
2
3
  display: inline-flex;
3
4
  align-items: center;
4
5
  white-space: nowrap;
5
6
  gap: var(--wzl-space-xs);
6
- font-family: var(--wzl-font-mono);
7
7
  font-size: var(--wzl-font-size-sm);
8
8
  color: var(--wzl-fg-muted);
9
9
  }
@@ -14,6 +14,5 @@
14
14
  a 240Hz display reads three digits, and a readout that grows into them
15
15
  pushes whatever shares its row. */
16
16
  min-width: 3ch;
17
- font-variant-numeric: tabular-nums;
18
17
  text-align: right;
19
18
  }
@@ -30,8 +30,7 @@
30
30
  }
31
31
 
32
32
  &__count {
33
- font-variant-numeric: tabular-nums;
34
- font-family: var(--wzl-font-numeric);
33
+ .numeric();
35
34
  white-space: nowrap;
36
35
  }
37
36
 
@@ -1,8 +1,8 @@
1
1
  .lk-scale-indicator {
2
+ .numeric();
2
3
  display: inline-flex;
3
4
  align-items: center;
4
5
  gap: var(--wzl-space-xs);
5
- font-family: var(--wzl-font-mono);
6
6
  font-size: var(--wzl-font-size-sm);
7
7
  color: var(--wzl-fg-muted);
8
8
  }
@@ -1,4 +1,5 @@
1
1
  .lk-status-bar {
2
+ .numeric();
2
3
  display: flex;
3
4
  align-items: center;
4
5
  /* One line, always. A bar that grows a second line takes it from the canvas
@@ -11,7 +12,6 @@
11
12
  border-top: 1px solid var(--wzl-border);
12
13
  font-size: var(--wzl-font-size-sm);
13
14
  color: var(--wzl-fg-muted);
14
- font-family: var(--wzl-font-mono);
15
15
  }
16
16
 
17
17
  .lk-status-bar-section {
@@ -78,6 +78,7 @@
78
78
  }
79
79
 
80
80
  .lk-specimen__pane {
81
+ .numeric();
81
82
  flex: none;
82
83
  width: var(--lk-specimen-pane-w);
83
84
  padding: var(--wzl-space-sm);
package/src/styles.less CHANGED
@@ -2,6 +2,8 @@
2
2
  // Left as a CSS @import for the consumer's bundler to resolve; build-css.mjs
3
3
  // swaps it for the faces themselves, pointed at dist/fonts/.
4
4
  @import (css) '@weasel-js/theme/faces.css';
5
+ // The numeric face's one definition, taken as a mixin: `.numeric();`.
6
+ @import (reference, less) '@weasel-js/theme/numeric.module.css';
5
7
  @import './theme/base.less';
6
8
  @import './lab/Workspace.less';
7
9
  @import './lab/LabShell.less';
@@ -22,6 +24,7 @@
22
24
  @import './chrome/regions/PaletteRegion.less';
23
25
  @import './chrome/regions/SidebarRegion.less';
24
26
  @import './controls/ControlPanel.less';
27
+ @import './controls/ControlMatrix.less';
25
28
  @import './canvas/CanvasStack.less';
26
29
  @import './canvas/Stage.less';
27
30
  @import './canvas/LinkedCursor.less';
@@ -4,7 +4,8 @@
4
4
  * is written on a trailing debounce: a write per scene notification re-renders
5
5
  * every trial on every frame of a drag.
6
6
  */
7
- import { act, render, screen, waitFor } from '@testing-library/react';
7
+ import { act, screen, waitFor } from '@testing-library/react';
8
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
8
9
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
9
10
  import { useAnnotations } from '../annotations/AnnotationsContext';
10
11
  import type { AnnotationsApi, SerializedAnnotations } from '../annotations/types';
@@ -83,7 +84,7 @@ const selfStoring = defineInstrument<Record<string, never>, Record<string, never
83
84
 
84
85
  async function mount(backing: Map<string, unknown>, which: InstrumentList[number] = inspector) {
85
86
  api = null;
86
- const view = render(
87
+ const view = await renderSettled(
87
88
  <Lab
88
89
  instruments={[which]}
89
90
  defaultInstrument={which.name}
@@ -121,7 +122,10 @@ describe('a trial that persists its marks', () => {
121
122
  it('brings them back on a remount from the same storage', async () => {
122
123
  const backing = new Map<string, unknown>();
123
124
  const first = await mount(backing);
124
- const id = marks().add(MARK, { angle: 12 });
125
+ let id = '';
126
+ act(() => {
127
+ id = marks().add(MARK, { angle: 12 });
128
+ });
125
129
  first.unmount();
126
130
  await waitFor(() => expect(storedTrial(backing)?.annotations).toBeDefined());
127
131
 
@@ -139,7 +143,9 @@ describe('a trial that persists its marks', () => {
139
143
  const backing = new Map<string, unknown>();
140
144
  await mount(backing);
141
145
  vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
142
- marks().add(MARK);
146
+ act(() => {
147
+ marks().add(MARK);
148
+ });
143
149
  await act(() => vi.advanceTimersByTimeAsync(20));
144
150
  // Not a claim about either interval — a claim that a store write is not
145
151
  // synchronous with a scene notification.
@@ -151,8 +157,11 @@ describe('a trial that persists its marks', () => {
151
157
  it('flushes on unmount, so the last mark before a close is not lost', async () => {
152
158
  const backing = new Map<string, unknown>();
153
159
  const first = await mount(backing);
154
- const id = marks().add(MARK);
155
- marks().add({ ...MARK, frac: { x: 0.6, y: 0.6, w: 0.1, h: 0.1 } });
160
+ let id = '';
161
+ act(() => {
162
+ id = marks().add(MARK);
163
+ marks().add({ ...MARK, frac: { x: 0.6, y: 0.6, w: 0.1, h: 0.1 } });
164
+ });
156
165
  first.unmount();
157
166
  // Nothing was written before the unmount: neither debounce had elapsed.
158
167
  await waitFor(() => expect(storedTrial(backing)?.annotations).toBeDefined());
@@ -165,7 +174,9 @@ describe('a trial that persists its marks', () => {
165
174
  it('leaves the slot alone when the instrument owns the storage', async () => {
166
175
  const backing = new Map<string, unknown>();
167
176
  const first = await mount(backing, selfStoring);
168
- marks().add(MARK);
177
+ act(() => {
178
+ marks().add(MARK);
179
+ });
169
180
  first.unmount();
170
181
  await waitFor(() => expect(owned?.version).toBe(1));
171
182
  await waitFor(() => expect(storedTrial(backing)).toBeDefined());
@@ -181,7 +192,7 @@ describe('a trial that persists its marks', () => {
181
192
  gate = new Promise((resolve) => {
182
193
  release = resolve;
183
194
  });
184
- render(
195
+ await renderSettled(
185
196
  <Lab
186
197
  instruments={[selfStoring]}
187
198
  defaultInstrument="SelfStoring"
@@ -199,7 +210,7 @@ describe('a trial that persists its marks', () => {
199
210
  gate = new Promise((resolve) => {
200
211
  release = resolve;
201
212
  });
202
- act(() => lab?.addTrial('SelfStoring'));
213
+ await act(async () => lab?.addTrial('SelfStoring'));
203
214
  expect(document.querySelectorAll('.lk-trial--loading')).toHaveLength(1);
204
215
  await act(async () => release());
205
216
  await waitFor(() => expect(screen.getAllByTestId('pane')).toHaveLength(2));
@@ -4,7 +4,8 @@
4
4
  * follow. The overlay's own geometry is covered in
5
5
  * `annotations/Annotations.overlay.test.tsx`.
6
6
  */
7
- import { act, fireEvent, render, screen, within } from '@testing-library/react';
7
+ import { act, fireEvent, screen, within } from '@testing-library/react';
8
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
8
9
  import { beforeAll, describe, expect, it, vi } from 'vitest';
9
10
  import { useAnnotations, useAnnotationsOptional } from '../annotations/AnnotationsContext';
10
11
  import type { AnnotationsApi } from '../annotations/types';
@@ -53,8 +54,8 @@ const plain = defineInstrument<Record<string, never>, Record<string, never>>({
53
54
  });
54
55
 
55
56
  describe('an instrument that declares annotations', () => {
56
- it('gets a drawing palette', () => {
57
- render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
57
+ it('gets a drawing palette', async () => {
58
+ await renderSettled(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
58
59
  for (const label of [
59
60
  'Interact',
60
61
  'Select',
@@ -69,7 +70,7 @@ describe('an instrument that declares annotations', () => {
69
70
  }
70
71
  });
71
72
 
72
- it('gets only the tools it names', () => {
73
+ it('gets only the tools it names', async () => {
73
74
  const narrow = defineInstrument<Record<string, never>, Record<string, never>>({
74
75
  ...annotating,
75
76
  name: 'Narrow',
@@ -78,20 +79,20 @@ describe('an instrument that declares annotations', () => {
78
79
  tools: ['pointer', 'select'],
79
80
  },
80
81
  });
81
- render(<Lab instruments={[narrow]} defaultInstrument="Narrow" />);
82
+ await renderSettled(<Lab instruments={[narrow]} defaultInstrument="Narrow" />);
82
83
  expect(screen.getByRole('button', { name: 'Interact' })).toBeTruthy();
83
84
  expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
84
85
  expect(screen.queryByRole('button', { name: 'Freehand' })).toBeNull();
85
86
  expect(screen.queryByRole('button', { name: 'Rectangle' })).toBeNull();
86
87
  });
87
88
 
88
- it('puts the store in reach of its own render', () => {
89
- render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
89
+ it('puts the store in reach of its own render', async () => {
90
+ await renderSettled(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
90
91
  expect(screen.getByTestId('pane').dataset.marks).toBe('0');
91
92
  });
92
93
 
93
- it('starts in interact, so a first click reaches the instrument', () => {
94
- render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
94
+ it('starts in interact, so a first click reaches the instrument', async () => {
95
+ await renderSettled(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
95
96
  expect(screen.getByRole('button', { name: 'Interact' }).getAttribute('aria-current')).toBe(
96
97
  'true',
97
98
  );
@@ -112,28 +113,28 @@ function CaptureLab() {
112
113
  }
113
114
 
114
115
  describe('the annotation tool', () => {
115
- it("is offered in the lab's rail, not in a trial's", () => {
116
- render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
116
+ it("is offered in the lab's rail, not in a trial's", async () => {
117
+ await renderSettled(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
117
118
  expect(screen.getAllByRole('button', { name: 'Rectangle' })).toHaveLength(1);
118
119
  const trial = screen.getByRole('region', { name: /trial/i });
119
120
  expect(within(trial).queryByRole('button', { name: 'Rectangle' })).toBeNull();
120
121
  });
121
122
 
122
- it('is one tool across every trial', () => {
123
+ it('is one tool across every trial', async () => {
123
124
  // A tool is what the hand is holding, not a property of a picture: picking
124
125
  // Rectangle once has to arm it wherever the next mark is drawn.
125
126
  labRef = null;
126
- render(
127
+ await renderSettled(
127
128
  <Lab instruments={[annotating]} defaultInstrument="Annotating">
128
129
  <CaptureLab />
129
130
  </Lab>,
130
131
  );
131
- act(() => labRef?.addTrial('Annotating'));
132
+ await act(async () => labRef?.addTrial('Annotating'));
132
133
  const panes = screen.getAllByTestId('pane');
133
134
  expect(panes).toHaveLength(2);
134
135
  for (const p of panes) expect(p.dataset.tool).toBe('pointer');
135
136
 
136
- act(() => {
137
+ await act(async () => {
137
138
  fireEvent.click(screen.getByRole('button', { name: 'Rectangle' }));
138
139
  });
139
140
  for (const p of screen.getAllByTestId('pane')) expect(p.dataset.tool).toBe('rect');
@@ -141,39 +142,39 @@ describe('the annotation tool', () => {
141
142
  });
142
143
 
143
144
  describe('the trial undo chrome over marks', () => {
144
- it('gets undo and redo without the instrument declaring `undo`', () => {
145
+ it('gets undo and redo without the instrument declaring `undo`', async () => {
145
146
  // Weasel history is the authority for marks, so the capability that
146
147
  // creates them is what earns the buttons.
147
- render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
148
+ await renderSettled(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
148
149
  expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy();
149
150
  expect(screen.getByRole('button', { name: 'Redo' })).toBeTruthy();
150
151
  });
151
152
 
152
- it('takes back a mark, and puts it back', () => {
153
- render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
153
+ it('takes back a mark, and puts it back', async () => {
154
+ await renderSettled(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
154
155
  const undo = screen.getByRole('button', { name: 'Undo' });
155
156
  expect(undo.getAttribute('aria-disabled') ?? undo.getAttribute('disabled')).not.toBeNull();
156
157
 
157
- act(() => {
158
+ await act(async () => {
158
159
  marks().add({ target: 'pane', kind: 'rect', frac: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } });
159
160
  });
160
161
  expect(screen.getByTestId('pane').dataset.marks).toBe('1');
161
162
 
162
- act(() => {
163
+ await act(async () => {
163
164
  fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
164
165
  });
165
166
  expect(screen.getByTestId('pane').dataset.marks).toBe('0');
166
167
 
167
- act(() => {
168
+ await act(async () => {
168
169
  fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
169
170
  });
170
171
  expect(screen.getByTestId('pane').dataset.marks).toBe('1');
171
172
  });
172
173
 
173
- it('lists the marks in a sidebar panel', () => {
174
- render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
174
+ it('lists the marks in a sidebar panel', async () => {
175
+ await renderSettled(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
175
176
  expect(screen.getByText('Marks')).toBeTruthy();
176
- act(() => {
177
+ await act(async () => {
177
178
  marks().add({ target: 'pane', kind: 'line', frac: { x: 0, y: 0, w: 0.5, h: 0 } });
178
179
  });
179
180
  expect(screen.getAllByRole('listitem')).toHaveLength(1);
@@ -190,7 +191,7 @@ function GrowingPane({ n, grow }: { n: number; grow: () => void }) {
190
191
  }
191
192
 
192
193
  describe('targets that follow the trial state', () => {
193
- it('reach a reader of the store once the change commits', () => {
194
+ it('reach a reader of the store once the change commits', async () => {
194
195
  // The store reads the committed state, so a reader rendering in the same
195
196
  // pass as the change sees the one before; the trial tells it to read again.
196
197
  const growing = defineInstrument<{ n: number }, Record<string, never>>({
@@ -209,9 +210,9 @@ describe('targets that follow the trial state', () => {
209
210
  })),
210
211
  },
211
212
  });
212
- render(<Lab instruments={[growing]} defaultInstrument="Growing" />);
213
+ await renderSettled(<Lab instruments={[growing]} defaultInstrument="Growing" />);
213
214
  expect(screen.getByTestId('growing').dataset.targets).toBe('1');
214
- act(() => {
215
+ await act(async () => {
215
216
  fireEvent.click(screen.getByTestId('growing'));
216
217
  });
217
218
  expect(screen.getByTestId('growing').textContent).toBe('2');
@@ -232,30 +233,34 @@ describe("the capability's selection mode", () => {
232
233
  ...(selection ? { selection } : {}),
233
234
  },
234
235
  });
235
- const selectBoth = (): readonly string[] => {
236
- const onA = marks().add({ target: 'a', kind: 'rect', frac: { x: 0, y: 0, w: 0.1, h: 0.1 } });
237
- const onB = marks().add({ target: 'b', kind: 'rect', frac: { x: 0, y: 0, w: 0.1, h: 0.1 } });
238
- act(() => marks().setSelection([onA, onB]));
239
- return [onA, onB];
236
+ const selectBoth = async (): Promise<readonly string[]> => {
237
+ let both: [string, string] = ['', ''];
238
+ await act(async () => {
239
+ const onA = marks().add({ target: 'a', kind: 'rect', frac: { x: 0, y: 0, w: 0.1, h: 0.1 } });
240
+ const onB = marks().add({ target: 'b', kind: 'rect', frac: { x: 0, y: 0, w: 0.1, h: 0.1 } });
241
+ marks().setSelection([onA, onB]);
242
+ both = [onA, onB];
243
+ });
244
+ return both;
240
245
  };
241
246
 
242
- it('keeps the selection to one target by default', () => {
243
- render(<Lab instruments={[twoTargets()]} defaultInstrument="Two" />);
244
- const [onA] = selectBoth();
247
+ it('keeps the selection to one target by default', async () => {
248
+ await renderSettled(<Lab instruments={[twoTargets()]} defaultInstrument="Two" />);
249
+ const [onA] = await selectBoth();
245
250
  expect(marks().selection()).toEqual([onA]);
246
251
  });
247
252
 
248
- it("reaches the trial's store when the instrument asks for per-target", () => {
249
- render(<Lab instruments={[twoTargets('per-target')]} defaultInstrument="Two" />);
253
+ it("reaches the trial's store when the instrument asks for per-target", async () => {
254
+ await renderSettled(<Lab instruments={[twoTargets('per-target')]} defaultInstrument="Two" />);
250
255
  expect(marks().selection()).toEqual([]);
251
- const both = selectBoth();
256
+ const both = await selectBoth();
252
257
  expect(marks().selection()).toEqual(both);
253
258
  });
254
259
  });
255
260
 
256
261
  describe('an instrument that declares none', () => {
257
- it('gets no store and no palette', () => {
258
- render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
262
+ it('gets no store and no palette', async () => {
263
+ await renderSettled(<Lab instruments={[plain]} defaultInstrument="Plain" />);
259
264
  expect(screen.getByTestId('plain').dataset.hasApi).toBe('false');
260
265
  expect(screen.queryByRole('button', { name: 'Rectangle' })).toBeNull();
261
266
  });
@@ -1,5 +1,6 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { act, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
3
4
  import { beforeAll, describe, expect, it, vi } from 'vitest';
4
5
  import { auto } from '../config/auto';
5
6
  import { f } from '../config/builder';
@@ -7,6 +8,9 @@ import { defineInstrument } from '../instrument/defineInstrument';
7
8
  import type { Instrument } from '../instrument/types';
8
9
  import { Lab } from '../lab/Lab';
9
10
 
11
+ /** Waits one animation frame inside act, so what the frame loop sets commits there. */
12
+ const nextFrame = () => act(() => new Promise((r) => requestAnimationFrame(() => r(null))));
13
+
10
14
  interface St {
11
15
  n: number;
12
16
  }
@@ -44,8 +48,8 @@ function makeInstrument(
44
48
  }
45
49
 
46
50
  describe('an instrument that draws', () => {
47
- it('still renders its own DOM, as an overlay over the canvas', () => {
48
- const { container } = render(
51
+ it('still renders its own DOM, as an overlay over the canvas', async () => {
52
+ const { container } = await renderSettled(
49
53
  <Lab instruments={[makeInstrument(() => undefined)]} defaultInstrument="Probe" />,
50
54
  );
51
55
  expect(screen.getByTestId('readout')).toHaveTextContent('n = 7');
@@ -57,8 +61,8 @@ describe('an instrument that draws', () => {
57
61
 
58
62
  it('draws with the camera applied, so world geometry lands where the view says', async () => {
59
63
  const draw = vi.fn();
60
- render(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
61
- await new Promise((r) => requestAnimationFrame(() => r(null)));
64
+ await renderSettled(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
65
+ await nextFrame();
62
66
 
63
67
  expect(draw).toHaveBeenCalled();
64
68
  expect(ctxSpies.translate).toHaveBeenCalledWith(30, 40);
@@ -67,8 +71,8 @@ describe('an instrument that draws', () => {
67
71
 
68
72
  it('passes zoom in the args so a layer can keep line widths unscaled', async () => {
69
73
  const draw = vi.fn();
70
- render(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
71
- await new Promise((r) => requestAnimationFrame(() => r(null)));
74
+ await renderSettled(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
75
+ await nextFrame();
72
76
 
73
77
  expect(draw).toHaveBeenCalledWith(
74
78
  expect.anything(),
@@ -93,10 +97,10 @@ describe('an auto config path', () => {
93
97
  render: () => null,
94
98
  canvas: { layers: [{ id: 'main', draw }] },
95
99
  });
96
- const { container } = render(
100
+ const { container } = await renderSettled(
97
101
  <Lab instruments={[instrument as Instrument]} defaultInstrument="Auto" />,
98
102
  );
99
- await new Promise((r) => requestAnimationFrame(() => r(null)));
103
+ await nextFrame();
100
104
 
101
105
  expect(draw).toHaveBeenCalledWith(
102
106
  expect.anything(),
@@ -111,11 +115,11 @@ describe('an auto config path', () => {
111
115
  });
112
116
 
113
117
  describe('LabProps.instruments', () => {
114
- it('accepts a typed instrument with no cast', () => {
118
+ it('accepts a typed instrument with no cast', async () => {
115
119
  // The assignment is the assertion: this file fails to compile if
116
120
  // `defineInstrument<St, ...>`'s result stops being assignable.
117
121
  const typed = makeInstrument(() => undefined);
118
- render(<Lab instruments={[typed]} defaultInstrument="Probe" />);
122
+ await renderSettled(<Lab instruments={[typed]} defaultInstrument="Probe" />);
119
123
  expect(screen.getByTestId('readout')).toBeInTheDocument();
120
124
  });
121
125
 
@@ -132,8 +136,8 @@ describe('LabProps.instruments', () => {
132
136
  const instrument = makeInstrument(vi.fn(), {
133
137
  worldSpec: { origin: { x: 0.5, y: 0.5 }, yAxis: 'up' },
134
138
  });
135
- render(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
136
- await new Promise((r) => requestAnimationFrame(() => r(null)));
139
+ await renderSettled(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
140
+ await nextFrame();
137
141
 
138
142
  // Origin (400,300) plus the instrument's pan (30,40); y runs up.
139
143
  expect(ctxSpies.translate).toHaveBeenCalledWith(430, 340);
@@ -156,9 +160,9 @@ describe('LabProps.instruments', () => {
156
160
  pan: { x: width / 4, y: height / 4 },
157
161
  }),
158
162
  });
159
- render(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
160
- await new Promise((r) => requestAnimationFrame(() => r(null)));
161
- await new Promise((r) => requestAnimationFrame(() => r(null)));
163
+ await renderSettled(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
164
+ await nextFrame();
165
+ await nextFrame();
162
166
 
163
167
  expect(ctxSpies.translate).toHaveBeenCalledWith(200, 150);
164
168
  vi.restoreAllMocks();
@@ -182,7 +186,7 @@ describe('LabProps.instruments', () => {
182
186
  },
183
187
  layers: { ids: ['grid', 'glyph'] },
184
188
  });
185
- render(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
189
+ await renderSettled(<Lab instruments={[instrument]} defaultInstrument="Probe" />);
186
190
  expect([...seen]).toEqual(['grid', 'glyph']);
187
191
 
188
192
  await userEvent.click(screen.getByRole('checkbox', { name: 'Show grid' }));
@@ -1,4 +1,5 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { fireEvent, screen } from '@testing-library/react';
2
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
2
3
  import { describe, expect, it } from 'vitest';
3
4
  import { auto } from '../config/auto';
4
5
  import { f } from '../config/builder';
@@ -27,19 +28,19 @@ const legacyInstrument = defineInstrument({
27
28
  });
28
29
 
29
30
  describe('a trial renders its instrument config', () => {
30
- it('renders a builder schema into the settings sidebar', () => {
31
- render(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
31
+ it('renders a builder schema into the settings sidebar', async () => {
32
+ await renderSettled(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
32
33
  expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
33
34
  expect(screen.getByText('Grid spacing')).toBeInTheDocument();
34
35
  });
35
36
 
36
- it('renders a legacy ConfigField list through the same path', () => {
37
- render(<Lab instruments={[legacyInstrument]} defaultInstrument="Legacy" />);
37
+ it('renders a legacy ConfigField list through the same path', async () => {
38
+ await renderSettled(<Lab instruments={[legacyInstrument]} defaultInstrument="Legacy" />);
38
39
  expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
39
40
  });
40
41
 
41
- it('writes a config change back to the trial', () => {
42
- render(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
42
+ it('writes a config change back to the trial', async () => {
43
+ await renderSettled(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
43
44
  const checkbox = screen.getByLabelText('Show grid') as HTMLInputElement;
44
45
  expect(checkbox.checked).toBe(true);
45
46
  fireEvent.click(checkbox);
@@ -48,7 +49,7 @@ describe('a trial renders its instrument config', () => {
48
49
  });
49
50
 
50
51
  describe('lab-wide config seams', () => {
51
- it('applies a lab-wide rule before labkit inference', () => {
52
+ it('applies a lab-wide rule before labkit inference', async () => {
52
53
  const inst = defineInstrument({
53
54
  name: 'Ruled',
54
55
  config: f.schema({ tintColor: f.value('#ffffff') }),
@@ -56,22 +57,24 @@ describe('lab-wide config seams', () => {
56
57
  render: () => null,
57
58
  });
58
59
  const colorByName: ConfigRule = (ctx) => (ctx.key.endsWith('Color') ? { kind: 'color' } : null);
59
- render(<Lab instruments={[inst]} defaultInstrument="Ruled" configRules={[colorByName]} />);
60
+ await renderSettled(
61
+ <Lab instruments={[inst]} defaultInstrument="Ruled" configRules={[colorByName]} />,
62
+ );
60
63
  expect(screen.getByLabelText('Tint color')).toHaveAttribute('type', 'color');
61
64
  });
62
65
 
63
- it('without the rule the same leaf falls back to a text input', () => {
66
+ it('without the rule the same leaf falls back to a text input', async () => {
64
67
  const inst = defineInstrument({
65
68
  name: 'Unruled',
66
69
  config: f.schema({ tintColor: f.value('#ffffff') }),
67
70
  initialState: () => ({}),
68
71
  render: () => null,
69
72
  });
70
- render(<Lab instruments={[inst]} defaultInstrument="Unruled" />);
73
+ await renderSettled(<Lab instruments={[inst]} defaultInstrument="Unruled" />);
71
74
  expect(screen.getByLabelText('Tint color')).toHaveAttribute('type', 'text');
72
75
  });
73
76
 
74
- it('supplies a lab-wide control for a kind labkit does not ship', () => {
77
+ it('supplies a lab-wide control for a kind labkit does not ship', async () => {
75
78
  const inst = defineInstrument({
76
79
  name: 'Custom',
77
80
  config: f.schema({ offset: f.custom('vector2', { x: 3 }) }),
@@ -81,29 +84,31 @@ describe('lab-wide config seams', () => {
81
84
  const vector2: ControlRenderer = (ctx) => (
82
85
  <span>vec:{String((ctx.value as { x: number }).x)}</span>
83
86
  );
84
- render(<Lab instruments={[inst]} defaultInstrument="Custom" controls={{ vector2 }} />);
87
+ await renderSettled(
88
+ <Lab instruments={[inst]} defaultInstrument="Custom" controls={{ vector2 }} />,
89
+ );
85
90
  expect(screen.getByText('vec:3')).toBeInTheDocument();
86
91
  });
87
92
 
88
- it('shows the placeholder when no control is supplied for that kind', () => {
93
+ it('shows the placeholder when no control is supplied for that kind', async () => {
89
94
  const inst = defineInstrument({
90
95
  name: 'Unwired',
91
96
  config: f.schema({ offset: f.custom('vector2', { x: 3 }) }),
92
97
  initialState: () => ({}),
93
98
  render: () => null,
94
99
  });
95
- render(<Lab instruments={[inst]} defaultInstrument="Unwired" />);
100
+ await renderSettled(<Lab instruments={[inst]} defaultInstrument="Unwired" />);
96
101
  expect(screen.getByText(/vector2/)).toBeInTheDocument();
97
102
  });
98
103
 
99
- it('omits the settings section for an instrument with no config', () => {
104
+ it('omits the settings section for an instrument with no config', async () => {
100
105
  const inst = defineInstrument({
101
106
  name: 'Bare',
102
107
  defaultConfig: () => ({}),
103
108
  initialState: () => ({}),
104
109
  render: () => null,
105
110
  });
106
- render(<Lab instruments={[inst]} defaultInstrument="Bare" />);
111
+ await renderSettled(<Lab instruments={[inst]} defaultInstrument="Bare" />);
107
112
  expect(screen.queryByText('Settings')).not.toBeInTheDocument();
108
113
  });
109
114
  });
@@ -129,9 +134,9 @@ describe('a nested config value, end to end', () => {
129
134
  return { instrument, seen };
130
135
  };
131
136
 
132
- it('renders a nested leaf and writes the change back into the tree', () => {
137
+ it('renders a nested leaf and writes the change back into the tree', async () => {
133
138
  const { instrument } = nestedInstrument();
134
- render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
139
+ await renderSettled(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
135
140
  expect(screen.getByText('20')).toBeInTheDocument();
136
141
  fireEvent.change(screen.getByLabelText('Cell size'), {
137
142
  target: { value: '40' },
@@ -139,9 +144,9 @@ describe('a nested config value, end to end', () => {
139
144
  expect(screen.getByText('40')).toBeInTheDocument();
140
145
  });
141
146
 
142
- it('hands onConfigChange the whole tree, with the sibling branch intact', () => {
147
+ it('hands onConfigChange the whole tree, with the sibling branch intact', async () => {
143
148
  const { instrument, seen } = nestedInstrument();
144
- render(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
149
+ await renderSettled(<Lab instruments={[instrument]} defaultInstrument="Nested" />);
145
150
  fireEvent.change(screen.getByLabelText('Cell size'), {
146
151
  target: { value: '40' },
147
152
  });
@@ -186,22 +191,22 @@ describe('a config written as auto', () => {
186
191
  return { instrument, seen };
187
192
  };
188
193
 
189
- it('hands onConfigChange the resolved config, never the sentinel', () => {
194
+ it('hands onConfigChange the resolved config, never the sentinel', async () => {
190
195
  const { instrument, seen } = unpinnable();
191
- render(<Lab instruments={[instrument]} defaultInstrument="Unpinnable" />);
196
+ await renderSettled(<Lab instruments={[instrument]} defaultInstrument="Unpinnable" />);
192
197
  fireEvent.click(screen.getByText('unpin gap'));
193
198
  expect(seen.at(-1)?.config).toEqual({ width: 432, gap: 18 });
194
199
  expect(seen.at(-1)?.prev).toEqual({ width: 432, gap: 12 });
195
200
  });
196
201
 
197
- it('keeps the state that write derived free of the sentinel', () => {
202
+ it('keeps the state that write derived free of the sentinel', async () => {
198
203
  const { instrument } = unpinnable();
199
- render(<Lab instruments={[instrument]} defaultInstrument="Unpinnable" />);
204
+ await renderSettled(<Lab instruments={[instrument]} defaultInstrument="Unpinnable" />);
200
205
  fireEvent.click(screen.getByText('unpin gap'));
201
206
  expect(screen.getByText('18')).toBeInTheDocument();
202
207
  });
203
208
 
204
- it("shows the trial's unpinned path in the settings panel", () => {
209
+ it("shows the trial's unpinned path in the settings panel", async () => {
205
210
  const instrument = defineInstrument({
206
211
  name: 'Ghosted',
207
212
  config: f.schema({
@@ -215,7 +220,9 @@ describe('a config written as auto', () => {
215
220
  initialState: () => ({}),
216
221
  render: () => null,
217
222
  });
218
- const { container } = render(<Lab instruments={[instrument]} defaultInstrument="Ghosted" />);
223
+ const { container } = await renderSettled(
224
+ <Lab instruments={[instrument]} defaultInstrument="Ghosted" />,
225
+ );
219
226
  expect(screen.getByRole('button', { name: /Gap/ })).toHaveAttribute('aria-pressed', 'false');
220
227
  expect(screen.getByRole('button', { name: /Width/ })).toHaveAttribute('aria-pressed', 'true');
221
228
  // 432 / 24. An auto row holds what the resolver decided rather than