@weasel-js/labkit 1.0.4 → 1.2.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 +33 -6
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
- package/dist/canvas/index.d.ts +2 -1
- package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
- package/dist/chunk-73KA7WBO.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
- package/dist/chunk-BOHF3PQO.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/chunk-THBG7FQZ.js +167 -0
- package/dist/chunk-THBG7FQZ.js.map +1 -0
- package/dist/chunk-VUU5UXHE.js +491 -0
- package/dist/chunk-VUU5UXHE.js.map +1 -0
- package/dist/dragdrop/index.d.ts +2 -1
- package/dist/index.d.ts +211 -151
- package/dist/index.js +317 -169
- 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 +3 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +29 -6
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +12 -12
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +31 -13
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +3 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/canvas/AGENTS.md +14 -2
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +70 -0
- package/src/index.ts +24 -7
- package/src/instrument/SineWave.smoke.test.tsx +2 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +30 -6
- 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.less +16 -0
- package/src/lab/Lab.stories.tsx +6 -7
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +50 -50
- package/src/lab/LabContext.ts +12 -12
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
- package/src/lab/index.ts +2 -2
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +21 -29
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -41
- package/src/state/store.ts +165 -102
- package/src/state/types.ts +32 -13
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +2 -2
- 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 +157 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/trial/DefaultStatusBar.tsx +19 -0
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/trial/Trial.job.test.tsx +75 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
- package/src/{workspace → trial}/index.ts +13 -13
- package/src/{workspace → trial}/slotTypes.ts +18 -17
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
- package/dist/chunk-BAPZPDDA.js.map +0 -1
- package/dist/chunk-DWV7SFKR.js.map +0 -1
- package/dist/chunk-KSTEW2AF.js +0 -367
- package/dist/chunk-KSTEW2AF.js.map +0 -1
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/DefaultStatusBar.tsx +0 -18
- package/src/workspace/workspaceOps.ts +0 -102
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { act, render, waitFor } from '@testing-library/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { JobCapability, JobEvent, JobHandle } from './types';
|
|
5
|
+
import { useJob } from './useJob';
|
|
6
|
+
|
|
7
|
+
interface State {
|
|
8
|
+
items: number[];
|
|
9
|
+
}
|
|
10
|
+
interface Config {
|
|
11
|
+
n: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Yields 0..n-1, pausing between each so a test can cancel mid-run. */
|
|
15
|
+
async function* counter(
|
|
16
|
+
n: number,
|
|
17
|
+
signal: AbortSignal,
|
|
18
|
+
failAt?: number,
|
|
19
|
+
): AsyncGenerator<JobEvent<number>> {
|
|
20
|
+
yield { kind: 'total', total: n };
|
|
21
|
+
for (let i = 0; i < n; i++) {
|
|
22
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
23
|
+
if (signal.aborted) return;
|
|
24
|
+
if (i === failAt) {
|
|
25
|
+
yield { kind: 'failed', index: i, error: 'frame died' };
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
yield { kind: 'item', item: i };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const capability = (failAt?: number): JobCapability<State, Config, number> => ({
|
|
33
|
+
run: ({ config, signal }) => counter(config.n, signal, failAt),
|
|
34
|
+
onItem: (item, state) => ({ items: [...state.items, item] }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
interface HarnessProps {
|
|
38
|
+
capability: JobCapability<State, Config, number>;
|
|
39
|
+
config: Config;
|
|
40
|
+
onHandle: (h: JobHandle, s: State) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Harness({ capability: cap, config, onHandle }: HarnessProps) {
|
|
44
|
+
const [state, setState] = useState<State>({ items: [] });
|
|
45
|
+
const job = useJob({ capability: cap, config, state, setState });
|
|
46
|
+
onHandle(job, state);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('useJob', () => {
|
|
51
|
+
it('folds each item into state and counts progress', async () => {
|
|
52
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
53
|
+
render(
|
|
54
|
+
<Harness
|
|
55
|
+
capability={capability()}
|
|
56
|
+
config={{ n: 3 }}
|
|
57
|
+
onHandle={(h, s) => {
|
|
58
|
+
seen.handle = h;
|
|
59
|
+
seen.state = s;
|
|
60
|
+
}}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
act(() => seen.handle?.start());
|
|
64
|
+
await waitFor(() => expect(seen.handle?.status).toBe('done'));
|
|
65
|
+
expect(seen.state.items).toEqual([0, 1, 2]);
|
|
66
|
+
expect(seen.handle?.done).toBe(3);
|
|
67
|
+
expect(seen.handle?.total).toBe(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('counts a failed item without ending the run', async () => {
|
|
71
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
72
|
+
render(
|
|
73
|
+
<Harness
|
|
74
|
+
capability={capability(1)}
|
|
75
|
+
config={{ n: 3 }}
|
|
76
|
+
onHandle={(h, s) => {
|
|
77
|
+
seen.handle = h;
|
|
78
|
+
seen.state = s;
|
|
79
|
+
}}
|
|
80
|
+
/>,
|
|
81
|
+
);
|
|
82
|
+
act(() => seen.handle?.start());
|
|
83
|
+
await waitFor(() => expect(seen.handle?.status).toBe('done'));
|
|
84
|
+
expect(seen.state.items).toEqual([0, 2]);
|
|
85
|
+
expect(seen.handle?.failures).toEqual([{ index: 1, error: 'frame died' }]);
|
|
86
|
+
expect(seen.handle?.done).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('stops folding results once cancelled', async () => {
|
|
90
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
91
|
+
render(
|
|
92
|
+
<Harness
|
|
93
|
+
capability={capability()}
|
|
94
|
+
config={{ n: 50 }}
|
|
95
|
+
onHandle={(h, s) => {
|
|
96
|
+
seen.handle = h;
|
|
97
|
+
seen.state = s;
|
|
98
|
+
}}
|
|
99
|
+
/>,
|
|
100
|
+
);
|
|
101
|
+
act(() => seen.handle?.start());
|
|
102
|
+
await waitFor(() => expect(seen.handle?.status).toBe('running'));
|
|
103
|
+
act(() => seen.handle?.cancel());
|
|
104
|
+
const atCancel = seen.state.items.length;
|
|
105
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
106
|
+
expect(seen.state.items.length).toBe(atCancel);
|
|
107
|
+
expect(seen.handle?.status).toBe('idle');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('discards results from a run its key superseded', async () => {
|
|
111
|
+
const withKey: JobCapability<State, Config, number> = {
|
|
112
|
+
...capability(),
|
|
113
|
+
key: (config) => [config.n],
|
|
114
|
+
auto: true,
|
|
115
|
+
};
|
|
116
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
117
|
+
const { rerender } = render(
|
|
118
|
+
<Harness
|
|
119
|
+
capability={withKey}
|
|
120
|
+
config={{ n: 40 }}
|
|
121
|
+
onHandle={(h, s) => {
|
|
122
|
+
seen.handle = h;
|
|
123
|
+
seen.state = s;
|
|
124
|
+
}}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
await waitFor(() => expect(seen.handle?.status).toBe('running'));
|
|
128
|
+
|
|
129
|
+
rerender(
|
|
130
|
+
<Harness
|
|
131
|
+
capability={withKey}
|
|
132
|
+
config={{ n: 2 }}
|
|
133
|
+
onHandle={(h, s) => {
|
|
134
|
+
seen.handle = h;
|
|
135
|
+
seen.state = s;
|
|
136
|
+
}}
|
|
137
|
+
/>,
|
|
138
|
+
);
|
|
139
|
+
await waitFor(() => expect(seen.handle?.status).toBe('done'));
|
|
140
|
+
|
|
141
|
+
// The superseded 40-item run cannot have contributed: the winner yields two.
|
|
142
|
+
expect(seen.state.items).toEqual([0, 1]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('aborts on unmount', async () => {
|
|
146
|
+
const aborted = vi.fn();
|
|
147
|
+
const watching: JobCapability<State, Config, number> = {
|
|
148
|
+
run: ({ signal }) => {
|
|
149
|
+
signal.addEventListener('abort', aborted);
|
|
150
|
+
return counter(50, signal);
|
|
151
|
+
},
|
|
152
|
+
onItem: (item, state) => ({ items: [...state.items, item] }),
|
|
153
|
+
};
|
|
154
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
155
|
+
const { unmount } = render(
|
|
156
|
+
<Harness
|
|
157
|
+
capability={watching}
|
|
158
|
+
config={{ n: 50 }}
|
|
159
|
+
onHandle={(h) => {
|
|
160
|
+
seen.handle = h;
|
|
161
|
+
}}
|
|
162
|
+
/>,
|
|
163
|
+
);
|
|
164
|
+
act(() => seen.handle?.start());
|
|
165
|
+
await waitFor(() => expect(seen.handle?.status).toBe('running'));
|
|
166
|
+
unmount();
|
|
167
|
+
expect(aborted).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('reports a thrown error without losing the items already folded', async () => {
|
|
171
|
+
const throwing: JobCapability<State, Config, number> = {
|
|
172
|
+
run: async function* () {
|
|
173
|
+
yield { kind: 'item', item: 7 };
|
|
174
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
175
|
+
throw new Error('the baker died');
|
|
176
|
+
},
|
|
177
|
+
onItem: (item, state) => ({ items: [...state.items, item] }),
|
|
178
|
+
};
|
|
179
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
180
|
+
render(
|
|
181
|
+
<Harness
|
|
182
|
+
capability={throwing}
|
|
183
|
+
config={{ n: 1 }}
|
|
184
|
+
onHandle={(h, s) => {
|
|
185
|
+
seen.handle = h;
|
|
186
|
+
seen.state = s;
|
|
187
|
+
}}
|
|
188
|
+
/>,
|
|
189
|
+
);
|
|
190
|
+
act(() => seen.handle?.start());
|
|
191
|
+
await waitFor(() => expect(seen.handle?.status).toBe('error'));
|
|
192
|
+
expect(seen.handle?.error).toMatch(/the baker died/);
|
|
193
|
+
expect(seen.state.items).toEqual([7]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('does not start on its own without auto', async () => {
|
|
197
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
198
|
+
render(
|
|
199
|
+
<Harness
|
|
200
|
+
capability={{ ...capability(), key: (c) => [c.n] }}
|
|
201
|
+
config={{ n: 3 }}
|
|
202
|
+
onHandle={(h) => {
|
|
203
|
+
seen.handle = h;
|
|
204
|
+
}}
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
208
|
+
expect(seen.handle?.status).toBe('idle');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { JobCapability, JobFailure, JobHandle, JobStatus } from './types';
|
|
3
|
+
|
|
4
|
+
export interface UseJobOptions<TS, TC, TItem> {
|
|
5
|
+
capability: JobCapability<TS, TC, TItem>;
|
|
6
|
+
config: TC;
|
|
7
|
+
state: TS;
|
|
8
|
+
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Progress {
|
|
12
|
+
status: JobStatus;
|
|
13
|
+
done: number;
|
|
14
|
+
total: number | null;
|
|
15
|
+
failures: JobFailure[];
|
|
16
|
+
error: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const IDLE: Progress = { status: 'idle', done: 0, total: null, failures: [], error: null };
|
|
20
|
+
|
|
21
|
+
function sameKey(a: readonly unknown[] | null, b: readonly unknown[] | null): boolean {
|
|
22
|
+
if (a === null || b === null) return a === b;
|
|
23
|
+
return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useJob<TS, TC, TItem>({
|
|
27
|
+
capability,
|
|
28
|
+
config,
|
|
29
|
+
state,
|
|
30
|
+
setState,
|
|
31
|
+
}: UseJobOptions<TS, TC, TItem>): JobHandle {
|
|
32
|
+
const [progress, setProgress] = useState<Progress>(IDLE);
|
|
33
|
+
|
|
34
|
+
// A run is identified by a token. Every result checks its token before touching
|
|
35
|
+
// state, so a superseded run finishes harmlessly instead of racing the winner.
|
|
36
|
+
const token = useRef(0);
|
|
37
|
+
const abort = useRef<AbortController | null>(null);
|
|
38
|
+
|
|
39
|
+
// Read through refs: `run` is called once per run and must see the values as of
|
|
40
|
+
// that moment rather than re-subscribing on every render.
|
|
41
|
+
const capRef = useRef(capability);
|
|
42
|
+
capRef.current = capability;
|
|
43
|
+
const configRef = useRef(config);
|
|
44
|
+
configRef.current = config;
|
|
45
|
+
const stateRef = useRef(state);
|
|
46
|
+
stateRef.current = state;
|
|
47
|
+
const setStateRef = useRef(setState);
|
|
48
|
+
setStateRef.current = setState;
|
|
49
|
+
|
|
50
|
+
const cancel = useCallback(() => {
|
|
51
|
+
token.current += 1;
|
|
52
|
+
abort.current?.abort();
|
|
53
|
+
abort.current = null;
|
|
54
|
+
setProgress(IDLE);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const start = useCallback(() => {
|
|
58
|
+
token.current += 1;
|
|
59
|
+
const mine = token.current;
|
|
60
|
+
abort.current?.abort();
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
abort.current = controller;
|
|
63
|
+
setProgress({ ...IDLE, status: 'running' });
|
|
64
|
+
|
|
65
|
+
void (async () => {
|
|
66
|
+
try {
|
|
67
|
+
const iterable = capRef.current.run({
|
|
68
|
+
config: configRef.current,
|
|
69
|
+
state: stateRef.current,
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
});
|
|
72
|
+
for await (const event of iterable) {
|
|
73
|
+
if (token.current !== mine) return;
|
|
74
|
+
if (event.kind === 'total') {
|
|
75
|
+
setProgress((p) => ({ ...p, total: event.total }));
|
|
76
|
+
} else if (event.kind === 'failed') {
|
|
77
|
+
setProgress((p) => ({
|
|
78
|
+
...p,
|
|
79
|
+
failures: [...p.failures, { index: event.index, error: event.error }],
|
|
80
|
+
}));
|
|
81
|
+
} else {
|
|
82
|
+
const fold = capRef.current.onItem;
|
|
83
|
+
const item = event.item;
|
|
84
|
+
setStateRef.current((prev) => fold(item, prev));
|
|
85
|
+
setProgress((p) => ({ ...p, done: p.done + 1 }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (token.current !== mine) return;
|
|
89
|
+
setProgress((p) => ({ ...p, status: 'done' }));
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (token.current !== mine) return;
|
|
92
|
+
setProgress((p) => ({
|
|
93
|
+
...p,
|
|
94
|
+
status: 'error',
|
|
95
|
+
error: err instanceof Error ? err.message : String(err),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
// Re-run when the declared key changes. `auto` covers the first mount too.
|
|
102
|
+
const lastKey = useRef<readonly unknown[] | null>(null);
|
|
103
|
+
const started = useRef(false);
|
|
104
|
+
const auto = capability.auto === true;
|
|
105
|
+
const key = capability.key ? capability.key(config, state) : null;
|
|
106
|
+
// `key` is compared element-wise inside; depending on the array identity here
|
|
107
|
+
// would restart the job on every render.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!auto) return;
|
|
110
|
+
if (started.current && sameKey(lastKey.current, key)) return;
|
|
111
|
+
lastKey.current = key;
|
|
112
|
+
started.current = true;
|
|
113
|
+
start();
|
|
114
|
+
}, [auto, key, start]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
115
|
+
|
|
116
|
+
useEffect(
|
|
117
|
+
() => () => {
|
|
118
|
+
token.current += 1;
|
|
119
|
+
abort.current?.abort();
|
|
120
|
+
abort.current = null;
|
|
121
|
+
},
|
|
122
|
+
[],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
status: progress.status,
|
|
127
|
+
done: progress.done,
|
|
128
|
+
total: progress.total,
|
|
129
|
+
failures: progress.failures,
|
|
130
|
+
error: progress.error,
|
|
131
|
+
start,
|
|
132
|
+
cancel,
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/lab/Lab.less
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
// `height: 100%` resolves against the containing block, so the lab needs an
|
|
2
|
+
// unbroken chain of sized ancestors — and a host that supplies the height but
|
|
3
|
+
// not the margin reset gets a page 16px taller than the viewport, which reads
|
|
4
|
+
// as a stuck canvas rather than as overflow. Scoped with `:has` so a page that
|
|
5
|
+
// mounts no lab is untouched, and stopping at the lab's own parent so an
|
|
6
|
+
// embedded lab cannot resize its host's layout.
|
|
7
|
+
html:has(.lk-lab),
|
|
8
|
+
body:has(.lk-lab) {
|
|
9
|
+
margin: 0;
|
|
10
|
+
height: 100%;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
body:has(.lk-lab) > :has(> .lk-lab) {
|
|
14
|
+
height: 100%;
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
.lk-lab {
|
|
2
18
|
height: 100%;
|
|
3
19
|
}
|
package/src/lab/Lab.stories.tsx
CHANGED
|
@@ -31,21 +31,20 @@ export const Default: Story = {
|
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
function
|
|
34
|
+
function AddSecondTrial() {
|
|
35
35
|
const ctx = useLabContext();
|
|
36
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
|
|
37
36
|
useEffect(() => {
|
|
38
|
-
if (ctx.
|
|
39
|
-
}, []);
|
|
37
|
+
if (ctx.trials.length < 2) ctx.addTrial('Stub');
|
|
38
|
+
}, [ctx]);
|
|
40
39
|
return null;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
export const
|
|
42
|
+
export const TwoTrials: Story = {
|
|
44
43
|
args: {
|
|
45
44
|
instruments: [StubInstrument],
|
|
46
45
|
defaultInstrument: 'Stub',
|
|
47
|
-
title: 'Two
|
|
46
|
+
title: 'Two Trials',
|
|
48
47
|
storage: null,
|
|
49
|
-
children: <
|
|
48
|
+
children: <AddSecondTrial />,
|
|
50
49
|
},
|
|
51
50
|
};
|
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -41,50 +41,50 @@ function mountLab(props: Partial<Parameters<typeof Lab>[0]> = {}) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe('<Lab>', () => {
|
|
44
|
-
it('renders one
|
|
44
|
+
it('renders one trial by default', () => {
|
|
45
45
|
mountLab();
|
|
46
|
-
expect(screen.getAllByRole('region', { name: /
|
|
46
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
it('
|
|
49
|
+
it('addTrial adds a second trial', () => {
|
|
50
50
|
mountLab();
|
|
51
|
-
act(() => labRef?.
|
|
52
|
-
expect(screen.getAllByRole('region', { name: /
|
|
51
|
+
act(() => labRef?.addTrial('Stub'));
|
|
52
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it('
|
|
55
|
+
it('closeTrial removes one when more than one exists', () => {
|
|
56
56
|
mountLab();
|
|
57
|
-
act(() => labRef?.
|
|
58
|
-
const first = labRef?.
|
|
59
|
-
act(() => labRef?.
|
|
60
|
-
expect(screen.getAllByRole('region', { name: /
|
|
57
|
+
act(() => labRef?.addTrial('Stub'));
|
|
58
|
+
const first = labRef?.trials[0];
|
|
59
|
+
act(() => labRef?.closeTrial(first?.id ?? ''));
|
|
60
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it('
|
|
63
|
+
it('closeTrial is a no-op on the last trial', () => {
|
|
64
64
|
mountLab();
|
|
65
|
-
const only = labRef?.
|
|
66
|
-
act(() => labRef?.
|
|
67
|
-
expect(screen.getAllByRole('region', { name: /
|
|
65
|
+
const only = labRef?.trials[0];
|
|
66
|
+
act(() => labRef?.closeTrial(only?.id ?? ''));
|
|
67
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('
|
|
70
|
+
it('cloneTrial inserts immediately after source', () => {
|
|
71
71
|
mountLab();
|
|
72
|
-
act(() => labRef?.
|
|
73
|
-
const ws0 = labRef?.
|
|
74
|
-
const ws1 = labRef?.
|
|
75
|
-
act(() => labRef?.
|
|
76
|
-
const ids = labRef?.
|
|
72
|
+
act(() => labRef?.addTrial('StubB'));
|
|
73
|
+
const ws0 = labRef?.trials[0];
|
|
74
|
+
const ws1 = labRef?.trials[1];
|
|
75
|
+
act(() => labRef?.cloneTrial(ws0?.id ?? ''));
|
|
76
|
+
const ids = labRef?.trials.map((w) => w.id) ?? [];
|
|
77
77
|
expect(ids).toHaveLength(3);
|
|
78
78
|
expect(ids[0]).toBe(ws0?.id);
|
|
79
79
|
expect(ids[2]).toBe(ws1?.id);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('
|
|
82
|
+
it('resetTrial restores defaults', () => {
|
|
83
83
|
mountLab();
|
|
84
|
-
const ws = labRef?.
|
|
85
|
-
if (!ws) throw new Error('no
|
|
86
|
-
act(() => labRef?.
|
|
87
|
-
const reset = labRef?.
|
|
84
|
+
const ws = labRef?.trials[0];
|
|
85
|
+
if (!ws) throw new Error('no trial');
|
|
86
|
+
act(() => labRef?.resetTrial(ws.id));
|
|
87
|
+
const reset = labRef?.trials[0];
|
|
88
88
|
expect(reset?.config).toEqual({ count: 0 });
|
|
89
89
|
expect(reset?.state).toEqual({ value: 0 });
|
|
90
90
|
});
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
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 {
|
|
4
|
+
import type { InstrumentList } from '../instrument/types';
|
|
5
5
|
import { noneAdapter } from '../state/adapters';
|
|
6
6
|
import { LabStoreContext } from '../state/context';
|
|
7
7
|
import { createLabStore, type LabStore } from '../state/store';
|
|
8
|
-
import type { LabMode, StorageAdapter,
|
|
8
|
+
import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
|
|
9
9
|
import { interstellarTheme } from '../theme/interstellar';
|
|
10
|
-
import {
|
|
10
|
+
import { Trial } from '../trial/Trial';
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from '../
|
|
12
|
+
addTrial as addTrialOp,
|
|
13
|
+
cloneTrial as cloneTrialOp,
|
|
14
|
+
closeTrial as closeTrialOp,
|
|
15
|
+
reorderTrials as reorderTrialsOp,
|
|
16
|
+
resetTrial as resetTrialOp,
|
|
17
|
+
} from '../trial/trialOps';
|
|
18
18
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
19
19
|
import { LabShell } from './LabShell';
|
|
20
20
|
import { useResolvedMode } from './useSystemMode';
|
|
21
|
-
import {
|
|
21
|
+
import { type TrialLayout, Workspace } from './Workspace';
|
|
22
22
|
|
|
23
23
|
/** Props for `<Lab>`. */
|
|
24
24
|
export interface LabProps {
|
|
25
|
-
instruments:
|
|
25
|
+
instruments: InstrumentList;
|
|
26
26
|
defaultInstrument: string;
|
|
27
27
|
storage?: StorageAdapter | null;
|
|
28
28
|
storageKey?: string;
|
|
@@ -39,19 +39,19 @@ export interface LabProps {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function buildStore(
|
|
42
|
-
instruments:
|
|
42
|
+
instruments: InstrumentList,
|
|
43
43
|
defaultInstrument: string,
|
|
44
44
|
storage: StorageAdapter,
|
|
45
45
|
storageKey: string,
|
|
46
46
|
initialMode: LabMode,
|
|
47
47
|
): LabStore {
|
|
48
48
|
const store = createLabStore({ storageKey, storage, initialMode });
|
|
49
|
-
if (store.getState().
|
|
50
|
-
const seeded =
|
|
49
|
+
if (store.getState().trials.length === 0) {
|
|
50
|
+
const seeded = addTrialOp([], instruments, defaultInstrument);
|
|
51
51
|
const record = seeded[0];
|
|
52
52
|
if (record) {
|
|
53
53
|
const { undoStack: _undoStack, ...rest } = record;
|
|
54
|
-
store.getState().
|
|
54
|
+
store.getState().addTrial(rest);
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
return store;
|
|
@@ -71,15 +71,15 @@ const NEBULA_SLOTS = [
|
|
|
71
71
|
|
|
72
72
|
function buildNebula(colors: readonly string[]): string {
|
|
73
73
|
const blobs = colors.map((c, i) => {
|
|
74
|
-
const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length]
|
|
74
|
+
const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
|
|
75
75
|
return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
|
|
76
76
|
});
|
|
77
77
|
blobs.push('radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%)');
|
|
78
78
|
return blobs.join(', ');
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** The lab runtime: creates the store, provides it, and renders one
|
|
82
|
-
* per record in a grid. Each
|
|
81
|
+
/** The lab runtime: creates the store, provides it, and renders one trial
|
|
82
|
+
* per record in a grid. Each trial runs one of `instruments`. */
|
|
83
83
|
export function Lab({
|
|
84
84
|
instruments,
|
|
85
85
|
defaultInstrument,
|
|
@@ -106,7 +106,7 @@ export function Lab({
|
|
|
106
106
|
}
|
|
107
107
|
const store = storeRef.current;
|
|
108
108
|
|
|
109
|
-
const
|
|
109
|
+
const trials = useStore(store, (s) => s.trials);
|
|
110
110
|
const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
|
|
111
111
|
const modeValue = useStore(store, (s) => s.mode);
|
|
112
112
|
const layout = useStore(store, (s) => s.layout);
|
|
@@ -119,51 +119,51 @@ export function Lab({
|
|
|
119
119
|
}, [mode, store]);
|
|
120
120
|
|
|
121
121
|
const contextValue = useMemo<LabContextValue>(() => {
|
|
122
|
-
const
|
|
123
|
-
const currentById = new Map(store.getState().
|
|
122
|
+
const replaceTrials = (next: TrialRecord[]): void => {
|
|
123
|
+
const currentById = new Map(store.getState().trials.map((w) => [w.id, w]));
|
|
124
124
|
const merged = next.map((w) => currentById.get(w.id) ?? w);
|
|
125
|
-
store.setState({
|
|
125
|
+
store.setState({ trials: merged });
|
|
126
126
|
// Trigger persistence flush via a tracked action.
|
|
127
127
|
store.getState().setMode(store.getState().mode);
|
|
128
128
|
};
|
|
129
129
|
|
|
130
130
|
return {
|
|
131
131
|
instruments,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const next =
|
|
135
|
-
|
|
132
|
+
trials,
|
|
133
|
+
addTrial: (instrumentName) => {
|
|
134
|
+
const next = addTrialOp(store.getState().trials, instruments, instrumentName);
|
|
135
|
+
replaceTrials(next);
|
|
136
136
|
},
|
|
137
|
-
|
|
138
|
-
const next =
|
|
139
|
-
|
|
137
|
+
cloneTrial: (id) => {
|
|
138
|
+
const next = cloneTrialOp(store.getState().trials, id);
|
|
139
|
+
replaceTrials(next);
|
|
140
140
|
},
|
|
141
|
-
|
|
142
|
-
const next =
|
|
143
|
-
|
|
141
|
+
closeTrial: (id) => {
|
|
142
|
+
const next = closeTrialOp(store.getState().trials, id);
|
|
143
|
+
replaceTrials(next);
|
|
144
144
|
},
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
reorderTrials: (ids) => {
|
|
146
|
+
replaceTrials(reorderTrialsOp(store.getState().trials, ids));
|
|
147
147
|
},
|
|
148
|
-
|
|
149
|
-
const next =
|
|
148
|
+
resetTrial: (id) => {
|
|
149
|
+
const next = resetTrialOp(store.getState().trials, id, instruments);
|
|
150
150
|
const record = next.find((w) => w.id === id);
|
|
151
151
|
if (!record) return;
|
|
152
152
|
store.setState((s) => ({
|
|
153
|
-
|
|
153
|
+
trials: s.trials.map((w) =>
|
|
154
154
|
w.id === id
|
|
155
155
|
? { ...w, config: record.config, state: record.state, view: record.view }
|
|
156
156
|
: w,
|
|
157
157
|
),
|
|
158
158
|
}));
|
|
159
|
-
store.getState().
|
|
159
|
+
store.getState().updateTrialView(id, record.view);
|
|
160
160
|
},
|
|
161
161
|
savedSnapshots,
|
|
162
|
-
saveSnapshot: (
|
|
163
|
-
store.getState().saveSnapshot(
|
|
162
|
+
saveSnapshot: (trialId, name) => {
|
|
163
|
+
store.getState().saveSnapshot(trialId, name ?? `Save ${new Date().toLocaleString()}`);
|
|
164
164
|
},
|
|
165
|
-
loadSnapshot: (
|
|
166
|
-
store.getState().loadSnapshot(snapshotId,
|
|
165
|
+
loadSnapshot: (trialId, snapshotId) => {
|
|
166
|
+
store.getState().loadSnapshot(snapshotId, trialId);
|
|
167
167
|
},
|
|
168
168
|
deleteSnapshot: (snapshotId) => {
|
|
169
169
|
store.getState().deleteSnapshot(snapshotId);
|
|
@@ -173,7 +173,7 @@ export function Lab({
|
|
|
173
173
|
store.getState().setMode(m);
|
|
174
174
|
},
|
|
175
175
|
};
|
|
176
|
-
}, [instruments,
|
|
176
|
+
}, [instruments, trials, savedSnapshots, modeValue, store]);
|
|
177
177
|
|
|
178
178
|
// Only override the backdrop in dark, where there is one to override.
|
|
179
179
|
// Setting a CSS custom property is the sanctioned use of inline style.
|
|
@@ -192,18 +192,18 @@ export function Lab({
|
|
|
192
192
|
style={backdropStyle}
|
|
193
193
|
>
|
|
194
194
|
<LabShell title={title ?? 'Labkit'} mode={modeValue} header={children}>
|
|
195
|
-
<
|
|
196
|
-
ids={
|
|
195
|
+
<Workspace
|
|
196
|
+
ids={trials.map((w) => w.id)}
|
|
197
197
|
resizable
|
|
198
198
|
reorderable
|
|
199
|
-
onReorder={(ids) => contextValue.
|
|
200
|
-
layout={layout as
|
|
199
|
+
onReorder={(ids) => contextValue.reorderTrials(ids)}
|
|
200
|
+
layout={layout as TrialLayout}
|
|
201
201
|
onLayoutChange={(next) => store.getState().setLayout(next)}
|
|
202
202
|
>
|
|
203
|
-
{
|
|
204
|
-
<
|
|
203
|
+
{trials.map((w) => (
|
|
204
|
+
<Trial key={w.id} id={w.id} />
|
|
205
205
|
))}
|
|
206
|
-
</
|
|
206
|
+
</Workspace>
|
|
207
207
|
</LabShell>
|
|
208
208
|
</ThemeProvider>
|
|
209
209
|
</LabContext.Provider>
|