@weasel-js/labkit 1.1.0 → 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 +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
- package/dist/_dts/{index-iAP6XbH3.d.ts → index-JFAYj5Tv.d.ts} +12 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-BMNIx3Cy.d.ts} +5 -9
- package/dist/canvas/index.d.ts +2 -1
- package/dist/{chunk-C6GJKPUI.js → chunk-73KA7WBO.js} +69 -63
- package/dist/chunk-73KA7WBO.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-BOHF3PQO.js} +4375 -3720
- package/dist/chunk-BOHF3PQO.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-G5TJVQQT.js} +3 -3
- package/dist/{chunk-NRKWVTVT.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-3TYUJR7Z.js → chunk-VUU5UXHE.js} +6 -3
- package/dist/chunk-VUU5UXHE.js.map +1 -0
- package/dist/dragdrop/index.d.ts +2 -1
- package/dist/index.d.ts +71 -11
- package/dist/index.js +165 -22
- 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 +20 -4
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +2 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +10 -0
- 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 +8 -0
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/index.test.ts +18 -0
- package/src/index.ts +13 -0
- package/src/instrument/SineWave.smoke.test.tsx +2 -1
- package/src/instrument/types.ts +13 -1
- 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.stories.tsx +1 -2
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.tsx +14 -1
- package/src/state/store.ts +8 -2
- package/src/state/types.ts +4 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +157 -0
- package/src/trial/DefaultStatusBar.tsx +2 -2
- package/src/trial/Trial.job.test.tsx +75 -0
- package/src/trial/Trial.less +12 -0
- package/src/trial/Trial.stories.tsx +8 -1
- package/src/trial/Trial.tsx +32 -6
- package/src/trial/TrialChrome.tsx +33 -6
- package/src/trial/slotTypes.ts +2 -1
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
package/src/job/types.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** What a running job reports as it goes. `total` may arrive at any point and may
|
|
2
|
+
* arrive more than once; a job that cannot count up front simply never sends it.
|
|
3
|
+
*
|
|
4
|
+
* `failed` is a first-class event rather than a thrown error because these
|
|
5
|
+
* failures are per item: a run with two failed items is a partial success, and
|
|
6
|
+
* its other items are worth showing. */
|
|
7
|
+
export type JobEvent<T> =
|
|
8
|
+
| { kind: 'total'; total: number }
|
|
9
|
+
| { kind: 'item'; item: T }
|
|
10
|
+
| { kind: 'failed'; index: number; error: string };
|
|
11
|
+
|
|
12
|
+
/** Where a job is. `idle` before its first run and after a cancel; `done` when the
|
|
13
|
+
* iterable finished, whether or not items failed along the way. */
|
|
14
|
+
export type JobStatus = 'idle' | 'running' | 'done' | 'error';
|
|
15
|
+
|
|
16
|
+
/** Declares that an instrument has work too slow to do during a render: what to
|
|
17
|
+
* run, when to re-run it, and how each result folds into state. */
|
|
18
|
+
export interface JobCapability<TS = unknown, TC = unknown, TItem = unknown> {
|
|
19
|
+
/** Re-run whenever this value changes, compared element-wise. A job with no
|
|
20
|
+
* `key` runs only when something calls `start()`. */
|
|
21
|
+
key?: (config: TC, state: TS) => readonly unknown[];
|
|
22
|
+
/** Start on mount and on every `key` change. Default false. */
|
|
23
|
+
auto?: boolean;
|
|
24
|
+
run: (args: { config: TC; state: TS; signal: AbortSignal }) => AsyncIterable<JobEvent<TItem>>;
|
|
25
|
+
/** Fold one result into state. Called once per `item` event, in arrival order. */
|
|
26
|
+
onItem: (item: TItem, state: TS) => TS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One item that failed, and why. */
|
|
30
|
+
export interface JobFailure {
|
|
31
|
+
index: number;
|
|
32
|
+
error: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What `RenderContext.job` exposes. Present only when the instrument declares the
|
|
36
|
+
* capability; `undefined` otherwise. */
|
|
37
|
+
export interface JobHandle {
|
|
38
|
+
status: JobStatus;
|
|
39
|
+
done: number;
|
|
40
|
+
/** Null until the job reports a total, and forever if it never does. */
|
|
41
|
+
total: number | null;
|
|
42
|
+
failures: readonly JobFailure[];
|
|
43
|
+
/** The error that ended the run, when `status` is `'error'`. */
|
|
44
|
+
error: string | null;
|
|
45
|
+
start: () => void;
|
|
46
|
+
cancel: () => void;
|
|
47
|
+
}
|
|
@@ -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.stories.tsx
CHANGED
|
@@ -33,10 +33,9 @@ export const Default: Story = {
|
|
|
33
33
|
|
|
34
34
|
function AddSecondTrial() {
|
|
35
35
|
const ctx = useLabContext();
|
|
36
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
|
|
37
36
|
useEffect(() => {
|
|
38
37
|
if (ctx.trials.length < 2) ctx.addTrial('Stub');
|
|
39
|
-
}, []);
|
|
38
|
+
}, [ctx]);
|
|
40
39
|
return null;
|
|
41
40
|
}
|
|
42
41
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { SurfaceContext } from '../surface/SurfaceContext';
|
|
4
|
+
import type { SurfaceHandle } from '../surface/useTiledSurface';
|
|
5
|
+
import { Workspace } from './Workspace';
|
|
6
|
+
|
|
7
|
+
function fakeHandle(): SurfaceHandle {
|
|
8
|
+
return {
|
|
9
|
+
invalidate: vi.fn(),
|
|
10
|
+
invalidateAll: vi.fn(),
|
|
11
|
+
invalidateRects: vi.fn(),
|
|
12
|
+
registerTile: vi.fn(),
|
|
13
|
+
containerRef: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Workspace with a surface above it', () => {
|
|
18
|
+
it('invalidates rects when the tile set changes, because a re-tile moves tiles', () => {
|
|
19
|
+
const handle = fakeHandle();
|
|
20
|
+
const { rerender } = render(
|
|
21
|
+
<SurfaceContext.Provider value={handle}>
|
|
22
|
+
<Workspace ids={['a']} viewport={{ w: 800, h: 600 }}>
|
|
23
|
+
<div>a</div>
|
|
24
|
+
</Workspace>
|
|
25
|
+
</SurfaceContext.Provider>,
|
|
26
|
+
);
|
|
27
|
+
(handle.invalidateRects as ReturnType<typeof vi.fn>).mockClear();
|
|
28
|
+
|
|
29
|
+
rerender(
|
|
30
|
+
<SurfaceContext.Provider value={handle}>
|
|
31
|
+
<Workspace ids={['a', 'b']} viewport={{ w: 800, h: 600 }}>
|
|
32
|
+
<div>a</div>
|
|
33
|
+
<div>b</div>
|
|
34
|
+
</Workspace>
|
|
35
|
+
</SurfaceContext.Provider>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(handle.invalidateRects).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders unchanged with no surface above it', () => {
|
|
42
|
+
const { getByText } = render(
|
|
43
|
+
<Workspace ids={['a']} viewport={{ w: 800, h: 600 }}>
|
|
44
|
+
<div>a</div>
|
|
45
|
+
</Workspace>,
|
|
46
|
+
);
|
|
47
|
+
expect(getByText('a')).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/lab/Workspace.tsx
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
StrategyRegistryProvider,
|
|
18
18
|
} from 'windease/react';
|
|
19
19
|
|
|
20
|
+
import { useSurfaceOptional } from '../surface/useSurfaceTile';
|
|
21
|
+
|
|
20
22
|
const ZONE_ID = asNodeId('lk-workspace');
|
|
21
23
|
const STRATEGIES = { grid: gridStrategy as never };
|
|
22
24
|
const KIND = 'trial';
|
|
@@ -93,7 +95,7 @@ export function Workspace({
|
|
|
93
95
|
const items = Children.toArray(children);
|
|
94
96
|
const idKey = ids ? ids.join(',') : `#${items.length}`;
|
|
95
97
|
// biome-ignore lint/correctness/useExhaustiveDependencies: idKey is the stable projection of items/ids; depending on those directly rebuilds every render and re-runs the sync effect forever
|
|
96
|
-
const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]);
|
|
98
|
+
const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
97
99
|
|
|
98
100
|
// Held in refs rather than depended on: a fresh object each render would
|
|
99
101
|
// re-run the sync effect, and only a newly registered tile reads `layout`.
|
|
@@ -119,6 +121,10 @@ export function Workspace({
|
|
|
119
121
|
}
|
|
120
122
|
const store = storeRef.current;
|
|
121
123
|
|
|
124
|
+
// A tile that only moves reports nothing to a ResizeObserver, and only this
|
|
125
|
+
// component knows the grid moved one. Optional: a lab may own no surface.
|
|
126
|
+
const surface = useSurfaceOptional();
|
|
127
|
+
|
|
122
128
|
useLayoutEffect(() => {
|
|
123
129
|
store.updateContainerConfig(ZONE_ID, { resizable, gap, padding });
|
|
124
130
|
}, [store, resizable, gap, padding]);
|
|
@@ -139,6 +145,13 @@ export function Workspace({
|
|
|
139
145
|
store.setChildOrder(ZONE_ID, [...nodeIds]);
|
|
140
146
|
}, [store, nodeIds]);
|
|
141
147
|
|
|
148
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: nodeIds is the signal that the tile set changed — a re-tile moves tiles without resizing any — not a value this reads
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!surface) return;
|
|
151
|
+
surface.invalidateRects();
|
|
152
|
+
return store.events.on('node.placementChanged', () => surface.invalidateRects());
|
|
153
|
+
}, [store, surface, nodeIds]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
154
|
+
|
|
142
155
|
useEffect(() => {
|
|
143
156
|
if (!onLayoutChange) return;
|
|
144
157
|
return store.events.on('node.placementChanged', () => {
|
package/src/state/store.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface LabStoreActions {
|
|
|
28
28
|
removeTrial: (id: string) => void;
|
|
29
29
|
updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
|
|
30
30
|
updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
|
|
31
|
-
updateTrialView: (id: string, view:
|
|
31
|
+
updateTrialView: (id: string, view: unknown) => void;
|
|
32
32
|
updateTrialUndoStack: (
|
|
33
33
|
id: string,
|
|
34
34
|
next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack']),
|
|
@@ -89,6 +89,10 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
89
89
|
if (w.id !== id) return w;
|
|
90
90
|
const nextState =
|
|
91
91
|
typeof next === 'function' ? (next as (prev: unknown) => unknown)(w.state) : next;
|
|
92
|
+
// An updater that returns its input means "nothing changed", and must not
|
|
93
|
+
// cost a new record: the trial re-renders on record identity, so
|
|
94
|
+
// allocating here turns the standard React bail-out into a render loop.
|
|
95
|
+
if (Object.is(nextState, w.state)) return w;
|
|
92
96
|
return { ...w, state: nextState };
|
|
93
97
|
}),
|
|
94
98
|
}));
|
|
@@ -110,7 +114,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
110
114
|
|
|
111
115
|
updateTrialView: (id, view) => {
|
|
112
116
|
set((s) => ({
|
|
113
|
-
trials: s.trials.map((w) =>
|
|
117
|
+
trials: s.trials.map((w) =>
|
|
118
|
+
w.id === id && !Object.is(view, w.view) ? { ...w, view } : w,
|
|
119
|
+
),
|
|
114
120
|
}));
|
|
115
121
|
scheduleFlush();
|
|
116
122
|
},
|
package/src/state/types.ts
CHANGED
|
@@ -7,12 +7,14 @@ export interface UndoStack {
|
|
|
7
7
|
|
|
8
8
|
/** One trial as the store holds it: which instrument it runs, that
|
|
9
9
|
* instrument's config and state, the camera, and the undo history. */
|
|
10
|
-
export interface TrialRecord<TS = unknown, TC = unknown> {
|
|
10
|
+
export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
11
11
|
id: string;
|
|
12
12
|
instrumentName: string;
|
|
13
13
|
config: TC;
|
|
14
14
|
state: TS;
|
|
15
|
-
|
|
15
|
+
/** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
|
|
16
|
+
* but never read into. A 3D lab puts an orbit here and keeps all three. */
|
|
17
|
+
view: TV;
|
|
16
18
|
undoStack: UndoStack;
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createMemoryAdapter } from './adapters';
|
|
3
|
+
import { createLabStore } from './store';
|
|
4
|
+
import { as2DView, DEFAULT_VIEW } from './view';
|
|
5
|
+
|
|
6
|
+
interface OrbitView {
|
|
7
|
+
yaw: number;
|
|
8
|
+
pitch: number;
|
|
9
|
+
distance: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const orbit: OrbitView = { yaw: 1.1, pitch: 0.3, distance: 9 };
|
|
13
|
+
|
|
14
|
+
describe('as2DView', () => {
|
|
15
|
+
it('accepts the 2D shape', () => {
|
|
16
|
+
expect(as2DView({ zoom: 2, pan: { x: 1, y: 3 } })).toEqual({ zoom: 2, pan: { x: 1, y: 3 } });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('copies rather than aliasing, so a caller cannot mutate the record', () => {
|
|
20
|
+
const source = { zoom: 2, pan: { x: 1, y: 3 } };
|
|
21
|
+
const out = as2DView(source);
|
|
22
|
+
expect(out).not.toBe(source);
|
|
23
|
+
expect(out?.pan).not.toBe(source.pan);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects an orbit view', () => {
|
|
27
|
+
expect(as2DView(orbit)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects a partial 2D view rather than filling in a default', () => {
|
|
31
|
+
expect(as2DView({ zoom: 2 })).toBeNull();
|
|
32
|
+
expect(as2DView({ zoom: 2, pan: { x: 1 } })).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects things that are not objects', () => {
|
|
36
|
+
expect(as2DView(null)).toBeNull();
|
|
37
|
+
expect(as2DView(undefined)).toBeNull();
|
|
38
|
+
expect(as2DView(4)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('a trial view labkit does not interpret', () => {
|
|
43
|
+
it('stores and returns a view shape that is not zoom/pan', () => {
|
|
44
|
+
const store = createLabStore({ storageKey: 'view-a', storage: createMemoryAdapter() });
|
|
45
|
+
store.getState().addTrial({
|
|
46
|
+
id: 'w1',
|
|
47
|
+
instrumentName: 'gem',
|
|
48
|
+
config: {},
|
|
49
|
+
state: {},
|
|
50
|
+
view: DEFAULT_VIEW,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
store.getState().updateTrialView('w1', orbit);
|
|
54
|
+
|
|
55
|
+
expect(store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('round-trips that view through persistence', () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
const mem = createMemoryAdapter();
|
|
61
|
+
const seed = createLabStore({ storageKey: 'view-b', storage: mem });
|
|
62
|
+
seed.getState().addTrial({
|
|
63
|
+
id: 'w1',
|
|
64
|
+
instrumentName: 'gem',
|
|
65
|
+
config: {},
|
|
66
|
+
state: {},
|
|
67
|
+
view: orbit,
|
|
68
|
+
});
|
|
69
|
+
vi.advanceTimersByTime(500);
|
|
70
|
+
vi.useRealTimers();
|
|
71
|
+
|
|
72
|
+
const hydrated = createLabStore({ storageKey: 'view-b', storage: mem });
|
|
73
|
+
expect(hydrated.getState().trials[0]?.view as OrbitView).toEqual(orbit);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('an unchanged write costs no new record', () => {
|
|
78
|
+
it('returns the same trial object when setState returns its input', () => {
|
|
79
|
+
const store = createLabStore({ storageKey: 'identity-a', storage: createMemoryAdapter() });
|
|
80
|
+
store.getState().addTrial({
|
|
81
|
+
id: 'w1',
|
|
82
|
+
instrumentName: 'gem',
|
|
83
|
+
config: {},
|
|
84
|
+
state: { n: 1 },
|
|
85
|
+
view: DEFAULT_VIEW,
|
|
86
|
+
});
|
|
87
|
+
const before = store.getState().trials[0];
|
|
88
|
+
|
|
89
|
+
store.getState().updateTrialState('w1', (prev: unknown) => prev);
|
|
90
|
+
|
|
91
|
+
// A trial re-renders on record identity, so allocating here would turn the
|
|
92
|
+
// standard React bail-out into a render loop.
|
|
93
|
+
expect(store.getState().trials[0]).toBe(before);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('still replaces the record when the state actually changes', () => {
|
|
97
|
+
const store = createLabStore({ storageKey: 'identity-b', storage: createMemoryAdapter() });
|
|
98
|
+
store.getState().addTrial({
|
|
99
|
+
id: 'w1',
|
|
100
|
+
instrumentName: 'gem',
|
|
101
|
+
config: {},
|
|
102
|
+
state: { n: 1 },
|
|
103
|
+
view: DEFAULT_VIEW,
|
|
104
|
+
});
|
|
105
|
+
const before = store.getState().trials[0];
|
|
106
|
+
|
|
107
|
+
store.getState().updateTrialState('w1', { n: 2 });
|
|
108
|
+
|
|
109
|
+
expect(store.getState().trials[0]).not.toBe(before);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns the same trial object when the view is written unchanged', () => {
|
|
113
|
+
const store = createLabStore({ storageKey: 'identity-c', storage: createMemoryAdapter() });
|
|
114
|
+
store.getState().addTrial({
|
|
115
|
+
id: 'w1',
|
|
116
|
+
instrumentName: 'gem',
|
|
117
|
+
config: {},
|
|
118
|
+
state: {},
|
|
119
|
+
view: orbit,
|
|
120
|
+
});
|
|
121
|
+
const before = store.getState().trials[0];
|
|
122
|
+
|
|
123
|
+
store.getState().updateTrialView('w1', orbit);
|
|
124
|
+
|
|
125
|
+
expect(store.getState().trials[0]).toBe(before);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ViewTransform } from '../instrument/types';
|
|
2
|
+
|
|
3
|
+
/** The 2D view labkit has always shipped, and what a trial gets when it names no
|
|
4
|
+
* other. Re-exported under its own name so a consumer can say which it means. */
|
|
5
|
+
export type ViewTransform2D = ViewTransform;
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_VIEW: ViewTransform2D = { zoom: 1, pan: { x: 0, y: 0 } };
|
|
8
|
+
|
|
9
|
+
/** A trial's view is opaque to labkit, so anything that needs the 2D shape — the
|
|
10
|
+
* zoom chrome, `CanvasStack` — asks for it and handles not getting it. */
|
|
11
|
+
export function as2DView(view: unknown): ViewTransform2D | null {
|
|
12
|
+
if (typeof view !== 'object' || view === null) return null;
|
|
13
|
+
const v = view as Partial<ViewTransform2D>;
|
|
14
|
+
if (typeof v.zoom !== 'number') return null;
|
|
15
|
+
if (typeof v.pan !== 'object' || v.pan === null) return null;
|
|
16
|
+
if (typeof v.pan.x !== 'number' || typeof v.pan.y !== 'number') return null;
|
|
17
|
+
return { zoom: v.zoom, pan: { x: v.pan.x, y: v.pan.y } };
|
|
18
|
+
}
|