@selvajs/ui 4.2.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.
Files changed (85) hide show
  1. package/dist/components/compute/AppLayout.svelte +1 -1
  2. package/dist/components/compute/AppLayout.svelte.d.ts +1 -1
  3. package/dist/components/compute/ComputeApp.svelte +54 -133
  4. package/dist/components/compute/ComputeApp.svelte.d.ts +2 -9
  5. package/dist/components/compute/ComputeMessagesDialog.svelte +125 -0
  6. package/dist/components/compute/ComputeMessagesDialog.svelte.d.ts +10 -0
  7. package/dist/components/compute/ParameterPresetManager.svelte +25 -33
  8. package/dist/components/layout/PageFooter.svelte +22 -144
  9. package/dist/components/preview/Group.svelte +6 -3
  10. package/dist/components/preview/ImageOutput.svelte +1 -13
  11. package/dist/components/preview/InputControl.svelte +96 -72
  12. package/dist/components/preview/OutputDisplay.svelte +6 -6
  13. package/dist/components/preview/TabLayout.svelte +6 -5
  14. package/dist/components/preview/inputs/FileInput.svelte +10 -19
  15. package/dist/composables/useFooterItem.svelte.d.ts +10 -12
  16. package/dist/composables/useFooterItem.svelte.js +9 -16
  17. package/dist/compute/computeThrottle.svelte.js +5 -2
  18. package/dist/compute/createSolveSession.svelte.d.ts +56 -0
  19. package/dist/compute/createSolveSession.svelte.js +122 -0
  20. package/dist/compute/solve-session-core.d.ts +45 -0
  21. package/dist/compute/solve-session-core.js +69 -0
  22. package/dist/constants.d.ts +0 -2
  23. package/dist/constants.js +0 -2
  24. package/dist/contexts/clientSlotContext.svelte.d.ts +13 -0
  25. package/dist/contexts/clientSlotContext.svelte.js +8 -0
  26. package/dist/contexts/footerContext.svelte.d.ts +17 -5
  27. package/dist/contexts/footerContext.svelte.js +10 -2
  28. package/dist/external/storage.js +7 -23
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +2 -0
  31. package/dist/schema/param-exporter.d.ts +25 -1
  32. package/dist/schema/param-exporter.js +30 -27
  33. package/dist/schema/traversal.d.ts +1 -0
  34. package/dist/schema/traversal.js +5 -0
  35. package/dist/schema/visibility-rules.d.ts +12 -0
  36. package/dist/schema/visibility-rules.js +20 -0
  37. package/dist/types/solveFn.d.ts +1 -2
  38. package/dist/utils/file-download.d.ts +5 -17
  39. package/dist/utils/file-download.js +12 -19
  40. package/dist/utils/randomId.js +3 -19
  41. package/package.json +10 -26
  42. package/src/lib/components/compute/AppLayout.svelte +1 -1
  43. package/src/lib/components/compute/ComputeApp.svelte +54 -133
  44. package/src/lib/components/compute/ComputeMessagesDialog.svelte +125 -0
  45. package/src/lib/components/compute/ParameterPresetManager.svelte +25 -33
  46. package/src/lib/components/layout/PageFooter.svelte +22 -144
  47. package/src/lib/components/preview/Group.svelte +6 -3
  48. package/src/lib/components/preview/ImageOutput.svelte +1 -13
  49. package/src/lib/components/preview/InputControl.svelte +96 -72
  50. package/src/lib/components/preview/OutputDisplay.svelte +6 -6
  51. package/src/lib/components/preview/TabLayout.svelte +6 -5
  52. package/src/lib/components/preview/inputs/FileInput.svelte +10 -19
  53. package/src/lib/composables/useFooterItem.svelte.ts +25 -25
  54. package/src/lib/compute/computeThrottle.svelte.ts +5 -2
  55. package/src/lib/compute/computeThrottle.test.ts +106 -0
  56. package/src/lib/compute/createSolveSession.svelte.ts +188 -0
  57. package/src/lib/compute/solve-session-core.test.ts +153 -0
  58. package/src/lib/compute/solve-session-core.ts +102 -0
  59. package/src/lib/constants.ts +0 -2
  60. package/src/lib/contexts/clientSlotContext.svelte.ts +33 -0
  61. package/src/lib/contexts/footerContext.svelte.ts +29 -14
  62. package/src/lib/external/storage.test.ts +99 -0
  63. package/src/lib/external/storage.ts +6 -22
  64. package/src/lib/index.ts +2 -0
  65. package/src/lib/schema/param-exporter.test.ts +136 -0
  66. package/src/lib/schema/param-exporter.ts +44 -30
  67. package/src/lib/schema/traversal.test.ts +92 -0
  68. package/src/lib/schema/traversal.ts +5 -0
  69. package/src/lib/schema/visibility-rules.test.ts +173 -0
  70. package/src/lib/schema/visibility-rules.ts +25 -0
  71. package/src/lib/types/solveFn.ts +1 -1
  72. package/src/lib/utils/file-download.ts +13 -20
  73. package/src/lib/utils/randomId.ts +3 -19
  74. package/dist/components/compute/ComputeMessages.svelte +0 -169
  75. package/dist/components/compute/ComputeMessages.svelte.d.ts +0 -7
  76. package/dist/components/compute/index.d.ts +0 -5
  77. package/dist/components/compute/index.js +0 -5
  78. package/dist/components/preview/index.d.ts +0 -3
  79. package/dist/components/preview/index.js +0 -4
  80. package/dist/components/viewer/index.d.ts +0 -3
  81. package/dist/components/viewer/index.js +0 -3
  82. package/src/lib/components/compute/ComputeMessages.svelte +0 -169
  83. package/src/lib/components/compute/index.ts +0 -5
  84. package/src/lib/components/preview/index.ts +0 -4
  85. package/src/lib/components/viewer/index.ts +0 -3
