@mmapp/player-core 0.1.0-alpha.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/dist/index.d.mts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +4828 -0
- package/dist/index.mjs +4762 -0
- package/package.json +35 -0
- package/package.json.backup +35 -0
- package/src/__tests__/actions.test.ts +187 -0
- package/src/__tests__/blueprint-e2e.test.ts +706 -0
- package/src/__tests__/blueprint-test-runner.test.ts +680 -0
- package/src/__tests__/core-functions.test.ts +78 -0
- package/src/__tests__/dsl-compiler.test.ts +1382 -0
- package/src/__tests__/dsl-grammar.test.ts +1682 -0
- package/src/__tests__/events.test.ts +200 -0
- package/src/__tests__/expression.test.ts +296 -0
- package/src/__tests__/failure-policies.test.ts +110 -0
- package/src/__tests__/frontend-context.test.ts +182 -0
- package/src/__tests__/integration.test.ts +256 -0
- package/src/__tests__/security.test.ts +190 -0
- package/src/__tests__/state-machine.test.ts +450 -0
- package/src/__tests__/testing-engine.test.ts +671 -0
- package/src/actions/dispatcher.ts +80 -0
- package/src/actions/index.ts +7 -0
- package/src/actions/types.ts +25 -0
- package/src/dsl/compiler/component-mapper.ts +289 -0
- package/src/dsl/compiler/field-mapper.ts +187 -0
- package/src/dsl/compiler/index.ts +82 -0
- package/src/dsl/compiler/manifest-compiler.ts +76 -0
- package/src/dsl/compiler/symbol-table.ts +214 -0
- package/src/dsl/compiler/utils.ts +48 -0
- package/src/dsl/compiler/view-compiler.ts +286 -0
- package/src/dsl/compiler/workflow-compiler.ts +600 -0
- package/src/dsl/index.ts +66 -0
- package/src/dsl/ir-migration.ts +221 -0
- package/src/dsl/ir-types.ts +416 -0
- package/src/dsl/lexer.ts +579 -0
- package/src/dsl/parser.ts +115 -0
- package/src/dsl/types.ts +256 -0
- package/src/events/event-bus.ts +68 -0
- package/src/events/index.ts +9 -0
- package/src/events/pattern-matcher.ts +61 -0
- package/src/events/types.ts +27 -0
- package/src/expression/evaluator.ts +676 -0
- package/src/expression/functions.ts +214 -0
- package/src/expression/index.ts +13 -0
- package/src/expression/types.ts +64 -0
- package/src/index.ts +61 -0
- package/src/state-machine/index.ts +16 -0
- package/src/state-machine/interpreter.ts +319 -0
- package/src/state-machine/types.ts +89 -0
- package/src/testing/action-trace.ts +209 -0
- package/src/testing/blueprint-test-runner.ts +214 -0
- package/src/testing/graph-walker.ts +249 -0
- package/src/testing/index.ts +69 -0
- package/src/testing/nrt-comparator.ts +199 -0
- package/src/testing/nrt-types.ts +230 -0
- package/src/testing/test-actions.ts +645 -0
- package/src/testing/test-compiler.ts +278 -0
- package/src/testing/test-runner.ts +444 -0
- package/src/testing/types.ts +231 -0
- package/src/validation/definition-validator.ts +812 -0
- package/src/validation/index.ts +13 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph Walker — static analysis of workflow definitions.
|
|
3
|
+
*
|
|
4
|
+
* Treats a PlayerWorkflowDefinition as a directed graph and provides:
|
|
5
|
+
* - analyzeDefinition(): Full graph analysis (states, edges, paths, cycles)
|
|
6
|
+
* - generateCoverageScenarios(): Auto-generate test scenarios from paths
|
|
7
|
+
*
|
|
8
|
+
* Pure functions, zero dependencies beyond player-core types.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PlayerWorkflowDefinition } from '../state-machine/types';
|
|
12
|
+
import type {
|
|
13
|
+
AnalysisResult,
|
|
14
|
+
StateNode,
|
|
15
|
+
TransitionEdge,
|
|
16
|
+
GraphPath,
|
|
17
|
+
TestScenario,
|
|
18
|
+
TestStep,
|
|
19
|
+
} from './types';
|
|
20
|
+
|
|
21
|
+
const MAX_PATHS = 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Analyze a workflow definition as a directed graph.
|
|
25
|
+
* Returns all states, edges, terminal paths, cycles, unreachable/dead-end states.
|
|
26
|
+
*/
|
|
27
|
+
export function analyzeDefinition(def: PlayerWorkflowDefinition): AnalysisResult {
|
|
28
|
+
// Build state nodes
|
|
29
|
+
const states: StateNode[] = def.states.map(s => ({
|
|
30
|
+
name: s.name,
|
|
31
|
+
type: s.type,
|
|
32
|
+
reachable: false, // Will be set during DFS
|
|
33
|
+
hasOnEvent: Boolean(s.on_event?.length),
|
|
34
|
+
hasOnEnter: Boolean(s.on_enter?.length),
|
|
35
|
+
hasOnExit: Boolean(s.on_exit?.length),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Build edges (expand multi-from transitions)
|
|
39
|
+
const edges: TransitionEdge[] = [];
|
|
40
|
+
for (const t of def.transitions) {
|
|
41
|
+
for (const from of t.from) {
|
|
42
|
+
edges.push({
|
|
43
|
+
name: t.name,
|
|
44
|
+
from,
|
|
45
|
+
to: t.to,
|
|
46
|
+
auto: Boolean(t.auto),
|
|
47
|
+
hasConditions: Boolean(t.conditions?.length),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build adjacency list
|
|
53
|
+
const adjacency = new Map<string, TransitionEdge[]>();
|
|
54
|
+
for (const s of def.states) {
|
|
55
|
+
adjacency.set(s.name, []);
|
|
56
|
+
}
|
|
57
|
+
for (const e of edges) {
|
|
58
|
+
adjacency.get(e.from)?.push(e);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Find start state
|
|
62
|
+
const startState = def.states.find(s => s.type === 'START');
|
|
63
|
+
if (!startState) {
|
|
64
|
+
return {
|
|
65
|
+
states,
|
|
66
|
+
edges,
|
|
67
|
+
terminalPaths: [],
|
|
68
|
+
cycles: [],
|
|
69
|
+
unreachableStates: states.map(s => s.name),
|
|
70
|
+
deadEndStates: [],
|
|
71
|
+
summary: {
|
|
72
|
+
totalStates: states.length,
|
|
73
|
+
totalTransitions: edges.length,
|
|
74
|
+
reachableStates: 0,
|
|
75
|
+
terminalPaths: 0,
|
|
76
|
+
cycles: 0,
|
|
77
|
+
unreachableStates: states.length,
|
|
78
|
+
deadEndStates: 0,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Terminal state names
|
|
84
|
+
const terminalStates = new Set(
|
|
85
|
+
def.states.filter(s => s.type === 'END' || s.type === 'CANCELLED').map(s => s.name),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// DFS to find all paths and cycles
|
|
89
|
+
const terminalPaths: GraphPath[] = [];
|
|
90
|
+
const cycles: GraphPath[] = [];
|
|
91
|
+
const visited = new Set<string>();
|
|
92
|
+
|
|
93
|
+
function dfs(
|
|
94
|
+
state: string,
|
|
95
|
+
pathStates: string[],
|
|
96
|
+
pathTransitions: string[],
|
|
97
|
+
pathSet: Set<string>,
|
|
98
|
+
): void {
|
|
99
|
+
if (terminalPaths.length + cycles.length >= MAX_PATHS) return;
|
|
100
|
+
|
|
101
|
+
// Mark as reachable
|
|
102
|
+
const stateNode = states.find(s => s.name === state);
|
|
103
|
+
if (stateNode) stateNode.reachable = true;
|
|
104
|
+
visited.add(state);
|
|
105
|
+
|
|
106
|
+
// Terminal state → record path (but still check outgoing edges for cycles)
|
|
107
|
+
const isTerminal = terminalStates.has(state);
|
|
108
|
+
if (isTerminal) {
|
|
109
|
+
terminalPaths.push({
|
|
110
|
+
states: [...pathStates],
|
|
111
|
+
transitions: [...pathTransitions],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const outEdges = adjacency.get(state) ?? [];
|
|
116
|
+
|
|
117
|
+
for (const edge of outEdges) {
|
|
118
|
+
if (terminalPaths.length + cycles.length >= MAX_PATHS) return;
|
|
119
|
+
|
|
120
|
+
if (pathSet.has(edge.to)) {
|
|
121
|
+
// Cycle detected
|
|
122
|
+
const cycleStart = pathStates.indexOf(edge.to);
|
|
123
|
+
cycles.push({
|
|
124
|
+
states: [...pathStates.slice(cycleStart), edge.to],
|
|
125
|
+
transitions: [...pathTransitions.slice(cycleStart), edge.name],
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pathStates.push(edge.to);
|
|
131
|
+
pathTransitions.push(edge.name);
|
|
132
|
+
pathSet.add(edge.to);
|
|
133
|
+
|
|
134
|
+
dfs(edge.to, pathStates, pathTransitions, pathSet);
|
|
135
|
+
|
|
136
|
+
pathStates.pop();
|
|
137
|
+
pathTransitions.pop();
|
|
138
|
+
pathSet.delete(edge.to);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
dfs(startState.name, [startState.name], [], new Set([startState.name]));
|
|
143
|
+
|
|
144
|
+
// Identify unreachable states
|
|
145
|
+
const unreachableStates = states
|
|
146
|
+
.filter(s => !s.reachable)
|
|
147
|
+
.map(s => s.name);
|
|
148
|
+
|
|
149
|
+
// Identify dead-end states: non-terminal, reachable, with no outgoing transitions
|
|
150
|
+
const deadEndStates = states
|
|
151
|
+
.filter(s => {
|
|
152
|
+
if (!s.reachable) return false;
|
|
153
|
+
if (terminalStates.has(s.name)) return false;
|
|
154
|
+
const outEdges = adjacency.get(s.name) ?? [];
|
|
155
|
+
return outEdges.length === 0;
|
|
156
|
+
})
|
|
157
|
+
.map(s => s.name);
|
|
158
|
+
|
|
159
|
+
// Deduplicate cycles by string key
|
|
160
|
+
const uniqueCycles: GraphPath[] = [];
|
|
161
|
+
const seenCycleKeys = new Set<string>();
|
|
162
|
+
for (const cycle of cycles) {
|
|
163
|
+
const key = cycle.states.join('→');
|
|
164
|
+
if (!seenCycleKeys.has(key)) {
|
|
165
|
+
seenCycleKeys.add(key);
|
|
166
|
+
uniqueCycles.push(cycle);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
states,
|
|
172
|
+
edges,
|
|
173
|
+
terminalPaths,
|
|
174
|
+
cycles: uniqueCycles,
|
|
175
|
+
unreachableStates,
|
|
176
|
+
deadEndStates,
|
|
177
|
+
summary: {
|
|
178
|
+
totalStates: states.length,
|
|
179
|
+
totalTransitions: edges.length,
|
|
180
|
+
reachableStates: states.filter(s => s.reachable).length,
|
|
181
|
+
terminalPaths: terminalPaths.length,
|
|
182
|
+
cycles: uniqueCycles.length,
|
|
183
|
+
unreachableStates: unreachableStates.length,
|
|
184
|
+
deadEndStates: deadEndStates.length,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Generate one test scenario per terminal path in the definition.
|
|
191
|
+
* Each step = one transition, asserting the landing state.
|
|
192
|
+
*/
|
|
193
|
+
export function generateCoverageScenarios(
|
|
194
|
+
def: PlayerWorkflowDefinition,
|
|
195
|
+
analysis?: AnalysisResult,
|
|
196
|
+
): TestScenario[] {
|
|
197
|
+
const resolved = analysis ?? analyzeDefinition(def);
|
|
198
|
+
const scenarios: TestScenario[] = [];
|
|
199
|
+
|
|
200
|
+
for (const path of resolved.terminalPaths) {
|
|
201
|
+
const pathLabel = path.states.join(' → ');
|
|
202
|
+
const steps: TestStep[] = [];
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < path.transitions.length; i++) {
|
|
205
|
+
const transitionName = path.transitions[i];
|
|
206
|
+
const expectedState = path.states[i + 1];
|
|
207
|
+
|
|
208
|
+
// Check if this is a terminal state
|
|
209
|
+
const stateDef = def.states.find(s => s.name === expectedState);
|
|
210
|
+
const isTerminal = stateDef?.type === 'END' || stateDef?.type === 'CANCELLED';
|
|
211
|
+
const expectedStatus = stateDef?.type === 'END'
|
|
212
|
+
? 'COMPLETED'
|
|
213
|
+
: stateDef?.type === 'CANCELLED'
|
|
214
|
+
? 'CANCELLED'
|
|
215
|
+
: 'ACTIVE';
|
|
216
|
+
|
|
217
|
+
steps.push({
|
|
218
|
+
name: `${transitionName} → ${expectedState}`,
|
|
219
|
+
action: { type: 'transition', name: transitionName },
|
|
220
|
+
assertions: [
|
|
221
|
+
{
|
|
222
|
+
target: 'state',
|
|
223
|
+
operator: 'eq',
|
|
224
|
+
expected: expectedState,
|
|
225
|
+
label: `State should be "${expectedState}"`,
|
|
226
|
+
},
|
|
227
|
+
...(isTerminal
|
|
228
|
+
? [
|
|
229
|
+
{
|
|
230
|
+
target: 'status' as const,
|
|
231
|
+
operator: 'eq' as const,
|
|
232
|
+
expected: expectedStatus,
|
|
233
|
+
label: `Status should be "${expectedStatus}"`,
|
|
234
|
+
},
|
|
235
|
+
]
|
|
236
|
+
: []),
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
scenarios.push({
|
|
242
|
+
name: `Path: ${pathLabel}`,
|
|
243
|
+
tags: ['auto-generated', 'coverage'],
|
|
244
|
+
steps,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return scenarios;
|
|
249
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing OS — test declarations and execution for player-core.
|
|
3
|
+
*
|
|
4
|
+
* Three layers:
|
|
5
|
+
* 1. Graph walker: static analysis + coverage generation
|
|
6
|
+
* 2. In-process runner: direct StateMachine execution (legacy, still works)
|
|
7
|
+
* 3. Blueprint runner: test IS a Blueprint, runs on any Player
|
|
8
|
+
* - Compiler: TestProgram → PlayerWorkflowDefinition
|
|
9
|
+
* - Action handlers: pluggable (in-process, API, cross-platform)
|
|
10
|
+
* - Runner: thin executor that drives the Player
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Types
|
|
14
|
+
export type {
|
|
15
|
+
// Static analysis
|
|
16
|
+
StateNode,
|
|
17
|
+
TransitionEdge,
|
|
18
|
+
GraphPath,
|
|
19
|
+
AnalysisSummary,
|
|
20
|
+
AnalysisResult,
|
|
21
|
+
// Test program
|
|
22
|
+
TestProgram,
|
|
23
|
+
TestInstance,
|
|
24
|
+
TestScenario,
|
|
25
|
+
TestStep,
|
|
26
|
+
TestAssertion,
|
|
27
|
+
// Results
|
|
28
|
+
AssertionResult,
|
|
29
|
+
StepResult,
|
|
30
|
+
ScenarioResult,
|
|
31
|
+
TestProgramResult,
|
|
32
|
+
// In-process config
|
|
33
|
+
TestRunnerConfig,
|
|
34
|
+
// API adapter + config
|
|
35
|
+
ApiTestAdapter,
|
|
36
|
+
ApiTestRunnerConfig,
|
|
37
|
+
ApiInstanceSnapshot,
|
|
38
|
+
ApiTransitionResult,
|
|
39
|
+
} from './types';
|
|
40
|
+
|
|
41
|
+
// Graph walker
|
|
42
|
+
export { analyzeDefinition, generateCoverageScenarios } from './graph-walker';
|
|
43
|
+
|
|
44
|
+
// In-process runner (direct StateMachine, no Blueprint compilation)
|
|
45
|
+
export { runTestProgram, runScenario } from './test-runner';
|
|
46
|
+
|
|
47
|
+
// Blueprint runner (test IS a Blueprint, runs on any Player)
|
|
48
|
+
export { compileTestScenario, compileTestProgram } from './test-compiler';
|
|
49
|
+
export { createInProcessTestActions, createApiTestActions } from './test-actions';
|
|
50
|
+
export type { TestActionState } from './test-actions';
|
|
51
|
+
export { runBlueprintTestProgram, runBlueprintScenario } from './blueprint-test-runner';
|
|
52
|
+
export type { BlueprintRunnerConfig } from './blueprint-test-runner';
|
|
53
|
+
|
|
54
|
+
// NRT (Normalized Render Trace)
|
|
55
|
+
export type {
|
|
56
|
+
NRT,
|
|
57
|
+
NRTNode,
|
|
58
|
+
NRTEvent,
|
|
59
|
+
NRTEventTarget,
|
|
60
|
+
NRTNavState,
|
|
61
|
+
NRTDiff,
|
|
62
|
+
NRTChange,
|
|
63
|
+
} from './nrt-types';
|
|
64
|
+
export { createEmptyNRT, findNode, findVisibleNodes, findInteractiveNodes, countNodes } from './nrt-types';
|
|
65
|
+
export { compareNRT } from './nrt-comparator';
|
|
66
|
+
|
|
67
|
+
// Action Trace
|
|
68
|
+
export type { ActionTrace, ActionTick, ActionRecorder } from './action-trace';
|
|
69
|
+
export { createActionRecorder, getTransitionPath, getFinalState, countByKind, hasTransition } from './action-trace';
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NRT Comparator — semantic diff between two NRT snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Compares structure, text, visibility, and events while ignoring
|
|
5
|
+
* platform-specific details like classNames, styles, and layout metrics.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const diff = compareNRT(before, after);
|
|
9
|
+
* if (!diff.equal) {
|
|
10
|
+
* console.log('Changes:', diff.changes);
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { NRT, NRTNode, NRTDiff, NRTChange } from './nrt-types';
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Comparison
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compares two NRT snapshots and returns a semantic diff.
|
|
22
|
+
*/
|
|
23
|
+
export function compareNRT(before: NRT, after: NRT): NRTDiff {
|
|
24
|
+
const changes: NRTChange[] = [];
|
|
25
|
+
|
|
26
|
+
// Compare workflow state
|
|
27
|
+
if (before.workflowState !== after.workflowState) {
|
|
28
|
+
changes.push({
|
|
29
|
+
type: 'modified',
|
|
30
|
+
path: 'workflowState',
|
|
31
|
+
field: 'workflowState',
|
|
32
|
+
oldValue: before.workflowState,
|
|
33
|
+
newValue: after.workflowState,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Compare navigation state
|
|
38
|
+
if (before.nav?.routeState !== after.nav?.routeState) {
|
|
39
|
+
changes.push({
|
|
40
|
+
type: 'modified',
|
|
41
|
+
path: 'nav.routeState',
|
|
42
|
+
field: 'routeState',
|
|
43
|
+
oldValue: before.nav?.routeState,
|
|
44
|
+
newValue: after.nav?.routeState,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Compare node trees
|
|
49
|
+
compareNodes(before.root, after.root, 'root', changes);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
equal: changes.length === 0,
|
|
53
|
+
changes,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recursively compares two NRT nodes.
|
|
59
|
+
*/
|
|
60
|
+
function compareNodes(
|
|
61
|
+
before: NRTNode | undefined,
|
|
62
|
+
after: NRTNode | undefined,
|
|
63
|
+
path: string,
|
|
64
|
+
changes: NRTChange[]
|
|
65
|
+
): void {
|
|
66
|
+
// Node added
|
|
67
|
+
if (!before && after) {
|
|
68
|
+
changes.push({
|
|
69
|
+
type: 'added',
|
|
70
|
+
path,
|
|
71
|
+
nodeId: after.id,
|
|
72
|
+
field: 'node',
|
|
73
|
+
newValue: summarizeNode(after),
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Node removed
|
|
79
|
+
if (before && !after) {
|
|
80
|
+
changes.push({
|
|
81
|
+
type: 'removed',
|
|
82
|
+
path,
|
|
83
|
+
nodeId: before.id,
|
|
84
|
+
field: 'node',
|
|
85
|
+
oldValue: summarizeNode(before),
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!before || !after) return;
|
|
91
|
+
|
|
92
|
+
// Compare semantic fields (ignore platform-specific details)
|
|
93
|
+
if (before.type !== after.type) {
|
|
94
|
+
changes.push({
|
|
95
|
+
type: 'modified',
|
|
96
|
+
path,
|
|
97
|
+
nodeId: after.id,
|
|
98
|
+
field: 'type',
|
|
99
|
+
oldValue: before.type,
|
|
100
|
+
newValue: after.type,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (before.text !== after.text) {
|
|
105
|
+
changes.push({
|
|
106
|
+
type: 'modified',
|
|
107
|
+
path,
|
|
108
|
+
nodeId: after.id,
|
|
109
|
+
field: 'text',
|
|
110
|
+
oldValue: before.text,
|
|
111
|
+
newValue: after.text,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (before.visible !== after.visible) {
|
|
116
|
+
changes.push({
|
|
117
|
+
type: 'modified',
|
|
118
|
+
path,
|
|
119
|
+
nodeId: after.id,
|
|
120
|
+
field: 'visible',
|
|
121
|
+
oldValue: before.visible,
|
|
122
|
+
newValue: after.visible,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (before.enabled !== after.enabled) {
|
|
127
|
+
changes.push({
|
|
128
|
+
type: 'modified',
|
|
129
|
+
path,
|
|
130
|
+
nodeId: after.id,
|
|
131
|
+
field: 'enabled',
|
|
132
|
+
oldValue: before.enabled,
|
|
133
|
+
newValue: after.enabled,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (JSON.stringify(before.value) !== JSON.stringify(after.value)) {
|
|
138
|
+
changes.push({
|
|
139
|
+
type: 'modified',
|
|
140
|
+
path,
|
|
141
|
+
nodeId: after.id,
|
|
142
|
+
field: 'value',
|
|
143
|
+
oldValue: before.value,
|
|
144
|
+
newValue: after.value,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Compare events
|
|
149
|
+
const beforeEvents = Object.keys(before.events || {});
|
|
150
|
+
const afterEvents = Object.keys(after.events || {});
|
|
151
|
+
|
|
152
|
+
for (const event of afterEvents) {
|
|
153
|
+
if (!beforeEvents.includes(event)) {
|
|
154
|
+
changes.push({
|
|
155
|
+
type: 'added',
|
|
156
|
+
path: `${path}.events`,
|
|
157
|
+
nodeId: after.id,
|
|
158
|
+
field: event,
|
|
159
|
+
newValue: after.events![event],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const event of beforeEvents) {
|
|
165
|
+
if (!afterEvents.includes(event)) {
|
|
166
|
+
changes.push({
|
|
167
|
+
type: 'removed',
|
|
168
|
+
path: `${path}.events`,
|
|
169
|
+
nodeId: before.id,
|
|
170
|
+
field: event,
|
|
171
|
+
oldValue: before.events![event],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Compare children
|
|
177
|
+
const maxChildren = Math.max(before.children.length, after.children.length);
|
|
178
|
+
for (let i = 0; i < maxChildren; i++) {
|
|
179
|
+
compareNodes(
|
|
180
|
+
before.children[i],
|
|
181
|
+
after.children[i],
|
|
182
|
+
`${path}.children[${i}]`,
|
|
183
|
+
changes
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Creates a brief summary of a node for diff output.
|
|
190
|
+
*/
|
|
191
|
+
function summarizeNode(node: NRTNode): Record<string, unknown> {
|
|
192
|
+
return {
|
|
193
|
+
id: node.id,
|
|
194
|
+
type: node.type,
|
|
195
|
+
text: node.text,
|
|
196
|
+
visible: node.visible,
|
|
197
|
+
childCount: node.children.length,
|
|
198
|
+
};
|
|
199
|
+
}
|