@selvajs/ui 6.0.0-beta.0 → 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.
- package/README.md +42 -9
- package/dist/components/compute/ComputeApp.svelte +22 -6
- package/dist/components/compute/ComputeApp.svelte.d.ts +13 -2
- package/dist/components/viewer/Viewer.svelte +2 -2
- package/dist/compute/useSolveSession.svelte.js +3 -0
- package/package.json +7 -4
- package/src/lib/components/compute/ComputeApp.svelte +22 -6
- package/src/lib/components/viewer/Viewer.svelte +2 -2
- package/src/lib/compute/useSolveSession.svelte.ts +3 -0
- package/src/lib/compute/mesh-policy-wiring.test.ts +0 -72
- package/src/lib/schema/dynamic-value-list.test.ts +0 -150
- package/src/lib/schema/param-exporter.test.ts +0 -136
- package/src/lib/schema/traversal.test.ts +0 -92
- package/src/lib/schema/visibility-rules.test.ts +0 -173
package/README.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# @selvajs/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Svelte layer over Selva's framework-free cores. Its npm surface is the **compute-app SDK**:
|
|
4
|
+
everything an external host app needs to embed a Grasshopper-driven app, drive solves, and wire
|
|
5
|
+
pre-step producers.
|
|
6
|
+
|
|
7
|
+
The design system (`Button`, `Card`, `Dialog`, `AppShell`, …) lives here too, but is **internal to
|
|
8
|
+
the monorepo** — it is reachable from the full barrel via the `@selvajs/source` export condition and
|
|
9
|
+
never ships to npm. [`src/lib/public.ts`](./src/lib/public.ts) is the authoritative list of what a
|
|
10
|
+
published consumer can import; promote a primitive there explicitly rather than assuming it is
|
|
11
|
+
available.
|
|
4
12
|
|
|
5
13
|
## Installation
|
|
6
14
|
|
|
@@ -8,20 +16,41 @@ Shared Svelte components, utilities, and theme system for Selva applications.
|
|
|
8
16
|
pnpm add @selvajs/ui
|
|
9
17
|
```
|
|
10
18
|
|
|
11
|
-
Peer dependencies
|
|
19
|
+
Peer dependencies: `svelte ^5`, `@sveltejs/kit ^2`, `bits-ui ^2`, `tailwind-variants ^3`, plus the
|
|
20
|
+
Selva cores this package wraps — `@selvajs/compute`, `@selvajs/schemas`, `@selvajs/solve`, and
|
|
21
|
+
`@selvajs/visualization`.
|
|
22
|
+
|
|
23
|
+
`three` is an **optional** peer: install it only if you use `Viewer`, the part that wraps
|
|
24
|
+
`@selvajs/visualization`.
|
|
12
25
|
|
|
13
26
|
## Usage
|
|
14
27
|
|
|
28
|
+
Embed a whole Grasshopper-driven app:
|
|
29
|
+
|
|
30
|
+
```svelte
|
|
31
|
+
<script lang="ts">
|
|
32
|
+
import { ComputeApp } from '@selvajs/ui';
|
|
33
|
+
</script>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or render meshes on their own, outside a `ComputeApp` host:
|
|
37
|
+
|
|
15
38
|
```svelte
|
|
16
39
|
<script lang="ts">
|
|
17
|
-
import {
|
|
40
|
+
import { Viewer, type ViewerConfig } from '@selvajs/ui';
|
|
18
41
|
</script>
|
|
19
42
|
```
|
|
20
43
|
|
|
44
|
+
To drive a solve session yourself rather than letting `ComputeApp` own it:
|
|
45
|
+
|
|
21
46
|
```typescript
|
|
22
|
-
import {
|
|
47
|
+
import { useSolveSession } from '@selvajs/ui';
|
|
23
48
|
```
|
|
24
49
|
|
|
50
|
+
The session itself lives in `@selvajs/solve/client` and is framework-free. Inside a Svelte
|
|
51
|
+
component always use `useSolveSession` — the raw `createSolveSession` factory returns correct
|
|
52
|
+
values that never re-render.
|
|
53
|
+
|
|
25
54
|
## Styles
|
|
26
55
|
|
|
27
56
|
In your `app.css`:
|
|
@@ -30,16 +59,20 @@ In your `app.css`:
|
|
|
30
59
|
@import '@selvajs/ui/styles/base.css';
|
|
31
60
|
```
|
|
32
61
|
|
|
33
|
-
Themes are available under `@selvajs/ui/styles/themes
|
|
62
|
+
Themes are available under `@selvajs/ui/styles/themes/*` — `selva`, `neutral`, `ocean`, and
|
|
63
|
+
`cyberpunk`.
|
|
64
|
+
|
|
65
|
+
## Schema types
|
|
34
66
|
|
|
35
|
-
|
|
67
|
+
Types generated from `packages/schemas/ui-schema.json` are published by `@selvajs/schemas`, not
|
|
68
|
+
re-exported here:
|
|
36
69
|
|
|
37
70
|
```typescript
|
|
38
|
-
import type {
|
|
71
|
+
import type { SelvaUISchema } from '@selvajs/schemas';
|
|
39
72
|
```
|
|
40
73
|
|
|
41
|
-
|
|
74
|
+
After modifying the schema, run:
|
|
42
75
|
|
|
43
76
|
```bash
|
|
44
|
-
cd packages/schemas && pnpm run generate
|
|
77
|
+
cd packages/schemas && pnpm run generate
|
|
45
78
|
```
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { PresetLabels } from '../../types/presetLabels';
|
|
8
8
|
import { createSolvingIndicator } from '../../compute/solving.svelte';
|
|
9
9
|
import { createRequestResponseDriver } from '@selvajs/solve/client';
|
|
10
|
+
import type { RetainedSolveResult } from '@selvajs/solve/client';
|
|
10
11
|
import { meshPolicy } from '@selvajs/visualization/parse';
|
|
11
12
|
import { useSolveSession } from '../../compute/useSolveSession.svelte';
|
|
12
13
|
import { useFooterItem } from '../../composables/useFooterItem.svelte';
|
|
@@ -43,13 +44,25 @@
|
|
|
43
44
|
copyrightName?: string;
|
|
44
45
|
/** Fully overrides the footer copyright line. `{name}` and `{year}` are substituted. */
|
|
45
46
|
footerText?: string;
|
|
46
|
-
/**
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* How long one solve may take before the client aborts it (ms). Required: pass
|
|
49
|
+
* the same value the server enforces (`COMPUTE_SOLVE_DEADLINE_MS`), so the client
|
|
50
|
+
* doesn't abort a solve that would have finished.
|
|
51
|
+
*/
|
|
52
|
+
solveDeadlineMs: number;
|
|
48
53
|
footerComponent?: any;
|
|
49
54
|
footerComponentProps?: () => Record<string, unknown>;
|
|
50
55
|
footerItemId?: string;
|
|
51
56
|
footerItemPriority?: number;
|
|
52
|
-
onReady?: (api: {
|
|
57
|
+
onReady?: (api: {
|
|
58
|
+
loadValues: (values: Record<string, unknown>) => void;
|
|
59
|
+
/**
|
|
60
|
+
* The last result reported to the session — the one the viewer is showing, carrying
|
|
61
|
+
* `source`/`values` even when a memo hit served it. Null before the first solve.
|
|
62
|
+
* A getter, not a snapshot: `onReady` fires once.
|
|
63
|
+
*/
|
|
64
|
+
getLastResult: () => RetainedSolveResult | null;
|
|
65
|
+
}) => void;
|
|
53
66
|
headerRight?: Snippet;
|
|
54
67
|
// Replaces the built-in header; takes precedence over `headerRight`.
|
|
55
68
|
header?: Snippet;
|
|
@@ -83,7 +96,7 @@
|
|
|
83
96
|
presetLabels,
|
|
84
97
|
copyrightName,
|
|
85
98
|
footerText,
|
|
86
|
-
|
|
99
|
+
solveDeadlineMs,
|
|
87
100
|
footerComponent,
|
|
88
101
|
footerComponentProps,
|
|
89
102
|
footerItemId = 'footer-item',
|
|
@@ -114,7 +127,7 @@
|
|
|
114
127
|
// reads the reporter lazily so it can capture the session it's wired into.
|
|
115
128
|
// svelte-ignore state_referenced_locally
|
|
116
129
|
const driver = createRequestResponseDriver(onSolve, () => session, {
|
|
117
|
-
|
|
130
|
+
solveDeadlineMs,
|
|
118
131
|
// The driver's result memo caches whole solve results, meshes included — and the viewer
|
|
119
132
|
// disposes what it renders on the next scene update. `@selvajs/solve` keeps meshes opaque,
|
|
120
133
|
// so the three.js clone/dispose rules are injected from the renderer that owns them
|
|
@@ -137,7 +150,10 @@
|
|
|
137
150
|
const solvingIndicator = createSolvingIndicator(() => session.isSolving);
|
|
138
151
|
|
|
139
152
|
$effect(() => {
|
|
140
|
-
onReady?.({
|
|
153
|
+
onReady?.({
|
|
154
|
+
loadValues: (incoming) => session.loadValues(incoming),
|
|
155
|
+
getLastResult: () => session.lastResult
|
|
156
|
+
});
|
|
141
157
|
});
|
|
142
158
|
|
|
143
159
|
let previousDefinitionKey = $state('');
|
|
@@ -2,6 +2,7 @@ import type { UISchema, ParameterPreset } from '@selvajs/schemas';
|
|
|
2
2
|
import type { ActionButton } from '../../types/actionButton';
|
|
3
3
|
import type { SolveFn } from '@selvajs/solve/shared';
|
|
4
4
|
import type { PresetLabels } from '../../types/presetLabels';
|
|
5
|
+
import type { RetainedSolveResult } from '@selvajs/solve/client';
|
|
5
6
|
import { type ClientSlot } from '../../contexts/clientSlotContext.svelte';
|
|
6
7
|
import type { Locale } from '../../i18n/messages';
|
|
7
8
|
import type { Snippet } from 'svelte';
|
|
@@ -28,14 +29,24 @@ interface Props {
|
|
|
28
29
|
copyrightName?: string;
|
|
29
30
|
/** Fully overrides the footer copyright line. `{name}` and `{year}` are substituted. */
|
|
30
31
|
footerText?: string;
|
|
31
|
-
/**
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* How long one solve may take before the client aborts it (ms). Required: pass
|
|
34
|
+
* the same value the server enforces (`COMPUTE_SOLVE_DEADLINE_MS`), so the client
|
|
35
|
+
* doesn't abort a solve that would have finished.
|
|
36
|
+
*/
|
|
37
|
+
solveDeadlineMs: number;
|
|
33
38
|
footerComponent?: any;
|
|
34
39
|
footerComponentProps?: () => Record<string, unknown>;
|
|
35
40
|
footerItemId?: string;
|
|
36
41
|
footerItemPriority?: number;
|
|
37
42
|
onReady?: (api: {
|
|
38
43
|
loadValues: (values: Record<string, unknown>) => void;
|
|
44
|
+
/**
|
|
45
|
+
* The last result reported to the session — the one the viewer is showing, carrying
|
|
46
|
+
* `source`/`values` even when a memo hit served it. Null before the first solve.
|
|
47
|
+
* A getter, not a snapshot: `onReady` fires once.
|
|
48
|
+
*/
|
|
49
|
+
getLastResult: () => RetainedSolveResult | null;
|
|
39
50
|
}) => void;
|
|
40
51
|
headerRight?: Snippet;
|
|
41
52
|
header?: Snippet;
|
|
@@ -194,10 +194,10 @@
|
|
|
194
194
|
measure: { enabled: config.showToolsMenu },
|
|
195
195
|
events: {
|
|
196
196
|
onMeshMetadataClicked: config.enableMeshClick
|
|
197
|
-
? (metadata: Record<string,
|
|
197
|
+
? (metadata: Record<string, unknown>) => {
|
|
198
198
|
if (hasUsefulMetadata(metadata)) {
|
|
199
199
|
selectedMeshMetadata = metadata;
|
|
200
|
-
selectedMeshName = metadata?.name || t.objectFallbackName;
|
|
200
|
+
selectedMeshName = String(metadata?.name ?? '') || t.objectFallbackName;
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
: undefined
|
|
@@ -55,6 +55,9 @@ export function useSolveSession(args) {
|
|
|
55
55
|
get meshes() {
|
|
56
56
|
return track(() => session.meshes);
|
|
57
57
|
},
|
|
58
|
+
get lastResult() {
|
|
59
|
+
return track(() => session.lastResult);
|
|
60
|
+
},
|
|
58
61
|
get hasPendingChanges() {
|
|
59
62
|
return track(() => session.hasPendingChanges);
|
|
60
63
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "6.0.0-beta.
|
|
3
|
+
"version": "6.0.0-beta.1",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "VektorNode",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
"svelte": "^5",
|
|
61
61
|
"tailwind-variants": "^3.3.0",
|
|
62
62
|
"three": "^0.185.1",
|
|
63
|
-
"@selvajs/compute": "^4.0.0-beta.
|
|
63
|
+
"@selvajs/compute": "^4.0.0-beta.1",
|
|
64
64
|
"@selvajs/schemas": "^4.7.0",
|
|
65
|
-
"@selvajs/solve": "^0.
|
|
65
|
+
"@selvajs/solve": "^1.0.0-beta.5",
|
|
66
66
|
"@selvajs/visualization": "^1.0.0-beta.0"
|
|
67
67
|
},
|
|
68
68
|
"peerDependenciesMeta": {
|
|
@@ -93,7 +93,10 @@
|
|
|
93
93
|
"tailwind-variants": "^3.3.0",
|
|
94
94
|
"vitest": "^4.1.10",
|
|
95
95
|
"@selvajs/config": "0.0.3",
|
|
96
|
-
"@selvajs/schemas": "4.7.0"
|
|
96
|
+
"@selvajs/schemas": "4.7.0",
|
|
97
|
+
"@selvajs/visualization": "1.0.0-beta.0",
|
|
98
|
+
"@selvajs/solve": "1.0.0-beta.5",
|
|
99
|
+
"@selvajs/compute": "4.0.0-beta.1"
|
|
97
100
|
},
|
|
98
101
|
"scripts": {
|
|
99
102
|
"predev": "node ../../scripts/sync-shared-assets.js",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { PresetLabels } from '../../types/presetLabels';
|
|
8
8
|
import { createSolvingIndicator } from '../../compute/solving.svelte';
|
|
9
9
|
import { createRequestResponseDriver } from '@selvajs/solve/client';
|
|
10
|
+
import type { RetainedSolveResult } from '@selvajs/solve/client';
|
|
10
11
|
import { meshPolicy } from '@selvajs/visualization/parse';
|
|
11
12
|
import { useSolveSession } from '../../compute/useSolveSession.svelte';
|
|
12
13
|
import { useFooterItem } from '../../composables/useFooterItem.svelte';
|
|
@@ -43,13 +44,25 @@
|
|
|
43
44
|
copyrightName?: string;
|
|
44
45
|
/** Fully overrides the footer copyright line. `{name}` and `{year}` are substituted. */
|
|
45
46
|
footerText?: string;
|
|
46
|
-
/**
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* How long one solve may take before the client aborts it (ms). Required: pass
|
|
49
|
+
* the same value the server enforces (`COMPUTE_SOLVE_DEADLINE_MS`), so the client
|
|
50
|
+
* doesn't abort a solve that would have finished.
|
|
51
|
+
*/
|
|
52
|
+
solveDeadlineMs: number;
|
|
48
53
|
footerComponent?: any;
|
|
49
54
|
footerComponentProps?: () => Record<string, unknown>;
|
|
50
55
|
footerItemId?: string;
|
|
51
56
|
footerItemPriority?: number;
|
|
52
|
-
onReady?: (api: {
|
|
57
|
+
onReady?: (api: {
|
|
58
|
+
loadValues: (values: Record<string, unknown>) => void;
|
|
59
|
+
/**
|
|
60
|
+
* The last result reported to the session — the one the viewer is showing, carrying
|
|
61
|
+
* `source`/`values` even when a memo hit served it. Null before the first solve.
|
|
62
|
+
* A getter, not a snapshot: `onReady` fires once.
|
|
63
|
+
*/
|
|
64
|
+
getLastResult: () => RetainedSolveResult | null;
|
|
65
|
+
}) => void;
|
|
53
66
|
headerRight?: Snippet;
|
|
54
67
|
// Replaces the built-in header; takes precedence over `headerRight`.
|
|
55
68
|
header?: Snippet;
|
|
@@ -83,7 +96,7 @@
|
|
|
83
96
|
presetLabels,
|
|
84
97
|
copyrightName,
|
|
85
98
|
footerText,
|
|
86
|
-
|
|
99
|
+
solveDeadlineMs,
|
|
87
100
|
footerComponent,
|
|
88
101
|
footerComponentProps,
|
|
89
102
|
footerItemId = 'footer-item',
|
|
@@ -114,7 +127,7 @@
|
|
|
114
127
|
// reads the reporter lazily so it can capture the session it's wired into.
|
|
115
128
|
// svelte-ignore state_referenced_locally
|
|
116
129
|
const driver = createRequestResponseDriver(onSolve, () => session, {
|
|
117
|
-
|
|
130
|
+
solveDeadlineMs,
|
|
118
131
|
// The driver's result memo caches whole solve results, meshes included — and the viewer
|
|
119
132
|
// disposes what it renders on the next scene update. `@selvajs/solve` keeps meshes opaque,
|
|
120
133
|
// so the three.js clone/dispose rules are injected from the renderer that owns them
|
|
@@ -137,7 +150,10 @@
|
|
|
137
150
|
const solvingIndicator = createSolvingIndicator(() => session.isSolving);
|
|
138
151
|
|
|
139
152
|
$effect(() => {
|
|
140
|
-
onReady?.({
|
|
153
|
+
onReady?.({
|
|
154
|
+
loadValues: (incoming) => session.loadValues(incoming),
|
|
155
|
+
getLastResult: () => session.lastResult
|
|
156
|
+
});
|
|
141
157
|
});
|
|
142
158
|
|
|
143
159
|
let previousDefinitionKey = $state('');
|
|
@@ -194,10 +194,10 @@
|
|
|
194
194
|
measure: { enabled: config.showToolsMenu },
|
|
195
195
|
events: {
|
|
196
196
|
onMeshMetadataClicked: config.enableMeshClick
|
|
197
|
-
? (metadata: Record<string,
|
|
197
|
+
? (metadata: Record<string, unknown>) => {
|
|
198
198
|
if (hasUsefulMetadata(metadata)) {
|
|
199
199
|
selectedMeshMetadata = metadata;
|
|
200
|
-
selectedMeshName = metadata?.name || t.objectFallbackName;
|
|
200
|
+
selectedMeshName = String(metadata?.name ?? '') || t.objectFallbackName;
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
: undefined
|
|
@@ -62,6 +62,9 @@ export function useSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
62
62
|
get meshes() {
|
|
63
63
|
return track(() => session.meshes);
|
|
64
64
|
},
|
|
65
|
+
get lastResult() {
|
|
66
|
+
return track(() => session.lastResult);
|
|
67
|
+
},
|
|
65
68
|
get hasPendingChanges() {
|
|
66
69
|
return track(() => session.hasPendingChanges);
|
|
67
70
|
},
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import * as THREE from 'three';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
|
|
4
|
-
import { createRequestResponseDriver, type SolveReporter } from '@selvajs/solve/client';
|
|
5
|
-
import type { SolveResult } from '@selvajs/solve/shared';
|
|
6
|
-
import { meshPolicy } from '@selvajs/visualization/parse';
|
|
7
|
-
|
|
8
|
-
// The C1 seam test. `@selvajs/solve` keeps meshes opaque and `@selvajs/visualization` owns the
|
|
9
|
-
// three.js clone/dispose rules; neither package can prove they compose, because neither may
|
|
10
|
-
// import the other. This is the only place both are in scope — it exists to catch a
|
|
11
|
-
// `ComputeApp` that forgets to pass `meshPolicy`, or a policy whose shape drifts from
|
|
12
|
-
// `MeshPolicy`.
|
|
13
|
-
|
|
14
|
-
function meshResult(tag: string): SolveResult<THREE.Object3D> {
|
|
15
|
-
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial());
|
|
16
|
-
mesh.name = tag;
|
|
17
|
-
return { outputs: { out: tag }, meshes: [mesh] };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Mirrors the viewer's `clearScene`: it disposes the geometry of whatever it was handed. */
|
|
21
|
-
function renderAndDispose(result: SolveResult<THREE.Object3D>): void {
|
|
22
|
-
result.meshes?.forEach((root) =>
|
|
23
|
-
root.traverse((child) => (child as Partial<THREE.Mesh>).geometry?.dispose())
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function collectingReporter(): SolveReporter<THREE.Object3D> & {
|
|
28
|
-
reports: SolveResult<THREE.Object3D>[];
|
|
29
|
-
} {
|
|
30
|
-
const reports: SolveResult<THREE.Object3D>[] = [];
|
|
31
|
-
return {
|
|
32
|
-
reports,
|
|
33
|
-
report: (result) => reports.push(result),
|
|
34
|
-
reportError: () => {}
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
39
|
-
|
|
40
|
-
describe('mesh policy wiring (audit C1)', () => {
|
|
41
|
-
it('a memo hit serves a live mesh after the viewer disposed the previous one', async () => {
|
|
42
|
-
const onSolve = async () => meshResult('a');
|
|
43
|
-
const reporter = collectingReporter();
|
|
44
|
-
const driver = createRequestResponseDriver(onSolve, () => reporter, { meshPolicy });
|
|
45
|
-
|
|
46
|
-
driver.solve({ a: 1 });
|
|
47
|
-
await flush();
|
|
48
|
-
renderAndDispose(reporter.reports[0]); // the viewer eats solve 1's meshes
|
|
49
|
-
|
|
50
|
-
driver.solve({ a: 1 }); // same inputs → memo hit, no onSolve call
|
|
51
|
-
await flush();
|
|
52
|
-
|
|
53
|
-
expect(reporter.reports).toHaveLength(2);
|
|
54
|
-
const served = reporter.reports[1].meshes![0] as THREE.Mesh;
|
|
55
|
-
expect(served).not.toBe(reporter.reports[0].meshes![0]);
|
|
56
|
-
expect(served.geometry.attributes.position).toBeDefined();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('without a policy the same flow serves the disposed instance (why the wiring matters)', async () => {
|
|
60
|
-
// Pins the failure mode, so the assertion above is known to be testing something.
|
|
61
|
-
const onSolve = async () => meshResult('a');
|
|
62
|
-
const reporter = collectingReporter();
|
|
63
|
-
const driver = createRequestResponseDriver(onSolve, () => reporter); // no meshPolicy
|
|
64
|
-
|
|
65
|
-
driver.solve({ a: 1 });
|
|
66
|
-
await flush();
|
|
67
|
-
driver.solve({ a: 1 });
|
|
68
|
-
await flush();
|
|
69
|
-
|
|
70
|
-
expect(reporter.reports[1].meshes![0]).toBe(reporter.reports[0].meshes![0]);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -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
|
-
});
|