@@ -41,7 +41,7 @@
41
41
  let urlInput = $state('');
42
42
  let isDragging = $state(false);
43
43
  let isLoading = $state(false);
44
- let urlError = $state<{ message: string; isCors: boolean } | null>(null);
44
+ let urlError = $state<{ message: string; isCors: boolean; hint?: string } | null>(null);
45
45
  let urlSuccess = $state('');
46
46
 
47
47
  // Parse existing value if it's JSON
@@ -88,26 +88,22 @@
88
88
  async function handleUrlChange() {
89
89
  if (!urlInput.trim()) return;
90
90
 
91
- // Reset all state before starting a new fetch
92
91
  isLoading = true;
93
92
  urlError = null;
94
93
  urlSuccess = '';
95
94
  uploadedFileName = '';
96
95
 
97
96
  try {
98
- // Validate URL format first
99
97
  let parsedUrl: URL;
100
98
  try {
101
99
  parsedUrl = new URL(urlInput);
102
100
  } catch {
103
101
  urlError = { message: 'Invalid URL — make sure it starts with https://', isCors: false };
104
- isLoading = false;
105
102
  return;
106
103
  }
107
104
 
108
105
  if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
109
106
  urlError = { message: 'Only http:// and https:// URLs are supported.', isCors: false };
110
- isLoading = false;
111
107
  return;
112
108
  }
113
109
 
@@ -117,7 +113,6 @@
117
113
  message: `Server returned ${response.status} ${response.statusText}. Check the URL is correct and publicly accessible.`,
118
114
  isCors: false
119
115
  };
120
- isLoading = false;
121
116
  return;
122
117
  }
123
118
 
@@ -129,7 +124,6 @@
129
124
  message: `File format "${fileEnding}" is not accepted. Allowed: ${acceptedFormats.join(', ')}`,
130
125
  isCors: false
131
126
  };
132
- isLoading = false;
133
127
  return;
134
128
  }
135
129
 
@@ -139,24 +133,22 @@
139
133
  message: `File too large: ${(blob.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB). Download it and use Upload instead.`,
140
134
  isCors: false
141
135
  };
142
- isLoading = false;
143
136
  return;
144
137
  }
145
138
 
