@selvajs/ui 5.0.1 → 6.0.0-beta.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/dist/components/compute/ComputeApp.svelte +15 -7
- package/dist/components/compute/ComputeApp.svelte.d.ts +1 -1
- 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 +20 -3
- package/dist/compute/useSolveSession.svelte.d.ts +12 -0
- package/dist/compute/useSolveSession.svelte.js +76 -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 +5 -3
- package/src/lib/components/compute/ComputeApp.svelte +15 -7
- 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 +20 -3
- package/src/lib/compute/mesh-policy-wiring.test.ts +72 -0
- package/src/lib/compute/useSolveSession.svelte.ts +83 -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/types/solveFn.ts +0 -29
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
// Reactive Solve Session (see CONTEXT.md): the $state-backed shell over the pure
|
|
2
|
-
// transition logic in solve-session-core.ts. It owns the live values/flags, delegates
|
|
3
|
-
// every transition to the core, and drives solves through a transport-agnostic
|
|
4
|
-
// SolveDriver. A completed solve re-enters via report().
|
|
5
|
-
|
|
6
|
-
import type { UISchema } from '@selvajs/schemas';
|
|
7
|
-
import { readExternalValue } from '../external/storage';
|
|
8
|
-
import type { SolveFn, SolveResult } from '../types/solveFn';
|
|
9
|
-
import { createComputeThrottle } from './computeThrottle.svelte';
|
|
10
|
-
import { createSolveMemo } from './solveMemo';
|
|
11
|
-
import {
|
|
12
|
-
buildInitialValues,
|
|
13
|
-
makeInitialFlags,
|
|
14
|
-
applyValueChange,
|
|
15
|
-
applySolveResult,
|
|
16
|
-
pickInputValues,
|
|
17
|
-
type SolveSessionState
|
|
18
|
-
} from './solve-session-core';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The transport behind a Solve Session. Knows how to start and cancel a solve and
|
|
22
|
-
* reports its in-flight state. It does NOT return outputs — those come back via the
|
|
23
|
-
* session's report() so push transports (WebSocket) fit without contortion.
|
|
24
|
-
*/
|
|
25
|
-
export interface SolveDriver {
|
|
26
|
-
solve(values: Record<string, unknown>): void;
|
|
27
|
-
cancel(): void;
|
|
28
|
-
readonly isSolving: boolean;
|
|
29
|
-
/**
|
|
30
|
-
* Drops any cached solve results the driver holds. Optional — only drivers with a
|
|
31
|
-
* client-side memo (the request/response driver) implement it. Called on rebuild so a
|
|
32
|
-
* definition swap can't serve a stale result from a prior definition's input space.
|
|
33
|
-
*/
|
|
34
|
-
clearCache?(): void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface SolveSession {
|
|
38
|
-
readonly values: Record<string, unknown>;
|
|
39
|
-
readonly error: string;
|
|
40
|
-
readonly computeErrors: string[];
|
|
41
|
-
readonly computeWarnings: string[];
|
|
42
|
-
readonly meshes: unknown[];
|
|
43
|
-
readonly hasPendingChanges: boolean;
|
|
44
|
-
readonly hasNeverSolved: boolean;
|
|
45
|
-
readonly isSolving: boolean;
|
|
46
|
-
/**
|
|
47
|
-
* Records a value change and dispatches a solve unless the schema is manual-solve.
|
|
48
|
-
* `forceSolve` overrides manual mode for system-initiated reconciliation (e.g. a dynamic
|
|
49
|
-
* value list pruning a vanished selection), so the output can't lag behind the new value.
|
|
50
|
-
*/
|
|
51
|
-
setValue(id: string, value: unknown, forceSolve?: boolean): void;
|
|
52
|
-
/** Explicit "calculate" — dispatches a solve with the current values. */
|
|
53
|
-
solve(): void;
|
|
54
|
-
/** Merges incoming values, then solves (auto) or marks dirty (manual). */
|
|
55
|
-
loadValues(incoming: Record<string, unknown>): void;
|
|
56
|
-
/** Re-seed for a new active definition: rebuild values, clear outputs, gate the solve. */
|
|
57
|
-
rebuild(schema: UISchema, scopeKey: string): void;
|
|
58
|
-
/** Feed a completed solve result back into the session (called by the driver/host). */
|
|
59
|
-
report(result: SolveResult): void;
|
|
60
|
-
/** Report a solve failure (transport/solver error) — surfaces in `error`. */
|
|
61
|
-
reportError(message: string): void;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface SolveSessionArgs {
|
|
65
|
-
schema: UISchema;
|
|
66
|
-
scopeKey: string;
|
|
67
|
-
driver: SolveDriver;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function createSolveSession(args: SolveSessionArgs): SolveSession {
|
|
71
|
-
let currentSchema = args.schema;
|
|
72
|
-
|
|
73
|
-
const flags = makeInitialFlags(currentSchema?.instanceSolve);
|
|
74
|
-
const state = $state<SolveSessionState>({
|
|
75
|
-
values: buildInitialValues(currentSchema, args.scopeKey, readExternalValue),
|
|
76
|
-
error: '',
|
|
77
|
-
computeErrors: [],
|
|
78
|
-
computeWarnings: [],
|
|
79
|
-
meshes: [],
|
|
80
|
-
pendingValues: {},
|
|
81
|
-
hasPendingChanges: flags.hasPendingChanges,
|
|
82
|
-
hasNeverSolved: flags.hasNeverSolved
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
function dispatch() {
|
|
86
|
-
// Input values only: outputs merged into state.values (for widgets that read
|
|
87
|
-
// them, e.g. dynamic value lists) must not be echoed back to the transport.
|
|
88
|
-
args.driver.solve(pickInputValues(currentSchema, $state.snapshot(state.values)));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
get values() {
|
|
93
|
-
return state.values;
|
|
94
|
-
},
|
|
95
|
-
get error() {
|
|
96
|
-
return state.error;
|
|
97
|
-
},
|
|
98
|
-
get computeErrors() {
|
|
99
|
-
return state.computeErrors;
|
|
100
|
-
},
|
|
101
|
-
get computeWarnings() {
|
|
102
|
-
return state.computeWarnings;
|
|
103
|
-
},
|
|
104
|
-
get meshes() {
|
|
105
|
-
return state.meshes;
|
|
106
|
-
},
|
|
107
|
-
get hasPendingChanges() {
|
|
108
|
-
return state.hasPendingChanges;
|
|
109
|
-
},
|
|
110
|
-
get hasNeverSolved() {
|
|
111
|
-
return state.hasNeverSolved;
|
|
112
|
-
},
|
|
113
|
-
get isSolving() {
|
|
114
|
-
return args.driver.isSolving;
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
setValue(id, value, forceSolve = false) {
|
|
118
|
-
const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
|
|
119
|
-
if (shouldSolve || forceSolve) {
|
|
120
|
-
// A forced solve reconciles the deferred output; clear the dirty flags it raised.
|
|
121
|
-
if (forceSolve && !shouldSolve) {
|
|
122
|
-
state.pendingValues = {};
|
|
123
|
-
state.hasPendingChanges = false;
|
|
124
|
-
}
|
|
125
|
-
dispatch();
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
solve() {
|
|
130
|
-
dispatch();
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
loadValues(incoming) {
|
|
134
|
-
Object.assign(state.values, incoming);
|
|
135
|
-
if (currentSchema?.instanceSolve !== false) {
|
|
136
|
-
dispatch();
|
|
137
|
-
} else {
|
|
138
|
-
state.hasPendingChanges = true;
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
|
|
142
|
-
rebuild(schema, scopeKey) {
|
|
143
|
-
currentSchema = schema;
|
|
144
|
-
// Drop the driver's result memo: the new definition has its own input space, so
|
|
145
|
-
// a matching input key from the prior definition must not serve its stale result.
|
|
146
|
-
args.driver.clearCache?.();
|
|
147
|
-
state.meshes = [];
|
|
148
|
-
state.error = '';
|
|
149
|
-
state.computeErrors = [];
|
|
150
|
-
state.computeWarnings = [];
|
|
151
|
-
state.pendingValues = {};
|
|
152
|
-
state.values = buildInitialValues(schema, scopeKey, readExternalValue);
|
|
153
|
-
const f = makeInitialFlags(schema?.instanceSolve);
|
|
154
|
-
state.hasPendingChanges = f.hasPendingChanges;
|
|
155
|
-
state.hasNeverSolved = f.hasNeverSolved;
|
|
156
|
-
if (schema && Object.keys(state.values).length > 0 && schema.instanceSolve !== false) {
|
|
157
|
-
dispatch();
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
|
|
161
|
-
report(result) {
|
|
162
|
-
applySolveResult(state, result);
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
reportError(message) {
|
|
166
|
-
state.error = message;
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** The slice of a SolveSession a driver feeds completed/failed solves back into. */
|
|
172
|
-
export interface SolveReporter {
|
|
173
|
-
report(result: SolveResult): void;
|
|
174
|
-
reportError(message: string): void;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Request/response Solve Driver: wraps createComputeThrottle around a SolveFn and feeds
|
|
179
|
-
* the resolved result back through the reporter. One solve in flight at a time; the
|
|
180
|
-
* latest triggered values win. Used by ComputeApp (Rhino.Compute over HTTP).
|
|
181
|
-
*
|
|
182
|
-
* Because the session and driver reference each other, the host passes the reporter
|
|
183
|
-
* lazily (`() => session`) so it can construct the session with the driver in hand.
|
|
184
|
-
*/
|
|
185
|
-
export function createRequestResponseDriver(
|
|
186
|
-
onSolve: SolveFn,
|
|
187
|
-
getReporter: () => SolveReporter,
|
|
188
|
-
options: { timeout?: number } = {}
|
|
189
|
-
): SolveDriver {
|
|
190
|
-
// M2: a small LRU memoizing completed solves by their input values. A slider dragged
|
|
191
|
-
// back to a value already solved this session reports instantly without a network
|
|
192
|
-
// round-trip. The check lives inside the throttled computeFn so the throttle's
|
|
193
|
-
// latest-wins ordering still holds — a hit only serves after the throttle picks these
|
|
194
|
-
// values as the ones to run.
|
|
195
|
-
const memo = createSolveMemo();
|
|
196
|
-
|
|
197
|
-
const throttle = createComputeThrottle<Record<string, unknown>>(async (values, signal) => {
|
|
198
|
-
const cached = memo.get(values);
|
|
199
|
-
if (cached !== undefined) {
|
|
200
|
-
if (signal.aborted) return;
|
|
201
|
-
getReporter().report(cached);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
try {
|
|
205
|
-
const result = await onSolve(values, signal);
|
|
206
|
-
if (signal.aborted) {
|
|
207
|
-
// Discarded on purpose (superseded/cancelled) — never memoized or reported.
|
|
208
|
-
console.debug('[Compute/session] solve completed after abort — result discarded');
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
memo.set(values, result);
|
|
212
|
-
getReporter().report(result);
|
|
213
|
-
} catch (err) {
|
|
214
|
-
if (signal.aborted) {
|
|
215
|
-
console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
// reportError only sets reactive state; without this line a transport
|
|
219
|
-
// failure leaves no console trace at all.
|
|
220
|
-
console.warn('[Compute/session] solve failed:', err);
|
|
221
|
-
getReporter().reportError(err instanceof Error ? err.message : String(err));
|
|
222
|
-
}
|
|
223
|
-
}, options);
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
solve(values) {
|
|
227
|
-
throttle.trigger(values);
|
|
228
|
-
},
|
|
229
|
-
cancel() {
|
|
230
|
-
throttle.cancel();
|
|
231
|
-
},
|
|
232
|
-
get isSolving() {
|
|
233
|
-
return throttle.isComputing;
|
|
234
|
-
},
|
|
235
|
-
clearCache() {
|
|
236
|
-
memo.clear();
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
createSolveSession,
|
|
4
|
-
createRequestResponseDriver,
|
|
5
|
-
type SolveDriver,
|
|
6
|
-
type SolveReporter
|
|
7
|
-
} from './createSolveSession.svelte';
|
|
8
|
-
import type { UISchema } from '@selvajs/schemas';
|
|
9
|
-
import type { SolveResult } from '../types/solveFn';
|
|
10
|
-
|
|
11
|
-
// Covers the reactive wrapper's dispatch decisions — specifically the `forceSolve` path
|
|
12
|
-
// added for dynamic-value-list reconciliation. The pure transition logic is pinned in
|
|
13
|
-
// solve-session-core.test.ts; this file pins how the rune shell turns those decisions into
|
|
14
|
-
// driver.solve() calls. Rune module, so it runs under the svelte vitest plugin.
|
|
15
|
-
|
|
16
|
-
function schema(instanceSolve?: boolean): UISchema {
|
|
17
|
-
return {
|
|
18
|
-
id: 'test',
|
|
19
|
-
name: 'Test',
|
|
20
|
-
instanceSolve,
|
|
21
|
-
inputs: [{ id: 'a', paramId: 'a', paramType: 'text', default: 'x' }],
|
|
22
|
-
outputs: [{ id: 'out' }],
|
|
23
|
-
layout: { type: 'flat', groups: [{ items: [] }] }
|
|
24
|
-
} as unknown as UISchema;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Records every solve dispatch with the snapshot of values it received.
|
|
28
|
-
function recordingDriver(): SolveDriver & { solves: Record<string, unknown>[] } {
|
|
29
|
-
const solves: Record<string, unknown>[] = [];
|
|
30
|
-
return {
|
|
31
|
-
solves,
|
|
32
|
-
solve(values) {
|
|
33
|
-
solves.push(values);
|
|
34
|
-
},
|
|
35
|
-
cancel() {},
|
|
36
|
-
get isSolving() {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
describe('createSolveSession.setValue', () => {
|
|
43
|
-
it('auto-solve mode dispatches on every value change', () => {
|
|
44
|
-
const driver = recordingDriver();
|
|
45
|
-
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
46
|
-
session.setValue('a', 'y');
|
|
47
|
-
expect(driver.solves.length).toBe(1);
|
|
48
|
-
expect(driver.solves[0].a).toBe('y');
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('manual mode defers: marks pending, no dispatch', () => {
|
|
52
|
-
const driver = recordingDriver();
|
|
53
|
-
const session = createSolveSession({ schema: schema(false), scopeKey: 's', driver });
|
|
54
|
-
session.setValue('a', 'y');
|
|
55
|
-
expect(driver.solves.length).toBe(0);
|
|
56
|
-
expect(session.hasPendingChanges).toBe(true);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('forceSolve dispatches even in manual mode and clears the dirty flags', () => {
|
|
60
|
-
const driver = recordingDriver();
|
|
61
|
-
const session = createSolveSession({ schema: schema(false), scopeKey: 's', driver });
|
|
62
|
-
// Reconcile a system-initiated change (e.g. a pruned dynamic-list selection).
|
|
63
|
-
session.setValue('a', 'reconciled', true);
|
|
64
|
-
expect(driver.solves.length).toBe(1);
|
|
65
|
-
expect(driver.solves[0].a).toBe('reconciled');
|
|
66
|
-
// The forced solve produces the matching output, so nothing is left pending.
|
|
67
|
-
expect(session.hasPendingChanges).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('forceSolve in auto mode still dispatches exactly once', () => {
|
|
71
|
-
const driver = recordingDriver();
|
|
72
|
-
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
73
|
-
session.setValue('a', 'y', true);
|
|
74
|
-
expect(driver.solves.length).toBe(1);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('never echoes output-keyed values back to the driver', () => {
|
|
78
|
-
const driver = recordingDriver();
|
|
79
|
-
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
80
|
-
// A solve result merges outputs into the session's values map (how widgets
|
|
81
|
-
// like dynamic value lists read them) — e.g. a multi-MB options payload.
|
|
82
|
-
session.report({ outputs: { out: { options: { huge: 'payload' } } } });
|
|
83
|
-
session.setValue('a', 'y');
|
|
84
|
-
expect(driver.solves.length).toBe(1);
|
|
85
|
-
expect(driver.solves[0].a).toBe('y');
|
|
86
|
-
// The output entry lives in session.values for widgets…
|
|
87
|
-
expect(session.values.out).toBeDefined();
|
|
88
|
-
// …but must not travel back through the transport.
|
|
89
|
-
expect(driver.solves[0]).not.toHaveProperty('out');
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// M2: the request/response driver's client-side result memo. Verifies a slider returning
|
|
94
|
-
// to a solved value serves from memory (no onSolve call) and that a definition rebuild
|
|
95
|
-
// drops the memo so a stale result can't cross the swap.
|
|
96
|
-
describe('createRequestResponseDriver — client memo', () => {
|
|
97
|
-
// Collects reported results so the memo hit/miss can be observed without a session.
|
|
98
|
-
function collectingReporter(): SolveReporter & { reports: SolveResult[]; errors: string[] } {
|
|
99
|
-
const reports: SolveResult[] = [];
|
|
100
|
-
const errors: string[] = [];
|
|
101
|
-
return {
|
|
102
|
-
reports,
|
|
103
|
-
errors,
|
|
104
|
-
report: (r) => reports.push(r),
|
|
105
|
-
reportError: (m) => errors.push(m)
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Lets the throttle's fire-and-forget executeCompute settle.
|
|
110
|
-
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
111
|
-
|
|
112
|
-
it('serves a repeated input from the memo without calling onSolve again', async () => {
|
|
113
|
-
const onSolve = vi.fn(
|
|
114
|
-
async (values: Record<string, unknown>): Promise<SolveResult> => ({
|
|
115
|
-
outputs: { echo: values.a }
|
|
116
|
-
})
|
|
117
|
-
);
|
|
118
|
-
const reporter = collectingReporter();
|
|
119
|
-
const driver = createRequestResponseDriver(onSolve, () => reporter);
|
|
120
|
-
|
|
121
|
-
driver.solve({ a: 1 });
|
|
122
|
-
await flush();
|
|
123
|
-
driver.solve({ a: 2 });
|
|
124
|
-
await flush();
|
|
125
|
-
driver.solve({ a: 1 }); // repeat — should hit the memo
|
|
126
|
-
await flush();
|
|
127
|
-
|
|
128
|
-
expect(onSolve).toHaveBeenCalledTimes(2); // only the two distinct inputs
|
|
129
|
-
expect(reporter.reports).toHaveLength(3); // but all three solves reported
|
|
130
|
-
expect(reporter.reports[2]).toEqual({ outputs: { echo: 1 } });
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('clearCache drops the memo so the next identical solve re-runs', async () => {
|
|
134
|
-
const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
|
|
135
|
-
const reporter = collectingReporter();
|
|
136
|
-
const driver = createRequestResponseDriver(onSolve, () => reporter);
|
|
137
|
-
|
|
138
|
-
driver.solve({ a: 1 });
|
|
139
|
-
await flush();
|
|
140
|
-
driver.clearCache?.();
|
|
141
|
-
driver.solve({ a: 1 }); // memo cleared → real solve again
|
|
142
|
-
await flush();
|
|
143
|
-
|
|
144
|
-
expect(onSolve).toHaveBeenCalledTimes(2);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('session.rebuild clears the driver memo (no cross-definition stale hit)', async () => {
|
|
148
|
-
const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
|
|
149
|
-
const reporter = collectingReporter();
|
|
150
|
-
let clears = 0;
|
|
151
|
-
// Wrap the real driver to observe clearCache being invoked from rebuild.
|
|
152
|
-
const base = createRequestResponseDriver(onSolve, () => reporter);
|
|
153
|
-
const driver: SolveDriver = {
|
|
154
|
-
solve: base.solve,
|
|
155
|
-
cancel: base.cancel,
|
|
156
|
-
get isSolving() {
|
|
157
|
-
return base.isSolving;
|
|
158
|
-
},
|
|
159
|
-
clearCache() {
|
|
160
|
-
clears += 1;
|
|
161
|
-
base.clearCache?.();
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
165
|
-
session.rebuild(schema(true), 's2');
|
|
166
|
-
expect(clears).toBe(1);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
@@ -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
|
-
}
|