@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.
Files changed (44) hide show
  1. package/dist/components/compute/ComputeApp.svelte +15 -7
  2. package/dist/components/compute/ComputeApp.svelte.d.ts +1 -1
  3. package/dist/components/primitives/textarea/textarea.svelte +1 -2
  4. package/dist/components/viewer/SceneManager.svelte +75 -160
  5. package/dist/components/viewer/SceneManager.svelte.d.ts +8 -3
  6. package/dist/components/viewer/Viewer.svelte +20 -3
  7. package/dist/compute/useSolveSession.svelte.d.ts +12 -0
  8. package/dist/compute/useSolveSession.svelte.js +76 -0
  9. package/dist/external/storage.d.ts +1 -15
  10. package/dist/external/storage.js +4 -54
  11. package/dist/index.d.ts +4 -3
  12. package/dist/index.js +7 -4
  13. package/dist/public.d.ts +4 -3
  14. package/dist/public.js +9 -5
  15. package/package.json +5 -3
  16. package/src/lib/components/compute/ComputeApp.svelte +15 -7
  17. package/src/lib/components/primitives/textarea/textarea.svelte +1 -2
  18. package/src/lib/components/viewer/SceneManager.svelte +75 -160
  19. package/src/lib/components/viewer/Viewer.svelte +20 -3
  20. package/src/lib/compute/mesh-policy-wiring.test.ts +72 -0
  21. package/src/lib/compute/useSolveSession.svelte.ts +83 -0
  22. package/src/lib/external/storage.ts +12 -64
  23. package/src/lib/index.ts +15 -5
  24. package/src/lib/public.ts +17 -6
  25. package/dist/compute/computeThrottle.svelte.d.ts +0 -24
  26. package/dist/compute/computeThrottle.svelte.js +0 -82
  27. package/dist/compute/createSolveSession.svelte.d.ts +0 -66
  28. package/dist/compute/createSolveSession.svelte.js +0 -159
  29. package/dist/compute/solve-session-core.d.ts +0 -54
  30. package/dist/compute/solve-session-core.js +0 -87
  31. package/dist/compute/solveMemo.d.ts +0 -22
  32. package/dist/compute/solveMemo.js +0 -124
  33. package/dist/types/solveFn.d.ts +0 -25
  34. package/dist/types/solveFn.js +0 -1
  35. package/src/lib/compute/computeThrottle.svelte.ts +0 -109
  36. package/src/lib/compute/computeThrottle.test.ts +0 -106
  37. package/src/lib/compute/createSolveSession.svelte.ts +0 -239
  38. package/src/lib/compute/createSolveSession.test.ts +0 -168
  39. package/src/lib/compute/solve-session-core.test.ts +0 -153
  40. package/src/lib/compute/solve-session-core.ts +0 -122
  41. package/src/lib/compute/solveMemo.test.ts +0 -220
  42. package/src/lib/compute/solveMemo.ts +0 -140
  43. package/src/lib/external/storage.test.ts +0 -99
  44. 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
- }