146
- await new Promise<void>((resolve, reject) => {
139
+ const base64Data = await new Promise<string>((resolve, reject) => {
147
140
  const reader = new FileReader();
148
141
  reader.onload = (e) => {
149
- const base64 = e.target?.result as string;
150
- const base64Data = base64.includes(',') ? base64.split(',')[1] : base64;
151
- const data = JSON.stringify({ file: base64Data, type: 'base64', fileEnding });
152
- value = data;
153
- urlSuccess = `Loaded ${fileEnding} (${(blob.size / 1024).toFixed(0)} KB)`;
154
- onChange(data);
155
- resolve();
142
+ const result = e.target?.result as string;
143
+ resolve(result.includes(',') ? result.split(',')[1] : result);
156
144
  };
157
145
  reader.onerror = () => reject(new Error('Failed to read the downloaded file.'));
158
146
  reader.readAsDataURL(blob);
159
147
  });
148
+ const data = JSON.stringify({ file: base64Data, type: 'base64', fileEnding });
149
+ value = data;
150
+ urlSuccess = `Loaded ${fileEnding} (${(blob.size / 1024).toFixed(0)} KB)`;
151
+ onChange(data);
160
152
  } catch (error) {
161
153
  if (isCorsError(error)) {
162
154
  const host = (() => {
@@ -184,7 +176,6 @@
184
176
  urlError = {
185
177
  message: `Could not reach "${host}" — the file may not exist, or the server does not allow browser access.`,
186
178
  isCors: true,
187
- // @ts-expect-error hint is properly typed at runtime
188
179
  hint
189
180
  };
190
181
  } else {
@@ -328,8 +319,8 @@
328
319
  <CircleAlert size={13} class="mt-0.5 shrink-0 text-destructive" />
329
320
  <p class="text-xs leading-snug text-destructive">{urlError.message}</p>
330
321
  </div>
331
- {#if urlError.isCors && (urlError as any).hint}
332
- <p class="text-xs leading-snug pl-5 text-muted-foreground">{(urlError as any).hint}</p>
322
+ {#if urlError.isCors && urlError.hint}
323
+ <p class="text-xs leading-snug pl-5 text-muted-foreground">{urlError.hint}</p>
333
324
  {/if}
334
325
  </div>
335
326
  {/if}
@@ -1,40 +1,40 @@
1
- import { getContext, onMount, onDestroy } from 'svelte';
2
- import { type FooterStore, FOOTER_CONTEXT_KEY } from '$lib/contexts/footerContext.svelte';
1
+ import { getContext, onMount, onDestroy, type Component } from 'svelte';
2
+ import {
3
+ type FooterStore,
4
+ type FooterItem,
5
+ FOOTER_CONTEXT_KEY
6
+ } from '$lib/contexts/footerContext.svelte';
7
+
8
+ /** Config for a footer-item registration; generic over the component's props. */
9
+ export type FooterItemConfig<P extends Record<string, unknown> = Record<string, unknown>> = Omit<
10
+ FooterItem<P>,
11
+ 'position' | 'priority'
12
+ > &
13
+ Partial<Pick<FooterItem<P>, 'position' | 'priority'>>;
3
14
 
4
15
  /**
5
- * Register a footer item component that reactively updates its props.
6
- * The `getProps` function is called on every render, so returning reactive state
7
- * (e.g. `() => ({ status: myState.status })`) will keep the footer in sync.
8
- *
9
- * Registration is deferred to onMount (client-side only) to avoid SSR context errors.
16
+ * Register a footer item for the lifetime of the calling component.
10
17
  *
11
- * @param id - Unique identifier for this footer item
12
- * @param component - Svelte component to render
13
- * @param getProps - Function returning current props (called reactively)
14
- * @param position - Where to display ('left' or 'right'), defaults to 'left'
15
- * @param priority - Higher values render first within their position
16
- * @param onClick - Optional click handler (e.g. open a popup/dialog)
18
+ * Registration is deferred to onMount (client-side only) to avoid SSR context errors;
19
+ * it's unregistered on destroy. A no-op when no footer context is present (e.g. a
20
+ * component rendered outside the root layout) or no component is supplied.
17
21
  */
18
- export function useFooterItem(
19
- id: string,
20
- component: any,
21
- getProps: () => Record<string, any>,
22
- position: 'left' | 'right' = 'left',
23
- priority: number = 0,
24
- onClick?: () => void
25
- ) {
22
+ export function useFooterItem<P extends Record<string, unknown>>(config: FooterItemConfig<P>) {
26
23
  onMount(() => {
27
24
  const store = getContext<FooterStore | undefined>(FOOTER_CONTEXT_KEY);
28
- if (!store || !component) return;
29
- store.register(id, component, getProps, position, priority, onClick);
25
+ if (!store || !config.component) return;
26
+ store.register(config);
30
27
  });
31
28
 
32
29
  onDestroy(() => {
33
30
  try {
34
31
  const store = getContext<FooterStore | undefined>(FOOTER_CONTEXT_KEY);
35
- store?.unregister(id);
32
+ store?.unregister(config.id);
36
33
  } catch {
37
- // Context may not exist during SSR cleanup — safe to ignore
34
+ // Context may not exist during SSR cleanup — safe to ignore.
38
35
  }
39
36
  });
40
37
  }
38
+
39
+ // Re-export for callers that type their component prop explicitly.
40
+ export type { Component };
@@ -55,8 +55,11 @@ export function createComputeThrottle<T>(
55
55
  try {
56
56
  await computeFn(values, signal);
57
57
  } catch (err) {
58
- if (!(err instanceof Error && err.name === 'AbortError')) {
59
- throw err;
58
+ // AbortError is expected (timeout or cancel). Non-abort errors must be
59
+ // handled inside computeFn — re-throwing here would produce an unhandled
60
+ // rejection because executeCompute is always called fire-and-forget.
61
+ if (!(err instanceof Error) || (err.name !== 'AbortError' && err.name !== 'TimeoutError')) {
62
+ console.error('[computeThrottle] unhandled error in computeFn:', err);
60
63
  }
61
64
  } finally {
62
65
  clearTimeout(timeoutId);
@@ -0,0 +1,106 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createComputeThrottle } from './computeThrottle.svelte';
3
+
4
+ // Pins the throttle state machine: single-in-flight, latest-pending-wins, abort on
5
+ // re-trigger, timeout -> abort, and cancel clearing the pending slot. These are the
6
+ // non-obvious paths (the finally-block re-entry, the abort cleanup) where bugs hide.
7
+ // `computeThrottle.svelte.ts` is a rune module, so these run under the svelte vitest
8
+ // plugin (see vitest.config.ts).
9
+
10
+ // A controllable computeFn: each call exposes a resolver so the test drives when a
11
+ // solve "finishes", plus records the values and the signal it was given.
12
+ function deferredComputeFn() {
13
+ const calls: { values: unknown; signal: AbortSignal; resolve: () => void }[] = [];
14
+ const fn = (values: unknown, signal: AbortSignal) =>
15
+ new Promise<void>((resolve) => {
16
+ calls.push({ values, signal, resolve });
17
+ });
18
+ return { fn, calls };
19
+ }
20
+
21
+ const tick = () => new Promise((r) => setTimeout(r, 0));
22
+
23
+ describe('createComputeThrottle', () => {
24
+ it('runs immediately when idle and reports isComputing across the call', async () => {
25
+ const { fn, calls } = deferredComputeFn();
26
+ const t = createComputeThrottle<number>(fn);
27
+
28
+ expect(t.isComputing).toBe(false);
29
+ t.trigger(1);
30
+ expect(t.isComputing).toBe(true);
31
+ expect(calls).toHaveLength(1);
32
+ expect(calls[0].values).toBe(1);
33
+
34
+ calls[0].resolve();
35
+ await tick();
36
+ expect(t.isComputing).toBe(false);
37
+ expect(t.hasPending).toBe(false);
38
+ });
39
+
40
+ it('keeps only one in flight; latest pending value wins, intermediates dropped', async () => {
41
+ const { fn, calls } = deferredComputeFn();
42
+ const t = createComputeThrottle<number>(fn);
43
+
44
+ t.trigger(1); // runs
45
+ t.trigger(2); // queued
46
+ t.trigger(3); // overwrites the single pending slot — 2 is dropped
47
+ expect(calls).toHaveLength(1);
48
+ expect(t.hasPending).toBe(true);
49
+
50
+ calls[0].resolve(); // first finishes -> pending (3) runs
51
+ await tick();
52
+ expect(calls).toHaveLength(2);
53
+ expect(calls[1].values).toBe(3); // not 2
54
+ expect(t.hasPending).toBe(false);
55
+ });
56
+
57
+ it('aborts the in-flight request when a new one starts (after the pending re-entry)', async () => {
58
+ const { fn, calls } = deferredComputeFn();
59
+ const t = createComputeThrottle<number>(fn);
60
+
61
+ t.trigger(1);
62
+ t.trigger(2); // pending
63
+ const firstSignal = calls[0].signal;
64
+
65
+ calls[0].resolve(); // finishes -> re-enters executeCompute(2), which aborts the (already-cleared) prior
66
+ await tick();
67
+ // The second run gets a fresh, non-aborted signal.
68
+ expect(calls).toHaveLength(2);
69
+ expect(calls[1].signal.aborted).toBe(false);
70
+ expect(firstSignal).not.toBe(calls[1].signal);
71
+ });
72
+
73
+ it('cancel() clears the pending slot and aborts the in-flight signal', async () => {
74
+ const { fn, calls } = deferredComputeFn();
75
+ const t = createComputeThrottle<number>(fn);
76
+
77
+ t.trigger(1);
78
+ t.trigger(2); // pending
79
+ expect(t.hasPending).toBe(true);
80
+
81
+ t.cancel();
82
+ expect(t.hasPending).toBe(false);
83
+ expect(calls[0].signal.aborted).toBe(true);
84
+
85
+ // Resolving the aborted call must NOT start the (now-cleared) pending one.
86
+ calls[0].resolve();
87
+ await tick();
88
+ expect(calls).toHaveLength(1);
89
+ });
90
+
91
+ describe('timeout', () => {
92
+ beforeEach(() => vi.useFakeTimers());
93
+ afterEach(() => vi.useRealTimers());
94
+
95
+ it('aborts the in-flight request when the timeout elapses', async () => {
96
+ const { fn, calls } = deferredComputeFn();
97
+ const t = createComputeThrottle<number>(fn, { timeout: 1000 });
98
+
99
+ t.trigger(1);
100
+ expect(calls[0].signal.aborted).toBe(false);
101
+
102
+ vi.advanceTimersByTime(1000);
103
+ expect(calls[0].signal.aborted).toBe(true);
104
+ });
105
+ });
106
+ });
@@ -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
+ });