@selvajs/ui 4.3.0 → 4.4.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/AppLayout.svelte +1 -1
- package/dist/components/compute/AppLayout.svelte.d.ts +1 -1
- package/dist/components/compute/ComputeApp.svelte +47 -140
- package/dist/components/compute/ComputeApp.svelte.d.ts +0 -16
- package/dist/components/compute/ComputeMessagesDialog.svelte +125 -0
- package/dist/components/compute/ComputeMessagesDialog.svelte.d.ts +10 -0
- package/dist/components/compute/ParameterPresetManager.svelte +25 -33
- package/dist/components/layout/PageFooter.svelte +22 -144
- package/dist/components/preview/Group.svelte +6 -3
- package/dist/components/preview/ImageOutput.svelte +1 -13
- package/dist/components/preview/OutputDisplay.svelte +6 -6
- package/dist/components/preview/TabLayout.svelte +6 -5
- package/dist/components/preview/inputs/FileInput.svelte +10 -19
- package/dist/composables/useFooterItem.svelte.d.ts +10 -12
- package/dist/composables/useFooterItem.svelte.js +9 -16
- package/dist/compute/computeThrottle.svelte.js +5 -2
- package/dist/compute/createSolveSession.svelte.d.ts +56 -0
- package/dist/compute/createSolveSession.svelte.js +122 -0
- package/dist/compute/solve-session-core.d.ts +45 -0
- package/dist/compute/solve-session-core.js +69 -0
- package/dist/constants.d.ts +0 -2
- package/dist/constants.js +0 -2
- package/dist/contexts/footerContext.svelte.d.ts +17 -5
- package/dist/contexts/footerContext.svelte.js +10 -2
- package/dist/external/storage.js +7 -23
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/schema/param-exporter.d.ts +25 -1
- package/dist/schema/param-exporter.js +30 -27
- package/dist/schema/traversal.d.ts +1 -0
- package/dist/schema/traversal.js +5 -0
- package/dist/schema/visibility-rules.d.ts +12 -0
- package/dist/schema/visibility-rules.js +20 -0
- package/dist/types/solveFn.d.ts +1 -2
- package/dist/utils/file-download.d.ts +5 -17
- package/dist/utils/file-download.js +12 -19
- package/dist/utils/randomId.js +3 -19
- package/package.json +11 -27
- package/src/lib/components/compute/AppLayout.svelte +1 -1
- package/src/lib/components/compute/ComputeApp.svelte +47 -140
- package/src/lib/components/compute/ComputeMessagesDialog.svelte +125 -0
- package/src/lib/components/compute/ParameterPresetManager.svelte +25 -33
- package/src/lib/components/layout/PageFooter.svelte +22 -144
- package/src/lib/components/preview/Group.svelte +6 -3
- package/src/lib/components/preview/ImageOutput.svelte +1 -13
- package/src/lib/components/preview/OutputDisplay.svelte +6 -6
- package/src/lib/components/preview/TabLayout.svelte +6 -5
- package/src/lib/components/preview/inputs/FileInput.svelte +10 -19
- package/src/lib/composables/useFooterItem.svelte.ts +25 -25
- package/src/lib/compute/computeThrottle.svelte.ts +5 -2
- package/src/lib/compute/computeThrottle.test.ts +106 -0
- package/src/lib/compute/createSolveSession.svelte.ts +188 -0
- package/src/lib/compute/solve-session-core.test.ts +153 -0
- package/src/lib/compute/solve-session-core.ts +102 -0
- package/src/lib/constants.ts +0 -2
- package/src/lib/contexts/footerContext.svelte.ts +29 -14
- package/src/lib/external/storage.test.ts +99 -0
- package/src/lib/external/storage.ts +6 -22
- package/src/lib/index.ts +1 -0
- package/src/lib/schema/param-exporter.test.ts +136 -0
- package/src/lib/schema/param-exporter.ts +44 -30
- package/src/lib/schema/traversal.test.ts +92 -0
- package/src/lib/schema/traversal.ts +5 -0
- package/src/lib/schema/visibility-rules.test.ts +173 -0
- package/src/lib/schema/visibility-rules.ts +25 -0
- package/src/lib/types/solveFn.ts +1 -1
- package/src/lib/utils/file-download.ts +13 -20
- package/src/lib/utils/randomId.ts +3 -19
- package/dist/components/compute/ComputeMessages.svelte +0 -169
- package/dist/components/compute/ComputeMessages.svelte.d.ts +0 -7
- package/dist/components/compute/index.d.ts +0 -5
- package/dist/components/compute/index.js +0 -5
- package/dist/components/preview/index.d.ts +0 -3
- package/dist/components/preview/index.js +0 -4
- package/dist/components/viewer/index.d.ts +0 -3
- package/dist/components/viewer/index.js +0 -3
- package/src/lib/components/compute/ComputeMessages.svelte +0 -169
- package/src/lib/components/compute/index.ts +0 -5
- package/src/lib/components/preview/index.ts +0 -4
- package/src/lib/components/viewer/index.ts +0 -3
|
@@ -0,0 +1,188 @@
|
|
|
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 {
|
|
11
|
+
buildInitialValues,
|
|
12
|
+
makeInitialFlags,
|
|
13
|
+
applyValueChange,
|
|
14
|
+
applySolveResult,
|
|
15
|
+
type SolveSessionState
|
|
16
|
+
} from './solve-session-core';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The transport behind a Solve Session. Knows how to start and cancel a solve and
|
|
20
|
+
* reports its in-flight state. It does NOT return outputs — those come back via the
|
|
21
|
+
* session's report() so push transports (WebSocket) fit without contortion.
|
|
22
|
+
*/
|
|
23
|
+
export interface SolveDriver {
|
|
24
|
+
solve(values: Record<string, unknown>): void;
|
|
25
|
+
cancel(): void;
|
|
26
|
+
readonly isSolving: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SolveSession {
|
|
30
|
+
readonly values: Record<string, unknown>;
|
|
31
|
+
readonly error: string;
|
|
32
|
+
readonly computeErrors: string[];
|
|
33
|
+
readonly computeWarnings: string[];
|
|
34
|
+
readonly meshes: unknown[];
|
|
35
|
+
readonly hasPendingChanges: boolean;
|
|
36
|
+
readonly hasNeverSolved: boolean;
|
|
37
|
+
readonly isSolving: boolean;
|
|
38
|
+
/** Records a value change and dispatches a solve unless the schema is manual-solve. */
|
|
39
|
+
setValue(id: string, value: unknown): void;
|
|
40
|
+
/** Explicit "calculate" — dispatches a solve with the current values. */
|
|
41
|
+
solve(): void;
|
|
42
|
+
/** Merges incoming values, then solves (auto) or marks dirty (manual). */
|
|
43
|
+
loadValues(incoming: Record<string, unknown>): void;
|
|
44
|
+
/** Re-seed for a new active definition: rebuild values, clear outputs, gate the solve. */
|
|
45
|
+
rebuild(schema: UISchema, scopeKey: string): void;
|
|
46
|
+
/** Feed a completed solve result back into the session (called by the driver/host). */
|
|
47
|
+
report(result: SolveResult): void;
|
|
48
|
+
/** Report a solve failure (transport/solver error) — surfaces in `error`. */
|
|
49
|
+
reportError(message: string): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SolveSessionArgs {
|
|
53
|
+
schema: UISchema;
|
|
54
|
+
scopeKey: string;
|
|
55
|
+
driver: SolveDriver;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createSolveSession(args: SolveSessionArgs): SolveSession {
|
|
59
|
+
let currentSchema = args.schema;
|
|
60
|
+
|
|
61
|
+
const flags = makeInitialFlags(currentSchema?.instanceSolve);
|
|
62
|
+
const state = $state<SolveSessionState>({
|
|
63
|
+
values: buildInitialValues(currentSchema, args.scopeKey, readExternalValue),
|
|
64
|
+
error: '',
|
|
65
|
+
computeErrors: [],
|
|
66
|
+
computeWarnings: [],
|
|
67
|
+
meshes: [],
|
|
68
|
+
pendingValues: {},
|
|
69
|
+
hasPendingChanges: flags.hasPendingChanges,
|
|
70
|
+
hasNeverSolved: flags.hasNeverSolved
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function dispatch() {
|
|
74
|
+
args.driver.solve($state.snapshot(state.values));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
get values() {
|
|
79
|
+
return state.values;
|
|
80
|
+
},
|
|
81
|
+
get error() {
|
|
82
|
+
return state.error;
|
|
83
|
+
},
|
|
84
|
+
get computeErrors() {
|
|
85
|
+
return state.computeErrors;
|
|
86
|
+
},
|
|
87
|
+
get computeWarnings() {
|
|
88
|
+
return state.computeWarnings;
|
|
89
|
+
},
|
|
90
|
+
get meshes() {
|
|
91
|
+
return state.meshes;
|
|
92
|
+
},
|
|
93
|
+
get hasPendingChanges() {
|
|
94
|
+
return state.hasPendingChanges;
|
|
95
|
+
},
|
|
96
|
+
get hasNeverSolved() {
|
|
97
|
+
return state.hasNeverSolved;
|
|
98
|
+
},
|
|
99
|
+
get isSolving() {
|
|
100
|
+
return args.driver.isSolving;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
setValue(id, value) {
|
|
104
|
+
const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
|
|
105
|
+
if (shouldSolve) dispatch();
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
solve() {
|
|
109
|
+
dispatch();
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
loadValues(incoming) {
|
|
113
|
+
Object.assign(state.values, incoming);
|
|
114
|
+
if (currentSchema?.instanceSolve !== false) {
|
|
115
|
+
dispatch();
|
|
116
|
+
} else {
|
|
117
|
+
state.hasPendingChanges = true;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
rebuild(schema, scopeKey) {
|
|
122
|
+
currentSchema = schema;
|
|
123
|
+
state.meshes = [];
|
|
124
|
+
state.error = '';
|
|
125
|
+
state.computeErrors = [];
|
|
126
|
+
state.computeWarnings = [];
|
|
127
|
+
state.pendingValues = {};
|
|
128
|
+
state.values = buildInitialValues(schema, scopeKey, readExternalValue);
|
|
129
|
+
const f = makeInitialFlags(schema?.instanceSolve);
|
|
130
|
+
state.hasPendingChanges = f.hasPendingChanges;
|
|
131
|
+
state.hasNeverSolved = f.hasNeverSolved;
|
|
132
|
+
if (schema && Object.keys(state.values).length > 0 && schema.instanceSolve !== false) {
|
|
133
|
+
dispatch();
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
report(result) {
|
|
138
|
+
applySolveResult(state, result);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
reportError(message) {
|
|
142
|
+
state.error = message;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The slice of a SolveSession a driver feeds completed/failed solves back into. */
|
|
148
|
+
export interface SolveReporter {
|
|
149
|
+
report(result: SolveResult): void;
|
|
150
|
+
reportError(message: string): void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Request/response Solve Driver: wraps createComputeThrottle around a SolveFn and feeds
|
|
155
|
+
* the resolved result back through the reporter. One solve in flight at a time; the
|
|
156
|
+
* latest triggered values win. Used by ComputeApp (Rhino.Compute over HTTP).
|
|
157
|
+
*
|
|
158
|
+
* Because the session and driver reference each other, the host passes the reporter
|
|
159
|
+
* lazily (`() => session`) so it can construct the session with the driver in hand.
|
|
160
|
+
*/
|
|
161
|
+
export function createRequestResponseDriver(
|
|
162
|
+
onSolve: SolveFn,
|
|
163
|
+
getReporter: () => SolveReporter,
|
|
164
|
+
options: { timeout?: number } = {}
|
|
165
|
+
): SolveDriver {
|
|
166
|
+
const throttle = createComputeThrottle<Record<string, unknown>>(async (values, signal) => {
|
|
167
|
+
try {
|
|
168
|
+
const result = await onSolve(values, signal);
|
|
169
|
+
if (signal.aborted) return;
|
|
170
|
+
getReporter().report(result);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (signal.aborted) return;
|
|
173
|
+
getReporter().reportError(err instanceof Error ? err.message : String(err));
|
|
174
|
+
}
|
|
175
|
+
}, options);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
solve(values) {
|
|
179
|
+
throttle.trigger(values);
|
|
180
|
+
},
|
|
181
|
+
cancel() {
|
|
182
|
+
throttle.cancel();
|
|
183
|
+
},
|
|
184
|
+
get isSolving() {
|
|
185
|
+
return throttle.isComputing;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
* Merges a reported solve result into the state and clears the post-solve lifecycle
|
|
90
|
+
* flags. Missing result arrays are treated as empty.
|
|
91
|
+
*/
|
|
92
|
+
export function applySolveResult(state: SolveSessionState, result: SolveResult): SolveSessionState {
|
|
93
|
+
state.error = '';
|
|
94
|
+
state.computeErrors = result.errors ?? [];
|
|
95
|
+
state.computeWarnings = result.warnings ?? [];
|
|
96
|
+
state.meshes = result.meshes ?? [];
|
|
97
|
+
Object.assign(state.values, result.outputs);
|
|
98
|
+
state.pendingValues = {};
|
|
99
|
+
state.hasPendingChanges = false;
|
|
100
|
+
state.hasNeverSolved = false;
|
|
101
|
+
return state;
|
|
102
|
+
}
|
package/src/lib/constants.ts
CHANGED
|
@@ -9,8 +9,6 @@ export const APP_DEFAULTS = {
|
|
|
9
9
|
TIMEOUTS: {
|
|
10
10
|
/** Duration to show solving state (ms) */
|
|
11
11
|
SOLVE_STATE_DURATION: 3500,
|
|
12
|
-
/** Parameter export callback delay (ms) */
|
|
13
|
-
PARAM_EXPORT_DELAY: 100,
|
|
14
12
|
/** Drawer open/close animation duration (ms) — matches the CSS transition. */
|
|
15
13
|
DRAWER_ANIMATION_MS: 350
|
|
16
14
|
},
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import { getContext, setContext } from 'svelte';
|
|
1
|
+
import { getContext, setContext, type Component } from 'svelte';
|
|
2
2
|
import { SvelteMap } from 'svelte/reactivity';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* A footer item registered by a descendant of the root layout. `component` + `position`
|
|
6
|
+
* are fixed at registration; the renderer re-invokes `getProps` reactively, so return
|
|
7
|
+
* reactive state from it (e.g. `() => ({ status: myState.status })`) to keep the footer
|
|
8
|
+
* in sync.
|
|
9
|
+
*
|
|
10
|
+
* Generic over the component's props `P` so registration is type-checked at the call
|
|
11
|
+
* site. The store holds a heterogeneous mix of items, so its map widens `P` to
|
|
12
|
+
* `Record<string, unknown>` at the boundary.
|
|
13
|
+
*/
|
|
14
|
+
export interface FooterItem<P extends Record<string, unknown> = Record<string, unknown>> {
|
|
5
15
|
id: string;
|
|
6
|
-
component:
|
|
7
|
-
/** Called on every render — return reactive state to keep footer in sync */
|
|
8
|
-
getProps: () =>
|
|
16
|
+
component: Component<P>;
|
|
17
|
+
/** Called on every render — return reactive state to keep footer in sync. */
|
|
18
|
+
getProps: () => P;
|
|
9
19
|
position: 'left' | 'right';
|
|
10
20
|
priority: number;
|
|
11
21
|
onClick?: () => void;
|
|
@@ -13,13 +23,10 @@ export interface FooterItem {
|
|
|
13
23
|
|
|
14
24
|
export interface FooterStore {
|
|
15
25
|
items: SvelteMap<string, FooterItem>;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
position?: 'left' | 'right',
|
|
21
|
-
priority?: number,
|
|
22
|
-
onClick?: () => void
|
|
26
|
+
/** Register (or replace) a footer item. `position`/`priority` default to 'left'/0. */
|
|
27
|
+
register<P extends Record<string, unknown>>(
|
|
28
|
+
item: Omit<FooterItem<P>, 'position' | 'priority'> &
|
|
29
|
+
Partial<Pick<FooterItem<P>, 'position' | 'priority'>>
|
|
23
30
|
): void;
|
|
24
31
|
unregister(id: string): void;
|
|
25
32
|
}
|
|
@@ -31,8 +38,16 @@ export function initializeFooterContext(): FooterStore {
|
|
|
31
38
|
|
|
32
39
|
const store: FooterStore = {
|
|
33
40
|
items,
|
|
34
|
-
register(
|
|
35
|
-
|
|
41
|
+
register(item) {
|
|
42
|
+
const { id, component, getProps, position = 'left', priority = 0, onClick } = item;
|
|
43
|
+
items.set(id, {
|
|
44
|
+
id,
|
|
45
|
+
component: component as Component<Record<string, unknown>>,
|
|
46
|
+
getProps: getProps as () => Record<string, unknown>,
|
|
47
|
+
position,
|
|
48
|
+
priority,
|
|
49
|
+
onClick
|
|
50
|
+
});
|
|
36
51
|
},
|
|
37
52
|
unregister(id) {
|
|
38
53
|
items.delete(id);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getExternalInputs,
|
|
4
|
+
writeExternalValue,
|
|
5
|
+
readExternalValue,
|
|
6
|
+
clearExternalValue
|
|
7
|
+
} from './storage';
|
|
8
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
9
|
+
|
|
10
|
+
// getExternalInputs is the gate the whole client-input feature hangs on: it decides
|
|
11
|
+
// which inputs a producer route must fill. These pin the source.kind === 'client'
|
|
12
|
+
// filter and the displayName fallback. The read/write helpers are also covered with a
|
|
13
|
+
// sessionStorage stub, including the no-storage guard (SSR / node).
|
|
14
|
+
|
|
15
|
+
const input = (paramId: string, opts: { displayName?: string; source?: { kind: string } } = {}) =>
|
|
16
|
+
({ type: 'input', paramId, ...opts }) as never;
|
|
17
|
+
|
|
18
|
+
function schema(items: unknown[]): UISchema {
|
|
19
|
+
return {
|
|
20
|
+
layout: { type: 'flat', groups: [{ id: 'g', label: 'g', items }] }
|
|
21
|
+
} as unknown as UISchema;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('getExternalInputs', () => {
|
|
25
|
+
it('keeps only inputs with source.kind === client', () => {
|
|
26
|
+
const s = schema([
|
|
27
|
+
input('a', { source: { kind: 'client' } }),
|
|
28
|
+
input('b', { source: { kind: 'user' } }),
|
|
29
|
+
input('c') // no source
|
|
30
|
+
]);
|
|
31
|
+
expect(getExternalInputs(s).map((e) => e.paramId)).toEqual(['a']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('falls back to paramId when displayName is absent', () => {
|
|
35
|
+
const s = schema([input('a', { source: { kind: 'client' } })]);
|
|
36
|
+
expect(getExternalInputs(s)[0].displayName).toBe('a');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('uses displayName when present', () => {
|
|
40
|
+
const s = schema([input('a', { displayName: 'Width', source: { kind: 'client' } })]);
|
|
41
|
+
expect(getExternalInputs(s)[0].displayName).toBe('Width');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('read/write/clear with a sessionStorage stub', () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.unstubAllGlobals();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function stubStorage() {
|
|
51
|
+
const store = new Map<string, string>();
|
|
52
|
+
vi.stubGlobal('sessionStorage', {
|
|
53
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
54
|
+
setItem: (k: string, v: string) => store.set(k, v),
|
|
55
|
+
removeItem: (k: string) => store.delete(k)
|
|
56
|
+
});
|
|
57
|
+
return store;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('round-trips a value scoped by (scopeKey, inputId)', () => {
|
|
61
|
+
const store = stubStorage();
|
|
62
|
+
writeExternalValue({ scopeKey: 's1', inputId: 'p1', value: { x: 1 } });
|
|
63
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: 'p1' })).toEqual({ x: 1 });
|
|
64
|
+
expect(store.has('external:s1:p1')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('scopes values so they do not bleed across scopeKey', () => {
|
|
68
|
+
stubStorage();
|
|
69
|
+
writeExternalValue({ scopeKey: 's1', inputId: 'p1', value: 'a' });
|
|
70
|
+
expect(readExternalValue({ scopeKey: 's2', inputId: 'p1' })).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('clear removes the value', () => {
|
|
74
|
+
stubStorage();
|
|
75
|
+
writeExternalValue({ scopeKey: 's1', inputId: 'p1', value: 'a' });
|
|
76
|
+
clearExternalValue({ scopeKey: 's1', inputId: 'p1' });
|
|
77
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: 'p1' })).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns undefined for malformed JSON', () => {
|
|
81
|
+
const store = stubStorage();
|
|
82
|
+
store.set('external:s1:p1', '{not json');
|
|
83
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: 'p1' })).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('no-storage guard (SSR / node)', () => {
|
|
88
|
+
it('read returns undefined and write/clear no-op when sessionStorage is absent', () => {
|
|
89
|
+
// node env: sessionStorage is undefined by default
|
|
90
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: 'p1' })).toBeUndefined();
|
|
91
|
+
expect(() => writeExternalValue({ scopeKey: 's1', inputId: 'p1', value: 1 })).not.toThrow();
|
|
92
|
+
expect(() => clearExternalValue({ scopeKey: 's1', inputId: 'p1' })).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('ignores empty scopeKey or inputId', () => {
|
|
96
|
+
expect(readExternalValue({ scopeKey: '', inputId: 'p1' })).toBeUndefined();
|
|
97
|
+
expect(readExternalValue({ scopeKey: 's1', inputId: '' })).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
//
|
|
9
9
|
// inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
|
|
10
10
|
|
|
11
|
-
import type { UISchema
|
|
11
|
+
import type { UISchema } from '@selvajs/schemas';
|
|
12
|
+
import { getInputItems } from '@selvajs/schemas';
|
|
12
13
|
|
|
13
14
|
const STORAGE_PREFIX = 'external';
|
|
14
15
|
|
|
@@ -53,28 +54,11 @@ export interface ExternalInput {
|
|
|
53
54
|
displayName: string;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function* walkLayoutItems(schema: UISchema): Generator<LayoutItem> {
|
|
57
|
-
const groups: GroupConfig[] =
|
|
58
|
-
schema.layout.type === 'tabbed'
|
|
59
|
-
? schema.layout.tabs.flatMap((t) => t.groups)
|
|
60
|
-
: schema.layout.groups;
|
|
61
|
-
for (const group of groups) {
|
|
62
|
-
for (const item of group.items) {
|
|
63
|
-
yield item;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
57
|
export function getExternalInputs(schema: UISchema): ExternalInput[] {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const source = (item as { source?: InputSource }).source;
|
|
73
|
-
if (source?.kind !== 'client') continue;
|
|
74
|
-
result.push({
|
|
58
|
+
return getInputItems(schema)
|
|
59
|
+
.filter((item) => item.source?.kind === 'client')
|
|
60
|
+
.map((item) => ({
|
|
75
61
|
paramId: item.paramId,
|
|
76
62
|
displayName: item.displayName ?? item.paramId
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
return result;
|
|
63
|
+
}));
|
|
80
64
|
}
|
package/src/lib/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
|
|
|
17
17
|
|
|
18
18
|
// Utilities
|
|
19
19
|
export * from './schema/defaults';
|
|
20
|
+
export * from './schema/traversal';
|
|
20
21
|
export * from './compute/solving.svelte';
|
|
21
22
|
|
|
22
23
|
// External-input transit storage (used by routes that wire pre-step producers)
|