@selvajs/ui 5.0.1 → 6.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -9
- package/dist/components/compute/ComputeApp.svelte +36 -12
- package/dist/components/compute/ComputeApp.svelte.d.ts +14 -3
- package/dist/components/primitives/textarea/textarea.svelte +1 -2
- package/dist/components/viewer/SceneManager.svelte +75 -160
- package/dist/components/viewer/SceneManager.svelte.d.ts +8 -3
- package/dist/components/viewer/Viewer.svelte +22 -5
- package/dist/compute/useSolveSession.svelte.d.ts +12 -0
- package/dist/compute/useSolveSession.svelte.js +79 -0
- package/dist/external/storage.d.ts +1 -15
- package/dist/external/storage.js +4 -54
- package/dist/index.d.ts +4 -3
- package/dist/index.js +7 -4
- package/dist/public.d.ts +4 -3
- package/dist/public.js +9 -5
- package/package.json +9 -4
- package/src/lib/components/compute/ComputeApp.svelte +36 -12
- package/src/lib/components/primitives/textarea/textarea.svelte +1 -2
- package/src/lib/components/viewer/SceneManager.svelte +75 -160
- package/src/lib/components/viewer/Viewer.svelte +22 -5
- package/src/lib/compute/useSolveSession.svelte.ts +86 -0
- package/src/lib/external/storage.ts +12 -64
- package/src/lib/index.ts +15 -5
- package/src/lib/public.ts +17 -6
- package/dist/compute/computeThrottle.svelte.d.ts +0 -24
- package/dist/compute/computeThrottle.svelte.js +0 -82
- package/dist/compute/createSolveSession.svelte.d.ts +0 -66
- package/dist/compute/createSolveSession.svelte.js +0 -159
- package/dist/compute/solve-session-core.d.ts +0 -54
- package/dist/compute/solve-session-core.js +0 -87
- package/dist/compute/solveMemo.d.ts +0 -22
- package/dist/compute/solveMemo.js +0 -124
- package/dist/types/solveFn.d.ts +0 -25
- package/dist/types/solveFn.js +0 -1
- package/src/lib/compute/computeThrottle.svelte.ts +0 -109
- package/src/lib/compute/computeThrottle.test.ts +0 -106
- package/src/lib/compute/createSolveSession.svelte.ts +0 -239
- package/src/lib/compute/createSolveSession.test.ts +0 -168
- package/src/lib/compute/solve-session-core.test.ts +0 -153
- package/src/lib/compute/solve-session-core.ts +0 -122
- package/src/lib/compute/solveMemo.test.ts +0 -220
- package/src/lib/compute/solveMemo.ts +0 -140
- package/src/lib/external/storage.test.ts +0 -99
- package/src/lib/schema/dynamic-value-list.test.ts +0 -150
- package/src/lib/schema/param-exporter.test.ts +0 -136
- package/src/lib/schema/traversal.test.ts +0 -92
- package/src/lib/schema/visibility-rules.test.ts +0 -173
- package/src/lib/types/solveFn.ts +0 -29
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
buildInitialValues,
|
|
4
|
-
makeInitialFlags,
|
|
5
|
-
applyValueChange,
|
|
6
|
-
applySolveResult,
|
|
7
|
-
type SolveSessionState
|
|
8
|
-
} from './solve-session-core';
|
|
9
|
-
import type { UISchema } from '@selvajs/schemas';
|
|
10
|
-
|
|
11
|
-
// These tests pin the lifecycle state machine that used to live inline in
|
|
12
|
-
// ComputeApp.svelte: how values are seeded (incl. client-sourced hydration), when a
|
|
13
|
-
// value change should dispatch a solve vs. defer it, and what a reported solve result
|
|
14
|
-
// does to the flags. Reactivity is NOT exercised here — that's the thin rune wrapper's
|
|
15
|
-
// job. This is the pure decision layer.
|
|
16
|
-
|
|
17
|
-
// Minimal schema factory. `inputs`/`outputs` are the flat lists ComputeApp reads;
|
|
18
|
-
// `layout` is what getExternalInputs walks for client-sourced inputs.
|
|
19
|
-
function schema(partial: {
|
|
20
|
-
inputs?: { id: string; paramType: string; default?: unknown }[];
|
|
21
|
-
outputs?: { id: string }[];
|
|
22
|
-
clientInputs?: string[]; // ids that carry source.kind === 'client'
|
|
23
|
-
instanceSolve?: boolean;
|
|
24
|
-
}): UISchema {
|
|
25
|
-
const clientSet = new Set(partial.clientInputs ?? []);
|
|
26
|
-
const items = (partial.inputs ?? []).map((i) => ({
|
|
27
|
-
type: 'input',
|
|
28
|
-
id: i.id,
|
|
29
|
-
paramId: i.id,
|
|
30
|
-
displayName: i.id,
|
|
31
|
-
...(clientSet.has(i.id) ? { source: { kind: 'client' } } : {})
|
|
32
|
-
}));
|
|
33
|
-
return {
|
|
34
|
-
id: 'test-schema',
|
|
35
|
-
name: 'Test',
|
|
36
|
-
instanceSolve: partial.instanceSolve,
|
|
37
|
-
inputs: partial.inputs ?? [],
|
|
38
|
-
outputs: partial.outputs ?? [],
|
|
39
|
-
layout: { type: 'flat', groups: [{ items }] }
|
|
40
|
-
} as unknown as UISchema;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe('buildInitialValues', () => {
|
|
44
|
-
it('seeds non-client inputs from default, falling back to paramType default', () => {
|
|
45
|
-
const s = schema({
|
|
46
|
-
inputs: [
|
|
47
|
-
{ id: 'a', paramType: 'number', default: 5 },
|
|
48
|
-
{ id: 'b', paramType: 'text' } // no default -> getDefaultValue('text') === ''
|
|
49
|
-
],
|
|
50
|
-
outputs: [{ id: 'out' }]
|
|
51
|
-
});
|
|
52
|
-
const v = buildInitialValues(s, 'scope', () => undefined);
|
|
53
|
-
expect(v.a).toBe(5);
|
|
54
|
-
expect(v.b).toBe('');
|
|
55
|
-
expect(v.out).toBe(null); // outputs always seeded null
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('hydrates client-sourced inputs from the reader, leaving them undefined when absent', () => {
|
|
59
|
-
const s = schema({
|
|
60
|
-
inputs: [
|
|
61
|
-
{ id: 'c', paramType: 'text', default: 'should-be-ignored' },
|
|
62
|
-
{ id: 'd', paramType: 'text', default: 'also-ignored' }
|
|
63
|
-
],
|
|
64
|
-
clientInputs: ['c', 'd']
|
|
65
|
-
});
|
|
66
|
-
const read = (ref: { inputId: string }) => (ref.inputId === 'c' ? 'stored-c' : undefined);
|
|
67
|
-
const v = buildInitialValues(s, 'scope', read);
|
|
68
|
-
expect(v.c).toBe('stored-c');
|
|
69
|
-
// Absent client value stays undefined (NOT the default) so the missing-inputs
|
|
70
|
-
// panel can detect it.
|
|
71
|
-
expect('d' in v).toBe(false);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe('makeInitialFlags', () => {
|
|
76
|
-
it('starts pending+never-solved when instanceSolve === false', () => {
|
|
77
|
-
expect(makeInitialFlags(false)).toEqual({ hasPendingChanges: true, hasNeverSolved: true });
|
|
78
|
-
});
|
|
79
|
-
it('starts clean when instanceSolve is true or absent', () => {
|
|
80
|
-
expect(makeInitialFlags(true)).toEqual({ hasPendingChanges: false, hasNeverSolved: false });
|
|
81
|
-
expect(makeInitialFlags(undefined)).toEqual({
|
|
82
|
-
hasPendingChanges: false,
|
|
83
|
-
hasNeverSolved: false
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
function state(overrides: Partial<SolveSessionState> = {}): SolveSessionState {
|
|
89
|
-
return {
|
|
90
|
-
values: {},
|
|
91
|
-
error: '',
|
|
92
|
-
computeErrors: [],
|
|
93
|
-
computeWarnings: [],
|
|
94
|
-
meshes: [],
|
|
95
|
-
pendingValues: {},
|
|
96
|
-
hasPendingChanges: false,
|
|
97
|
-
hasNeverSolved: false,
|
|
98
|
-
...overrides
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
describe('applyValueChange', () => {
|
|
103
|
-
it('in auto-solve mode, records the value and asks to dispatch', () => {
|
|
104
|
-
const s = state({ values: { a: 1 } });
|
|
105
|
-
const out = applyValueChange(s, 'a', 2, /* instanceSolve */ true);
|
|
106
|
-
expect(out.state.values.a).toBe(2);
|
|
107
|
-
expect(out.shouldSolve).toBe(true);
|
|
108
|
-
expect(out.state.hasPendingChanges).toBe(false);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('in manual mode, defers: records pending + sets the flag, no dispatch', () => {
|
|
112
|
-
const s = state({ values: { a: 1 } });
|
|
113
|
-
const out = applyValueChange(s, 'a', 2, /* instanceSolve */ false);
|
|
114
|
-
expect(out.state.values.a).toBe(2);
|
|
115
|
-
expect(out.state.pendingValues.a).toBe(2);
|
|
116
|
-
expect(out.state.hasPendingChanges).toBe(true);
|
|
117
|
-
expect(out.shouldSolve).toBe(false);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe('applySolveResult', () => {
|
|
122
|
-
it('merges outputs into values and clears the lifecycle flags', () => {
|
|
123
|
-
const s = state({
|
|
124
|
-
values: { a: 1, out: null },
|
|
125
|
-
pendingValues: { a: 1 },
|
|
126
|
-
hasPendingChanges: true,
|
|
127
|
-
hasNeverSolved: true,
|
|
128
|
-
error: 'stale'
|
|
129
|
-
});
|
|
130
|
-
const out = applySolveResult(s, {
|
|
131
|
-
outputs: { out: 42 },
|
|
132
|
-
errors: ['e'],
|
|
133
|
-
warnings: ['w'],
|
|
134
|
-
meshes: [{ id: 'm' }]
|
|
135
|
-
});
|
|
136
|
-
expect(out.values.out).toBe(42);
|
|
137
|
-
expect(out.values.a).toBe(1);
|
|
138
|
-
expect(out.computeErrors).toEqual(['e']);
|
|
139
|
-
expect(out.computeWarnings).toEqual(['w']);
|
|
140
|
-
expect(out.meshes).toEqual([{ id: 'm' }]);
|
|
141
|
-
expect(out.pendingValues).toEqual({});
|
|
142
|
-
expect(out.hasPendingChanges).toBe(false);
|
|
143
|
-
expect(out.hasNeverSolved).toBe(false);
|
|
144
|
-
expect(out.error).toBe('');
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('treats missing result arrays as empty', () => {
|
|
148
|
-
const out = applySolveResult(state(), { outputs: {} });
|
|
149
|
-
expect(out.computeErrors).toEqual([]);
|
|
150
|
-
expect(out.computeWarnings).toEqual([]);
|
|
151
|
-
expect(out.meshes).toEqual([]);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
// Pure, framework-free transition logic for a Solve Session (see CONTEXT.md).
|
|
2
|
-
//
|
|
3
|
-
// This module holds the value/lifecycle state machine with no Svelte runes and no
|
|
4
|
-
// transport: how values are seeded (including client-sourced hydration), when a value
|
|
5
|
-
// change should dispatch a solve vs. defer it, and what a reported solve result does to
|
|
6
|
-
// the flags. The reactive wrapper in createSolveSession.svelte.ts is a thin shell over
|
|
7
|
-
// these functions; everything testable lives here.
|
|
8
|
-
|
|
9
|
-
import type { UISchema } from '@selvajs/schemas';
|
|
10
|
-
import { getDefaultValue } from '../schema/defaults';
|
|
11
|
-
import { getExternalInputs, type ExternalValueRef } from '../external/storage';
|
|
12
|
-
import type { SolveResult } from '../types/solveFn';
|
|
13
|
-
|
|
14
|
-
export interface SolveSessionState {
|
|
15
|
-
values: Record<string, unknown>;
|
|
16
|
-
error: string;
|
|
17
|
-
computeErrors: string[];
|
|
18
|
-
computeWarnings: string[];
|
|
19
|
-
meshes: unknown[];
|
|
20
|
-
/** Values changed since the last solve, in manual (instanceSolve === false) mode. */
|
|
21
|
-
pendingValues: Record<string, unknown>;
|
|
22
|
-
hasPendingChanges: boolean;
|
|
23
|
-
hasNeverSolved: boolean;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Reads a previously produced client-sourced value, or undefined if absent. */
|
|
27
|
-
export type ExternalReader = (ref: ExternalValueRef) => unknown | undefined;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Builds the initial `values` map for a schema. Non-client inputs get their declared
|
|
31
|
-
* default (falling back to the paramType default); client-sourced inputs are hydrated
|
|
32
|
-
* from `read` and left absent when no stored value exists so the missing-inputs panel
|
|
33
|
-
* can detect them. Outputs are seeded to null.
|
|
34
|
-
*/
|
|
35
|
-
export function buildInitialValues(
|
|
36
|
-
schema: UISchema,
|
|
37
|
-
scopeKey: string,
|
|
38
|
-
read: ExternalReader
|
|
39
|
-
): Record<string, unknown> {
|
|
40
|
-
const clientSet = new Set(getExternalInputs(schema).map((e) => e.paramId));
|
|
41
|
-
const values: Record<string, unknown> = {};
|
|
42
|
-
for (const input of schema.inputs) {
|
|
43
|
-
if (clientSet.has(input.id)) {
|
|
44
|
-
const stored = read({ scopeKey, inputId: input.id });
|
|
45
|
-
if (stored !== undefined) values[input.id] = stored;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
values[input.id] = input.default ?? getDefaultValue(input.paramType);
|
|
49
|
-
}
|
|
50
|
-
for (const output of schema.outputs) {
|
|
51
|
-
values[output.id] = null;
|
|
52
|
-
}
|
|
53
|
-
return values;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Initial lifecycle flags. Manual-solve schemas (instanceSolve === false) start dirty
|
|
58
|
-
* so the user must explicitly calculate; auto-solve schemas start clean.
|
|
59
|
-
*/
|
|
60
|
-
export function makeInitialFlags(instanceSolve: boolean | undefined): {
|
|
61
|
-
hasPendingChanges: boolean;
|
|
62
|
-
hasNeverSolved: boolean;
|
|
63
|
-
} {
|
|
64
|
-
const manual = instanceSolve === false;
|
|
65
|
-
return { hasPendingChanges: manual, hasNeverSolved: manual };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Records a single value change and decides whether it should dispatch a solve now.
|
|
70
|
-
* Auto-solve mode dispatches immediately; manual mode defers (records the pending value
|
|
71
|
-
* and raises the dirty flag) and never dispatches.
|
|
72
|
-
*/
|
|
73
|
-
export function applyValueChange(
|
|
74
|
-
state: SolveSessionState,
|
|
75
|
-
id: string,
|
|
76
|
-
value: unknown,
|
|
77
|
-
instanceSolve: boolean | undefined
|
|
78
|
-
): { state: SolveSessionState; shouldSolve: boolean } {
|
|
79
|
-
state.values[id] = value;
|
|
80
|
-
if (instanceSolve === false) {
|
|
81
|
-
state.pendingValues[id] = value;
|
|
82
|
-
state.hasPendingChanges = true;
|
|
83
|
-
return { state, shouldSolve: false };
|
|
84
|
-
}
|
|
85
|
-
return { state, shouldSolve: true };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Projects the session's live values down to solve INPUTS. Solve outputs are merged
|
|
90
|
-
* into the same values map after each solve (applySolveResult) so widgets like
|
|
91
|
-
* dynamic value lists can read them — but they are not solve inputs, and echoing
|
|
92
|
-
* them back to the driver re-uploads potentially MB-sized payloads (a measured
|
|
93
|
-
* 6.4 MB options list) that no backend reads. Every transport gets this projection
|
|
94
|
-
* for free by going through the session's dispatch.
|
|
95
|
-
*/
|
|
96
|
-
export function pickInputValues(
|
|
97
|
-
schema: UISchema | undefined,
|
|
98
|
-
values: Record<string, unknown>
|
|
99
|
-
): Record<string, unknown> {
|
|
100
|
-
if (!schema?.inputs) return values;
|
|
101
|
-
const picked: Record<string, unknown> = {};
|
|
102
|
-
for (const input of schema.inputs) {
|
|
103
|
-
if (input.id in values) picked[input.id] = values[input.id];
|
|
104
|
-
}
|
|
105
|
-
return picked;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Merges a reported solve result into the state and clears the post-solve lifecycle
|
|
110
|
-
* flags. Missing result arrays are treated as empty.
|
|
111
|
-
*/
|
|
112
|
-
export function applySolveResult(state: SolveSessionState, result: SolveResult): SolveSessionState {
|
|
113
|
-
state.error = '';
|
|
114
|
-
state.computeErrors = result.errors ?? [];
|
|
115
|
-
state.computeWarnings = result.warnings ?? [];
|
|
116
|
-
state.meshes = result.meshes ?? [];
|
|
117
|
-
Object.assign(state.values, result.outputs);
|
|
118
|
-
state.pendingValues = {};
|
|
119
|
-
state.hasPendingChanges = false;
|
|
120
|
-
state.hasNeverSolved = false;
|
|
121
|
-
return state;
|
|
122
|
-
}
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import * as THREE from 'three';
|
|
3
|
-
import { createSolveMemo, stableInputKey } from './solveMemo';
|
|
4
|
-
import type { SolveResult } from '../types/solveFn';
|
|
5
|
-
|
|
6
|
-
// Pins the client-side result memo (M2): stable keying across key order, LRU recency and
|
|
7
|
-
// eviction, hit/miss semantics, and clear(). The driver wiring is pinned separately in
|
|
8
|
-
// createSolveSession.test.ts.
|
|
9
|
-
|
|
10
|
-
const result = (tag: string): SolveResult => ({ outputs: { out: tag } });
|
|
11
|
-
|
|
12
|
-
/** A mesh-bearing result — the shape that exposed audit C1. */
|
|
13
|
-
function meshResult(tag: string): SolveResult {
|
|
14
|
-
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial());
|
|
15
|
-
mesh.name = tag;
|
|
16
|
-
return { outputs: { out: tag }, meshes: [mesh] };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Mirrors `clearScene`'s disposal of whatever the viewer currently holds. */
|
|
20
|
-
function disposeLikeViewer(res: SolveResult | undefined): void {
|
|
21
|
-
res?.meshes?.forEach((m: THREE.Object3D) =>
|
|
22
|
-
m.traverse((child) => {
|
|
23
|
-
const r = child as Partial<THREE.Mesh> & THREE.Object3D;
|
|
24
|
-
r.geometry?.dispose();
|
|
25
|
-
const mat = r.material;
|
|
26
|
-
if (!mat) return;
|
|
27
|
-
(Array.isArray(mat) ? mat : [mat]).forEach((m) => m.dispose());
|
|
28
|
-
})
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Count `dispose()` calls across ALL geometries for the duration of a test.
|
|
34
|
-
*
|
|
35
|
-
* The memo stores a private clone, so watching the object handed to `set` would prove
|
|
36
|
-
* nothing — the retained copy is deliberately unreachable. Spying the prototype observes
|
|
37
|
-
* disposal of whichever instance the memo actually owns, which is the real invariant:
|
|
38
|
-
* an entry leaving the map must release its buffers.
|
|
39
|
-
*/
|
|
40
|
-
function countDisposals(): { count: () => number; restore: () => void } {
|
|
41
|
-
const original = THREE.BufferGeometry.prototype.dispose;
|
|
42
|
-
let n = 0;
|
|
43
|
-
THREE.BufferGeometry.prototype.dispose = function (this: THREE.BufferGeometry) {
|
|
44
|
-
n++;
|
|
45
|
-
return original.call(this);
|
|
46
|
-
};
|
|
47
|
-
return {
|
|
48
|
-
count: () => n,
|
|
49
|
-
restore: () => {
|
|
50
|
-
THREE.BufferGeometry.prototype.dispose = original;
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
describe('stableInputKey', () => {
|
|
56
|
-
it('is insensitive to object key order', () => {
|
|
57
|
-
expect(stableInputKey({ a: 1, b: 2 })).toBe(stableInputKey({ b: 2, a: 1 }));
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('sorts keys at every level (nested objects)', () => {
|
|
61
|
-
expect(stableInputKey({ o: { x: 1, y: 2 } })).toBe(stableInputKey({ o: { y: 2, x: 1 } }));
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('distinguishes different values', () => {
|
|
65
|
-
expect(stableInputKey({ a: 1 })).not.toBe(stableInputKey({ a: 2 }));
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('preserves array order (arrays are ordered)', () => {
|
|
69
|
-
expect(stableInputKey({ a: [1, 2] })).not.toBe(stableInputKey({ a: [2, 1] }));
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('handles null and primitive values', () => {
|
|
73
|
-
expect(stableInputKey({ a: null, b: 'x', c: true })).toBe(
|
|
74
|
-
stableInputKey({ c: true, b: 'x', a: null })
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe('createSolveMemo', () => {
|
|
80
|
-
it('returns undefined on a miss', () => {
|
|
81
|
-
const memo = createSolveMemo();
|
|
82
|
-
expect(memo.get({ a: 1 })).toBeUndefined();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('round-trips a stored result by equal inputs regardless of key order', () => {
|
|
86
|
-
const memo = createSolveMemo();
|
|
87
|
-
memo.set({ a: 1, b: 2 }, result('r'));
|
|
88
|
-
expect(memo.get({ b: 2, a: 1 })).toEqual(result('r'));
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('caches errored results (a complete, deterministic solve outcome)', () => {
|
|
92
|
-
const memo = createSolveMemo();
|
|
93
|
-
const errored: SolveResult = { outputs: {}, errors: ['boom'] };
|
|
94
|
-
memo.set({ a: 1 }, errored);
|
|
95
|
-
expect(memo.get({ a: 1 })).toEqual(errored);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('evicts the least-recently-used entry past capacity', () => {
|
|
99
|
-
const memo = createSolveMemo(2);
|
|
100
|
-
memo.set({ k: 1 }, result('1'));
|
|
101
|
-
memo.set({ k: 2 }, result('2'));
|
|
102
|
-
memo.set({ k: 3 }, result('3')); // evicts k:1
|
|
103
|
-
expect(memo.get({ k: 1 })).toBeUndefined();
|
|
104
|
-
expect(memo.get({ k: 2 })).toEqual(result('2'));
|
|
105
|
-
expect(memo.get({ k: 3 })).toEqual(result('3'));
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('a get refreshes recency, protecting the entry from eviction', () => {
|
|
109
|
-
const memo = createSolveMemo(2);
|
|
110
|
-
memo.set({ k: 1 }, result('1'));
|
|
111
|
-
memo.set({ k: 2 }, result('2'));
|
|
112
|
-
memo.get({ k: 1 }); // k:1 now most-recent
|
|
113
|
-
memo.set({ k: 3 }, result('3')); // evicts k:2, not k:1
|
|
114
|
-
expect(memo.get({ k: 1 })).toEqual(result('1'));
|
|
115
|
-
expect(memo.get({ k: 2 })).toBeUndefined();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('re-setting an existing key updates the value without growing size', () => {
|
|
119
|
-
const memo = createSolveMemo(1);
|
|
120
|
-
memo.set({ k: 1 }, result('old'));
|
|
121
|
-
memo.set({ k: 1 }, result('new'));
|
|
122
|
-
expect(memo.get({ k: 1 })).toEqual(result('new'));
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('clear() drops every entry', () => {
|
|
126
|
-
const memo = createSolveMemo();
|
|
127
|
-
memo.set({ a: 1 }, result('r'));
|
|
128
|
-
memo.clear();
|
|
129
|
-
expect(memo.get({ a: 1 })).toBeUndefined();
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Audit C1. The memo caches whole SolveResults, including live three.js objects, but the
|
|
134
|
-
// viewer's `clearScene` disposes the meshes it is handed on the next scene update. Every
|
|
135
|
-
// pre-existing test above used mesh-free results, so nothing caught it.
|
|
136
|
-
describe('createSolveMemo — GPU object ownership (audit C1)', () => {
|
|
137
|
-
it('serves a usable mesh after the viewer disposed the one it was given', () => {
|
|
138
|
-
const memo = createSolveMemo();
|
|
139
|
-
const stored = meshResult('a');
|
|
140
|
-
memo.set({ k: 1 }, stored);
|
|
141
|
-
|
|
142
|
-
// Solve 1 renders: the viewer owns and (on the next update) disposes these meshes.
|
|
143
|
-
const first = memo.get({ k: 1 })!;
|
|
144
|
-
disposeLikeViewer(first);
|
|
145
|
-
|
|
146
|
-
// Slider returns to the same value → memo hit. The served mesh must be renderable,
|
|
147
|
-
// not the corpse the viewer just disposed.
|
|
148
|
-
const second = memo.get({ k: 1 })!;
|
|
149
|
-
const geo = (second.meshes![0] as THREE.Mesh).geometry;
|
|
150
|
-
expect(geo.attributes.position).toBeDefined();
|
|
151
|
-
expect(second.meshes![0]).not.toBe(first.meshes![0]);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('never hands the same mesh instance to two consumers', () => {
|
|
155
|
-
// The scene takes ownership of what it is given (updateScene → scene.add), so two
|
|
156
|
-
// hits handing out one instance means a double-add and a shared disposal fate.
|
|
157
|
-
const memo = createSolveMemo();
|
|
158
|
-
memo.set({ k: 1 }, meshResult('a'));
|
|
159
|
-
expect(memo.get({ k: 1 })!.meshes![0]).not.toBe(memo.get({ k: 1 })!.meshes![0]);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('preserves non-mesh result fields on a hit', () => {
|
|
163
|
-
const memo = createSolveMemo();
|
|
164
|
-
const stored: SolveResult = { ...meshResult('a'), errors: ['e'], warnings: ['w'] };
|
|
165
|
-
memo.set({ k: 1 }, stored);
|
|
166
|
-
const hit = memo.get({ k: 1 })!;
|
|
167
|
-
expect(hit.outputs).toEqual({ out: 'a' });
|
|
168
|
-
expect(hit.errors).toEqual(['e']);
|
|
169
|
-
expect(hit.warnings).toEqual(['w']);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('releases GPU memory when an entry is evicted', () => {
|
|
173
|
-
const memo = createSolveMemo(1);
|
|
174
|
-
memo.set({ k: 1 }, meshResult('a'));
|
|
175
|
-
|
|
176
|
-
const spy = countDisposals();
|
|
177
|
-
try {
|
|
178
|
-
memo.set({ k: 2 }, meshResult('b')); // evicts k:1
|
|
179
|
-
expect(spy.count()).toBe(1);
|
|
180
|
-
} finally {
|
|
181
|
-
spy.restore();
|
|
182
|
-
}
|
|
183
|
-
expect(memo.get({ k: 1 })).toBeUndefined();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('releases GPU memory on clear() (definition switch)', () => {
|
|
187
|
-
const memo = createSolveMemo();
|
|
188
|
-
memo.set({ k: 1 }, meshResult('a'));
|
|
189
|
-
memo.set({ k: 2 }, meshResult('b'));
|
|
190
|
-
|
|
191
|
-
const spy = countDisposals();
|
|
192
|
-
try {
|
|
193
|
-
memo.clear();
|
|
194
|
-
expect(spy.count()).toBe(2);
|
|
195
|
-
} finally {
|
|
196
|
-
spy.restore();
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('releases the old value when a key is overwritten', () => {
|
|
201
|
-
const memo = createSolveMemo();
|
|
202
|
-
memo.set({ k: 1 }, meshResult('old'));
|
|
203
|
-
|
|
204
|
-
const spy = countDisposals();
|
|
205
|
-
try {
|
|
206
|
-
memo.set({ k: 1 }, meshResult('new'));
|
|
207
|
-
expect(spy.count()).toBe(1);
|
|
208
|
-
} finally {
|
|
209
|
-
spy.restore();
|
|
210
|
-
}
|
|
211
|
-
expect(memo.get({ k: 1 })!.outputs).toEqual({ out: 'new' });
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it('handles mesh-free results without touching disposal paths', () => {
|
|
215
|
-
const memo = createSolveMemo(1);
|
|
216
|
-
memo.set({ k: 1 }, result('1'));
|
|
217
|
-
memo.set({ k: 2 }, result('2')); // evicts k:1 — must not throw
|
|
218
|
-
expect(memo.get({ k: 2 })).toEqual(result('2'));
|
|
219
|
-
});
|
|
220
|
-
});
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
// Client-side solve result memo (M2). A small LRU keyed on a stable serialization of
|
|
2
|
-
// the solve INPUTS, sitting in front of the request/response driver. Dragging a slider
|
|
3
|
-
// back to a value already solved this session returns instantly without a network
|
|
4
|
-
// round-trip — killing slider-scrub storms before they leave the browser. It pairs with
|
|
5
|
-
// the throttle's latest-wins abort: the memo only serves values that fully solved, so a
|
|
6
|
-
// hit is always a complete result.
|
|
7
|
-
//
|
|
8
|
-
// GPU ownership (audit C1): a SolveResult carries live three.js objects, and the viewer
|
|
9
|
-
// takes ownership of every mesh array it renders — `updateScene` disposes the previous
|
|
10
|
-
// content on the next update. So the memo can neither hand out its own instances (they'd
|
|
11
|
-
// be disposed under it, then re-added dead on the next hit) nor drop entries silently
|
|
12
|
-
// (their GPU buffers would leak). It therefore keeps private copies, serves a fresh clone
|
|
13
|
-
// per hit, and disposes an entry whenever it leaves the map.
|
|
14
|
-
|
|
15
|
-
import * as THREE from 'three';
|
|
16
|
-
import type { SolveResult } from '../types/solveFn';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Deterministic string key for a set of input values. Object keys are sorted at every
|
|
20
|
-
* level so two logically-equal inputs (built in different key order) collide, matching
|
|
21
|
-
* the server's stable-input keying intent. Values are plain JSON (numbers, strings,
|
|
22
|
-
* booleans, arrays) — the projected solve inputs never contain functions or cycles.
|
|
23
|
-
*/
|
|
24
|
-
export function stableInputKey(values: Record<string, unknown>): string {
|
|
25
|
-
return serialize(values);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function serialize(value: unknown): string {
|
|
29
|
-
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
30
|
-
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
|
|
31
|
-
const obj = value as Record<string, unknown>;
|
|
32
|
-
const keys = Object.keys(obj).sort();
|
|
33
|
-
return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Deep-clone a solve's scene objects so the caller owns them outright.
|
|
38
|
-
*
|
|
39
|
-
* `Object3D.clone()` copies the transform hierarchy but SHARES `geometry` and `material`
|
|
40
|
-
* by reference — which is exactly the aliasing that makes a naive clone useless here, so
|
|
41
|
-
* geometry is copied explicitly. Materials are deliberately left shared: the viewer's
|
|
42
|
-
* `clearScene` skips disposing anything in its SHARED_MATERIALS set (module-scope
|
|
43
|
-
* singletons reused across solves), and per-mesh materials are cheap to recreate but
|
|
44
|
-
* expensive to re-compile as new shader programs.
|
|
45
|
-
*/
|
|
46
|
-
function cloneSceneObjects(meshes: THREE.Object3D[]): THREE.Object3D[] {
|
|
47
|
-
return meshes.map((root) => {
|
|
48
|
-
const copy = root.clone(true);
|
|
49
|
-
const sources: THREE.Object3D[] = [];
|
|
50
|
-
root.traverse((child) => sources.push(child));
|
|
51
|
-
let i = 0;
|
|
52
|
-
copy.traverse((child) => {
|
|
53
|
-
const source = sources[i++] as Partial<THREE.Mesh> & THREE.Object3D;
|
|
54
|
-
const target = child as Partial<THREE.Mesh> & THREE.Object3D;
|
|
55
|
-
if (source.geometry) target.geometry = source.geometry.clone();
|
|
56
|
-
});
|
|
57
|
-
return copy;
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Release an entry's GPU buffers. Mirrors `clearScene`'s traversal, minus materials —
|
|
63
|
-
* the memo never owns those (see {@link cloneSceneObjects}), so disposing one here would
|
|
64
|
-
* free a singleton still referenced by live scene content.
|
|
65
|
-
*/
|
|
66
|
-
function disposeSceneObjects(result: SolveResult): void {
|
|
67
|
-
result.meshes?.forEach((root: THREE.Object3D) =>
|
|
68
|
-
root.traverse((child) => {
|
|
69
|
-
(child as Partial<THREE.Mesh>).geometry?.dispose();
|
|
70
|
-
})
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface SolveMemo {
|
|
75
|
-
/** Returns a previously stored result for these inputs, or undefined on a miss. */
|
|
76
|
-
get(values: Record<string, unknown>): SolveResult | undefined;
|
|
77
|
-
/** Records a completed solve result under its input key (evicting the LRU tail). */
|
|
78
|
-
set(values: Record<string, unknown>, result: SolveResult): void;
|
|
79
|
-
/** Drops every entry — called when the active definition changes. */
|
|
80
|
-
clear(): void;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
|
|
85
|
-
* default is deliberately small — this targets the tight slider-scrub loop, not a durable
|
|
86
|
-
* cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
|
|
87
|
-
*/
|
|
88
|
-
export function createSolveMemo(max = 16): SolveMemo {
|
|
89
|
-
const entries = new Map<string, SolveResult>();
|
|
90
|
-
|
|
91
|
-
/** Drop an entry and release its GPU buffers. No-op when the key is absent. */
|
|
92
|
-
function evict(key: string): void {
|
|
93
|
-
const entry = entries.get(key);
|
|
94
|
-
if (entry === undefined) return;
|
|
95
|
-
entries.delete(key);
|
|
96
|
-
disposeSceneObjects(entry);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
get(values) {
|
|
101
|
-
const key = stableInputKey(values);
|
|
102
|
-
const hit = entries.get(key);
|
|
103
|
-
if (hit === undefined) return undefined;
|
|
104
|
-
// Refresh recency: re-insert at the tail.
|
|
105
|
-
entries.delete(key);
|
|
106
|
-
entries.set(key, hit);
|
|
107
|
-
// A memo hit skips the transport entirely, so no other log line fires —
|
|
108
|
-
// this line is the only trace it wasn't a fresh solve.
|
|
109
|
-
console.info(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
|
|
110
|
-
// Clone on the way out: the viewer disposes what it renders, so the retained
|
|
111
|
-
// entry must never be the instance handed to it (audit C1).
|
|
112
|
-
if (!hit.meshes?.length) return hit;
|
|
113
|
-
return { ...hit, meshes: cloneSceneObjects(hit.meshes) };
|
|
114
|
-
},
|
|
115
|
-
set(values, result) {
|
|
116
|
-
const key = stableInputKey(values);
|
|
117
|
-
// Overwriting a key strands the old value's buffers unless it's disposed first.
|
|
118
|
-
evict(key);
|
|
119
|
-
// Store a private copy for the same reason `get` clones: the caller reports this
|
|
120
|
-
// same object to the viewer, which will dispose it on the next scene update.
|
|
121
|
-
entries.set(
|
|
122
|
-
key,
|
|
123
|
-
result.meshes?.length ? { ...result, meshes: cloneSceneObjects(result.meshes) } : result
|
|
124
|
-
);
|
|
125
|
-
while (entries.size > max) {
|
|
126
|
-
const oldest = entries.keys().next().value;
|
|
127
|
-
if (oldest === undefined) break;
|
|
128
|
-
evict(oldest);
|
|
129
|
-
console.info(`[Compute/memo] evicted LRU entry (cap ${max})`);
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
clear() {
|
|
133
|
-
if (entries.size > 0) {
|
|
134
|
-
console.info(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
|
|
135
|
-
}
|
|
136
|
-
entries.forEach(disposeSceneObjects);
|
|
137
|
-
entries.clear();
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
}
|