@selvajs/ui 5.0.1 → 6.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +42 -9
  2. package/dist/components/compute/ComputeApp.svelte +36 -12
  3. package/dist/components/compute/ComputeApp.svelte.d.ts +14 -3
  4. package/dist/components/primitives/textarea/textarea.svelte +1 -2
  5. package/dist/components/viewer/SceneManager.svelte +75 -160
  6. package/dist/components/viewer/SceneManager.svelte.d.ts +8 -3
  7. package/dist/components/viewer/Viewer.svelte +22 -5
  8. package/dist/compute/useSolveSession.svelte.d.ts +12 -0
  9. package/dist/compute/useSolveSession.svelte.js +79 -0
  10. package/dist/external/storage.d.ts +1 -15
  11. package/dist/external/storage.js +4 -54
  12. package/dist/index.d.ts +4 -3
  13. package/dist/index.js +7 -4
  14. package/dist/public.d.ts +4 -3
  15. package/dist/public.js +9 -5
  16. package/package.json +9 -4
  17. package/src/lib/components/compute/ComputeApp.svelte +36 -12
  18. package/src/lib/components/primitives/textarea/textarea.svelte +1 -2
  19. package/src/lib/components/viewer/SceneManager.svelte +75 -160
  20. package/src/lib/components/viewer/Viewer.svelte +22 -5
  21. package/src/lib/compute/useSolveSession.svelte.ts +86 -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/schema/dynamic-value-list.test.ts +0 -150
  45. package/src/lib/schema/param-exporter.test.ts +0 -136
  46. package/src/lib/schema/traversal.test.ts +0 -92
  47. package/src/lib/schema/visibility-rules.test.ts +0 -173
  48. package/src/lib/types/solveFn.ts +0 -29
@@ -1,99 +0,0 @@
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
- });
@@ -1,150 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { fileURLToPath } from 'node:url';
3
- import { describe, expect, it } from 'vitest';
4
- import type { UISchema } from '@selvajs/schemas';
5
- import { buildDynamicValueListOptions } from './dynamic-value-list';
6
-
7
- // The collector keys `values` by the ContextBake GUID. A dynamicValueList output can live in
8
- // schema.outputs[] OR only in the layout (a routing sink). These pin that BOTH are honoured —
9
- // the layout-only case is the bug where the C# collector sent the payload but the UI threw it away.
10
-
11
- const BAKE = 'bake-guid';
12
- const TARGET = 'target-input-guid';
13
-
14
- function schemaWith(opts: { outputs?: UISchema['outputs']; layoutItems?: unknown[] }): UISchema {
15
- return {
16
- outputs: opts.outputs ?? [],
17
- layout: {
18
- type: 'tabbed',
19
- tabs: [
20
- { id: 't1', groups: [{ id: 'g1', label: 'g1', order: 0, items: opts.layoutItems ?? [] }] }
21
- ]
22
- }
23
- } as unknown as UISchema;
24
- }
25
-
26
- const layoutItem = (paramId: string, targetInputId: string) =>
27
- ({
28
- id: 'li1',
29
- type: 'output',
30
- widgetType: 'dynamicValueList',
31
- paramId,
32
- config: { targetInputId }
33
- }) as unknown;
34
-
35
- const payload = (targetInputId: string | null, options: Record<string, string>) => ({
36
- targetInputId,
37
- options
38
- });
39
-
40
- describe('buildDynamicValueListOptions', () => {
41
- it('routes options from a schema.outputs[] source', () => {
42
- const schema = schemaWith({
43
- outputs: [{ id: BAKE, type: 'dynamicValueList', targetInputId: TARGET }] as never
44
- });
45
-
46
- const result = buildDynamicValueListOptions(schema, {
47
- [BAKE]: payload(TARGET, { A: '1', B: '2' })
48
- });
49
-
50
- expect(result[TARGET]).toEqual({ A: '1', B: '2' });
51
- });
52
-
53
- it('routes options from a LAYOUT-only source (the dropped-data bug)', () => {
54
- const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
55
-
56
- const result = buildDynamicValueListOptions(schema, {
57
- [BAKE]: payload(TARGET, { Sphere: '0', Box: '1' })
58
- });
59
-
60
- expect(result[TARGET]).toEqual({ Sphere: '0', Box: '1' });
61
- });
62
-
63
- it("prefers the payload's targetInputId over the schema fallback", () => {
64
- const schema = schemaWith({ layoutItems: [layoutItem(BAKE, 'stale-target')] });
65
-
66
- const result = buildDynamicValueListOptions(schema, {
67
- [BAKE]: payload('live-target', { A: '1' })
68
- });
69
-
70
- expect(result['live-target']).toEqual({ A: '1' });
71
- expect(result['stale-target']).toBeUndefined();
72
- });
73
-
74
- it('falls back to the schema targetInputId when the payload omits it', () => {
75
- const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
76
-
77
- const result = buildDynamicValueListOptions(schema, {
78
- [BAKE]: payload(null, { A: '1' })
79
- });
80
-
81
- expect(result[TARGET]).toEqual({ A: '1' });
82
- });
83
-
84
- it('parses a JSON-string payload (Rhino.Compute path)', () => {
85
- const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
86
-
87
- const result = buildDynamicValueListOptions(schema, {
88
- [BAKE]: JSON.stringify(payload(TARGET, { A: '1' }))
89
- });
90
-
91
- expect(result[TARGET]).toEqual({ A: '1' });
92
- });
93
-
94
- it('returns the SAME parsed object for repeated large string payloads (memoization)', () => {
95
- const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
96
- // Above the 1024-char memoization threshold — the expensive compute-mode path.
97
- const bigOptions: Record<string, string> = {};
98
- for (let i = 0; i < 100; i++) bigOptions[`option-${i}-${'x'.repeat(20)}`] = String(i);
99
- const str = JSON.stringify(payload(TARGET, bigOptions));
100
-
101
- const first = buildDynamicValueListOptions(schema, { [BAKE]: str });
102
- // A later solve delivering an identical (even newly-allocated) string must yield
103
- // the same object reference — referential stability is what stops the dropdown
104
- // subtree from re-rendering on every unrelated values change.
105
- const second = buildDynamicValueListOptions(schema, { [BAKE]: String(str) });
106
-
107
- expect(first[TARGET]).toEqual(bigOptions);
108
- expect(second[TARGET]).toBe(first[TARGET]);
109
- });
110
-
111
- it('dedupes outputs[] over layout for the same id', () => {
112
- const schema = schemaWith({
113
- outputs: [{ id: BAKE, type: 'dynamicValueList', targetInputId: 'from-outputs' }] as never,
114
- layoutItems: [layoutItem(BAKE, 'from-layout')]
115
- });
116
-
117
- // Payload omits targetInputId -> the outputs[] fallback wins (it's set last in the dedupe map).
118
- const result = buildDynamicValueListOptions(schema, {
119
- [BAKE]: payload(null, { A: '1' })
120
- });
121
-
122
- expect(result['from-outputs']).toEqual({ A: '1' });
123
- expect(result['from-layout']).toBeUndefined();
124
- });
125
-
126
- it('ignores values with no matching source', () => {
127
- const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
128
-
129
- const result = buildDynamicValueListOptions(schema, {
130
- 'unrelated-id': payload(TARGET, { A: '1' })
131
- });
132
-
133
- expect(Object.keys(result)).toHaveLength(0);
134
- });
135
-
136
- // The SAME json file the C# DynamicValueListPayload test loads. If C# and TS stop agreeing on
137
- // this shape, one side's CI goes red — that's the cross-stack drift guard.
138
- it('routes the shared cross-stack golden fixture', () => {
139
- const fixturePath = fileURLToPath(
140
- new URL('../../../../schemas/fixtures/dynamic-value-list-payload.json', import.meta.url)
141
- );
142
- const fixture = JSON.parse(readFileSync(fixturePath, 'utf-8'));
143
- const schema = schemaWith({ layoutItems: [layoutItem(BAKE, fixture.targetInputId)] });
144
-
145
- const result = buildDynamicValueListOptions(schema, { [BAKE]: fixture });
146
-
147
- expect(result[fixture.targetInputId]).toEqual(fixture.options);
148
- expect(result[fixture.targetInputId]).toEqual({ Sphere: '0', Box: '1', Cone: '2' });
149
- });
150
- });
@@ -1,136 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { validateSavedState, extractLoadableValues, loadPreset } from './param-exporter';
3
- import type { UISchema, ParameterPreset } from '@selvajs/schemas';
4
-
5
- // These tests cover the load path a user hits when restoring a preset against a
6
- // schema that has drifted. The severity of each mismatch decides whether the
7
- // preset can load at all (error blocks, warning allows), so those are the cases
8
- // worth locking in — not the happy path.
9
-
10
- function schema(over: Partial<UISchema> = {}): UISchema {
11
- return {
12
- id: 'schema-1',
13
- documentId: 'doc-1',
14
- inputs: [
15
- { id: 'a', nickname: 'A', paramType: 'number' },
16
- { id: 'b', nickname: 'B', paramType: 'number' }
17
- ],
18
- ...over
19
- } as unknown as UISchema;
20
- }
21
-
22
- function preset(over: Partial<ParameterPreset> = {}): ParameterPreset {
23
- return {
24
- id: 'p1',
25
- name: 'My Preset',
26
- schemaId: 'schema-1',
27
- documentId: 'doc-1',
28
- parameters: [
29
- { paramId: 'a', nickname: 'A', value: 1 },
30
- { paramId: 'b', nickname: 'B', value: 2 }
31
- ],
32
- ...over
33
- } as unknown as ParameterPreset;
34
- }
35
-
36
- describe('validateSavedState', () => {
37
- it('document-ID mismatch is a blocking error', () => {
38
- const result = validateSavedState(preset({ documentId: 'other-doc' }), schema());
39
- expect(result.canLoad).toBe(false);
40
- expect(result.issues.some((i) => i.severity === 'error' && i.paramId === '__document__')).toBe(
41
- true
42
- );
43
- });
44
-
45
- it('schema-ID change is a warning that still allows loading', () => {
46
- const result = validateSavedState(preset({ schemaId: 'old-schema' }), schema());
47
- expect(result.canLoad).toBe(true);
48
- expect(result.isValid).toBe(false);
49
- expect(result.issues.some((i) => i.severity === 'warning' && i.paramId === '__schema__')).toBe(
50
- true
51
- );
52
- });
53
-
54
- it('a parameter missing from the schema is a blocking error', () => {
55
- const p = preset({
56
- parameters: [
57
- { paramId: 'a', nickname: 'A', value: 1 },
58
- { paramId: 'gone', nickname: 'Gone', value: 9 }
59
- ] as ParameterPreset['parameters']
60
- });
61
- const result = validateSavedState(p, schema());
62
- expect(result.canLoad).toBe(false);
63
- expect(result.issues.some((i) => i.severity === 'error' && i.paramId === 'gone')).toBe(true);
64
- });
65
-
66
- it('a renamed nickname is a warning, not a block', () => {
67
- const renamed = schema({
68
- inputs: [
69
- { id: 'a', nickname: 'A renamed', paramType: 'number' },
70
- { id: 'b', nickname: 'B', paramType: 'number' }
71
- ]
72
- } as unknown as Partial<UISchema>);
73
- const result = validateSavedState(preset(), renamed);
74
- expect(result.canLoad).toBe(true);
75
- expect(result.issues.some((i) => i.severity === 'warning' && i.paramId === 'a')).toBe(true);
76
- });
77
-
78
- it('a fully matching preset is valid with no issues', () => {
79
- const result = validateSavedState(preset(), schema());
80
- expect(result).toEqual({ isValid: true, issues: [], canLoad: true });
81
- });
82
- });
83
-
84
- describe('extractLoadableValues', () => {
85
- it('drops params that no longer exist in the schema, keeps the rest', () => {
86
- const p = preset({
87
- parameters: [
88
- { paramId: 'a', nickname: 'A', value: 1 },
89
- { paramId: 'gone', nickname: 'Gone', value: 9 } // not in schema
90
- ] as ParameterPreset['parameters']
91
- });
92
- expect(extractLoadableValues(p, schema())).toEqual({ a: 1 });
93
- });
94
-
95
- it('keeps a warned-but-valid param (nickname drift) on load', () => {
96
- const renamed = schema({
97
- inputs: [
98
- { id: 'a', nickname: 'A renamed', paramType: 'number' },
99
- { id: 'b', nickname: 'B', paramType: 'number' }
100
- ]
101
- } as unknown as Partial<UISchema>);
102
- expect(extractLoadableValues(preset(), renamed)).toEqual({ a: 1, b: 2 });
103
- });
104
- });
105
-
106
- describe('loadPreset', () => {
107
- it('fuses validation and extraction: valid preset loads cleanly', () => {
108
- const result = loadPreset(preset(), schema());
109
- expect(result.isValid).toBe(true);
110
- expect(result.canLoad).toBe(true);
111
- expect(result.issues).toEqual([]);
112
- expect(result.values).toEqual({ a: 1, b: 2 });
113
- });
114
-
115
- it('blocks on a document mismatch but still returns loadable values', () => {
116
- const result = loadPreset(preset({ documentId: 'other-doc' }), schema());
117
- expect(result.canLoad).toBe(false);
118
- expect(result.issues.some((i) => i.severity === 'error')).toBe(true);
119
- // values are still computed (the caller decides whether to apply them)
120
- expect(result.values).toEqual({ a: 1, b: 2 });
121
- });
122
-
123
- it('allows load with warnings (schema drift) and drops missing params', () => {
124
- const p = preset({
125
- parameters: [
126
- { paramId: 'a', nickname: 'A', value: 1 },
127
- { paramId: 'gone', nickname: 'Gone', value: 9 }
128
- ] as ParameterPreset['parameters'],
129
- schemaId: 'old-schema'
130
- });
131
- const result = loadPreset(p, schema());
132
- expect(result.isValid).toBe(false);
133
- expect(result.canLoad).toBe(false); // missing param is an error
134
- expect(result.values).toEqual({ a: 1 });
135
- });
136
- });
@@ -1,92 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { getGroups, getLayoutItems, getInputItems } from '@selvajs/schemas';
3
- import type { UISchema } from '@selvajs/schemas';
4
-
5
- // These pin the layout-union discrimination (tabbed vs flat) and the defensive contract
6
- // (missing layout / groups / items yield empty, never throw), since this module is the
7
- // single place every caller relies on to walk a schema.
8
-
9
- const input = (id: string, source?: { kind: string }) =>
10
- ({ type: 'input', paramId: id, displayName: id, ...(source ? { source } : {}) }) as never;
11
- const linebreak = (id: string) => ({ type: 'linebreak', id }) as never;
12
- const group = (id: string, items: unknown[]) => ({ id, label: id, items }) as never;
13
-
14
- function schema(layout: unknown): UISchema {
15
- return { layout } as unknown as UISchema;
16
- }
17
-
18
- describe('getGroups — layout discrimination', () => {
19
- it('flattens tabs into a single group list, tab order preserved', () => {
20
- const s = schema({
21
- type: 'tabbed',
22
- tabs: [
23
- { id: 't1', label: 't1', groups: [group('g1', []), group('g2', [])] },
24
- { id: 't2', label: 't2', groups: [group('g3', [])] }
25
- ]
26
- });
27
- expect(getGroups(s).map((g) => g.id)).toEqual(['g1', 'g2', 'g3']);
28
- });
29
-
30
- it('returns flat groups directly', () => {
31
- const s = schema({ type: 'flat', groups: [group('g1', []), group('g2', [])] });
32
- expect(getGroups(s).map((g) => g.id)).toEqual(['g1', 'g2']);
33
- });
34
- });
35
-
36
- describe('getGroups — defensive contract', () => {
37
- it('returns [] when layout is missing', () => {
38
- expect(getGroups({} as UISchema)).toEqual([]);
39
- });
40
-
41
- it('returns [] for an unknown layout type', () => {
42
- expect(getGroups(schema({ type: 'mystery' }))).toEqual([]);
43
- });
44
-
45
- it('tolerates missing tabs / groups arrays', () => {
46
- expect(getGroups(schema({ type: 'tabbed' }))).toEqual([]);
47
- expect(getGroups(schema({ type: 'flat' }))).toEqual([]);
48
- });
49
-
50
- it('tolerates a tab with no groups', () => {
51
- const s = schema({ type: 'tabbed', tabs: [{ id: 't1', label: 't1' }] });
52
- expect(getGroups(s)).toEqual([]);
53
- });
54
- });
55
-
56
- describe('getLayoutItems', () => {
57
- it('collects items across all groups', () => {
58
- const s = schema({
59
- type: 'flat',
60
- groups: [group('g1', [input('a'), linebreak('lb')]), group('g2', [input('b')])]
61
- });
62
- expect(getLayoutItems(s).map((i) => (i.type === 'linebreak' ? i.id : i.paramId))).toEqual([
63
- 'a',
64
- 'lb',
65
- 'b'
66
- ]);
67
- });
68
-
69
- it('tolerates a group with no items', () => {
70
- const s = schema({ type: 'flat', groups: [{ id: 'g1', label: 'g1' }] });
71
- expect(getLayoutItems(s)).toEqual([]);
72
- });
73
- });
74
-
75
- describe('getInputItems', () => {
76
- it('keeps only input items, dropping outputs and linebreaks', () => {
77
- const s = schema({
78
- type: 'flat',
79
- groups: [group('g1', [input('a'), linebreak('lb'), { type: 'output', paramId: 'out' }])]
80
- });
81
- expect(getInputItems(s).map((i) => i.paramId)).toEqual(['a']);
82
- });
83
-
84
- it('preserves the source field so client-input filters work', () => {
85
- const s = schema({
86
- type: 'flat',
87
- groups: [group('g1', [input('a', { kind: 'client' }), input('b')])]
88
- });
89
- const clients = getInputItems(s).filter((i) => i.source?.kind === 'client');
90
- expect(clients.map((i) => i.paramId)).toEqual(['a']);
91
- });
92
- });
@@ -1,173 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- evaluateRule,
4
- evaluateVisibility,
5
- evaluateGroupVisibility,
6
- buildVisibilityMap,
7
- itemKey
8
- } from './visibility-rules';
9
- import type { GroupVisibilityCondition, LayoutItem, VisibilityRule } from '@selvajs/schemas';
10
-
11
- // These tests pin the non-obvious branches: operator edge cases that are easy to
12
- // break in a refactor, and the action/short-circuit precedence in evaluateVisibility.
13
- // Trivial passthroughs (equals true === true) are omitted on purpose.
14
-
15
- // `operator` is widened to string so tests can exercise the unknown-operator path.
16
- function rule(partial: {
17
- operator: string;
18
- paramId: string;
19
- value?: unknown;
20
- values?: unknown[];
21
- }): VisibilityRule {
22
- return partial as unknown as VisibilityRule;
23
- }
24
-
25
- describe('evaluateRule — operator edge cases', () => {
26
- it('between requires exactly two values and is inclusive', () => {
27
- const r = rule({ operator: 'between', paramId: 'x', values: [10, 20] });
28
- expect(evaluateRule(r, { x: 10 })).toBe(true); // lower bound inclusive
29
- expect(evaluateRule(r, { x: 20 })).toBe(true); // upper bound inclusive
30
- expect(evaluateRule(r, { x: 21 })).toBe(false);
31
- // Wrong arity must fail closed, not throw or pass.
32
- expect(evaluateRule(rule({ operator: 'between', paramId: 'x', values: [10] }), { x: 15 })).toBe(
33
- false
34
- );
35
- });
36
-
37
- it('matches returns false for an invalid regex instead of throwing', () => {
38
- const r = rule({ operator: 'matches', paramId: 'x', value: '(' });
39
- expect(evaluateRule(r, { x: 'anything' })).toBe(false);
40
- });
41
-
42
- it('contains / containsAny coerce scalars and arrays through toStringArray', () => {
43
- // scalar value is treated as a single-element array
44
- expect(evaluateRule(rule({ operator: 'contains', paramId: 'x', value: '5' }), { x: 5 })).toBe(
45
- true
46
- );
47
- expect(
48
- evaluateRule(rule({ operator: 'containsAny', paramId: 'x', values: ['a', 'b'] }), {
49
- x: ['b', 'c']
50
- })
51
- ).toBe(true);
52
- });
53
-
54
- it('isEmpty treats empty string, null, and [] as empty but not a non-empty value', () => {
55
- const empty = rule({ operator: 'isEmpty', paramId: 'x' });
56
- expect(evaluateRule(empty, { x: '' })).toBe(true);
57
- expect(evaluateRule(empty, { x: null })).toBe(true);
58
- expect(evaluateRule(empty, { x: [] })).toBe(true);
59
- expect(evaluateRule(empty, { x: '0' })).toBe(false);
60
- });
61
-
62
- it('unknown operator fails closed', () => {
63
- expect(evaluateRule(rule({ operator: 'definitelyNotAnOp', paramId: 'x' }), { x: 1 })).toBe(
64
- false
65
- );
66
- });
67
- });
68
-
69
- describe('evaluateVisibility — action & short-circuit precedence', () => {
70
- const condition = {
71
- rules: [{ operator: 'equals', paramId: 'mode', value: 'advanced' }],
72
- mode: 'all'
73
- };
74
-
75
- it('explicit visible:false beats any visibility condition', () => {
76
- const item = {
77
- type: 'input',
78
- paramId: 'p',
79
- visible: false,
80
- visibilityCondition: { ...condition, action: 'show' }
81
- } as unknown as LayoutItem;
82
- // Condition is met, but visible:false wins.
83
- expect(evaluateVisibility(item, { mode: 'advanced' })).toEqual({
84
- visible: false,
85
- disabled: false
86
- });
87
- });
88
-
89
- it('hide action inverts visibility and only carries defaultValue', () => {
90
- const item = {
91
- type: 'input',
92
- paramId: 'p',
93
- visibilityCondition: { ...condition, action: 'hide', defaultValue: 7 }
94
- } as unknown as LayoutItem;
95
- expect(evaluateVisibility(item, { mode: 'advanced' })).toEqual({
96
- visible: false,
97
- disabled: false,
98
- defaultValue: 7
99
- });
100
- expect(evaluateVisibility(item, { mode: 'basic' }).visible).toBe(true);
101
- });
102
-
103
- it('disable action keeps item visible and only applies defaultValue when the condition is met', () => {
104
- const item = {
105
- type: 'input',
106
- paramId: 'p',
107
- visibilityCondition: { ...condition, action: 'disable', defaultValue: 'X' }
108
- } as unknown as LayoutItem;
109
- expect(evaluateVisibility(item, { mode: 'advanced' })).toEqual({
110
- visible: true,
111
- disabled: true,
112
- defaultValue: 'X'
113
- });
114
- expect(evaluateVisibility(item, { mode: 'basic' })).toEqual({
115
- visible: true,
116
- disabled: false,
117
- defaultValue: undefined
118
- });
119
- });
120
-
121
- it('item without a condition is visible and enabled', () => {
122
- const item = { type: 'input', paramId: 'p' } as unknown as LayoutItem;
123
- expect(evaluateVisibility(item, {})).toEqual({ visible: true, disabled: false });
124
- });
125
- });
126
-
127
- describe('buildVisibilityMap', () => {
128
- const cond = {
129
- rules: [{ operator: 'equals', paramId: 'mode', value: 'advanced' }],
130
- mode: 'all'
131
- };
132
-
133
- it('keys each item by paramId (inputs/outputs) or id (linebreak) and matches evaluateVisibility', () => {
134
- const items = [
135
- { type: 'input', paramId: 'a' },
136
- { type: 'linebreak', id: 'lb1' },
137
- {
138
- type: 'input',
139
- paramId: 'b',
140
- visibilityCondition: { ...cond, action: 'show' }
141
- }
142
- ] as unknown as LayoutItem[];
143
- const values = { mode: 'basic' };
144
-
145
- const map = buildVisibilityMap(items, values);
146
- expect(map.a).toEqual(evaluateVisibility(items[0], values));
147
- expect(map.lb1).toEqual(evaluateVisibility(items[1], values));
148
- // 'b' shows only when mode === 'advanced'; here it's hidden.
149
- expect(map.b.visible).toBe(false);
150
- expect(map.b).toEqual(evaluateVisibility(items[2], values));
151
- });
152
-
153
- it('itemKey returns paramId for controls and id for linebreaks', () => {
154
- expect(itemKey({ type: 'input', paramId: 'p1' } as unknown as LayoutItem)).toBe('p1');
155
- expect(itemKey({ type: 'linebreak', id: 'lb' } as unknown as LayoutItem)).toBe('lb');
156
- });
157
- });
158
-
159
- describe('evaluateGroupVisibility', () => {
160
- it('hide action inverts, show is the default, no condition means visible', () => {
161
- const cond = (action?: string): { visibilityCondition: GroupVisibilityCondition } => ({
162
- visibilityCondition: {
163
- action,
164
- mode: 'all',
165
- rules: [{ operator: 'equals', paramId: 'show', value: true }]
166
- } as unknown as GroupVisibilityCondition
167
- });
168
- expect(evaluateGroupVisibility(cond('hide'), { show: true })).toBe(false);
169
- expect(evaluateGroupVisibility(cond('show'), { show: true })).toBe(true);
170
- expect(evaluateGroupVisibility(cond(), { show: true })).toBe(true);
171
- expect(evaluateGroupVisibility({}, {})).toBe(true);
172
- });
173
- });
@@ -1,29 +0,0 @@
1
- /**
2
- * Result returned from a solve operation.
3
- *
4
- * @property outputs - Key-value pairs of computed results
5
- * @property meshes - Optional array of 3D mesh data generated during computation
6
- * @property errors - Optional array of error messages that occurred
7
- * @property warnings - Optional array of warning messages from the computation
8
- */
9
- export interface SolveResult {
10
- outputs: Record<string, unknown>;
11
- meshes?: any[];
12
- errors?: string[];
13
- warnings?: string[];
14
- }
15
-
16
- /**
17
- * Function type for running a computation with given input values.
18
- *
19
- * Implementations should listen to the abort signal and clean up resources
20
- * when the signal is triggered (e.g., when the user cancels the operation).
21
- *
22
- * @param values - Input parameters for the computation
23
- * @param signal - AbortSignal to cancel ongoing operations
24
- * @returns Promise resolving to the computation result
25
- */
26
- export type SolveFn = (
27
- values: Record<string, unknown>,
28
- signal: AbortSignal
29
- ) => Promise<SolveResult>;