Skip to content

Commit 0fb9433

Browse files
committed
Export equivalence functions and constants
1 parent 6d2465d commit 0fb9433

4 files changed

Lines changed: 364 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@statelyai/graph": minor
3+
---
4+
5+
Add entity equivalence functions: `areEntitiesEqual`, `isLayoutEqual`, `isNonLayoutEqual`, and `LAYOUT_KEYS`.

src/equivalence.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { GraphNode, GraphEdge } from './types';
2+
3+
/** Shallow-compare two values, returning true if they differ. */
4+
function differs(a: unknown, b: unknown): boolean {
5+
if (a === b) return false;
6+
if (a == null || b == null) return a !== b;
7+
if (typeof a === 'object' && typeof b === 'object') {
8+
return JSON.stringify(a) !== JSON.stringify(b);
9+
}
10+
return true;
11+
}
12+
13+
export const LAYOUT_KEYS = {
14+
node: ['x', 'y', 'width', 'height', 'style', 'color', 'shape'] as const,
15+
edge: ['x', 'y', 'width', 'height', 'style', 'color'] as const,
16+
};
17+
18+
const LAYOUT_KEY_SET = {
19+
node: new Set<string>(LAYOUT_KEYS.node),
20+
edge: new Set<string>(LAYOUT_KEYS.edge),
21+
};
22+
23+
/**
24+
* Compare two entities on a given set of keys.
25+
* If `keys` is omitted or empty, compares all own keys of `a`.
26+
*
27+
* @example
28+
* ```ts
29+
* import { createGraphNode, areEntitiesEqual, LAYOUT_KEYS } from '@statelyai/graph';
30+
*
31+
* const a = createGraphNode({ id: 'n1', label: 'hello', x: 0 });
32+
* const b = createGraphNode({ id: 'n1', label: 'hello', x: 100 });
33+
*
34+
* areEntitiesEqual(a, b, LAYOUT_KEYS.node); // false (x differs)
35+
* areEntitiesEqual(a, b, NON_LAYOUT_KEYS.node); // true
36+
* areEntitiesEqual(a, b); // false (compares all keys)
37+
* ```
38+
*/
39+
export function areEntitiesEqual<T extends GraphNode | GraphEdge>(
40+
a: T,
41+
b: T,
42+
keys?: readonly (keyof T)[],
43+
): boolean {
44+
const compareKeys =
45+
keys && keys.length > 0 ? keys : (Object.keys(a) as (keyof T)[]);
46+
for (const key of compareKeys) {
47+
if (differs(a[key], b[key])) return false;
48+
}
49+
return true;
50+
}
51+
52+
/**
53+
* Compare two entities on layout keys only (position, size, style, color, shape).
54+
*
55+
* @example
56+
* ```ts
57+
* import { createGraphNode, isLayoutEqual } from '@statelyai/graph';
58+
*
59+
* const a = createGraphNode({ id: 'n1', x: 0, y: 0 });
60+
* const b = createGraphNode({ id: 'n1', x: 100, y: 200 });
61+
*
62+
* isLayoutEqual(a, b); // false
63+
* ```
64+
*/
65+
export function isLayoutEqual<T extends GraphNode | GraphEdge>(
66+
a: T,
67+
b: T,
68+
): boolean {
69+
return areEntitiesEqual(a, b, LAYOUT_KEYS[a.type] as readonly (keyof T)[]);
70+
}
71+
72+
/**
73+
* Compare two entities on non-layout keys only (id, data, connections, labels, etc.).
74+
*
75+
* @example
76+
* ```ts
77+
* import { createGraphNode, isNonLayoutEqual } from '@statelyai/graph';
78+
*
79+
* const a = createGraphNode({ id: 'n1', label: 'hello', x: 0 });
80+
* const b = createGraphNode({ id: 'n1', label: 'hello', x: 100 });
81+
*
82+
* isNonLayoutEqual(a, b); // true (layout changed, but non-layout didn't)
83+
* ```
84+
*/
85+
export function isNonLayoutEqual<T extends GraphNode | GraphEdge>(
86+
a: T,
87+
b: T,
88+
): boolean {
89+
const skip = LAYOUT_KEY_SET[a.type];
90+
const keys = Object.keys(a);
91+
for (let i = 0; i < keys.length; i++) {
92+
if (skip.has(keys[i])) continue;
93+
if (differs((a as any)[keys[i]], (b as any)[keys[i]])) return false;
94+
}
95+
return true;
96+
}

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ export {
161161
isIsomorphic,
162162
} from './algorithms';
163163

164+
// Equivalence
165+
export {
166+
areEntitiesEqual,
167+
isLayoutEqual,
168+
isNonLayoutEqual,
169+
LAYOUT_KEYS,
170+
} from './equivalence';
171+
164172
// Diff & Patches
165173
export {
166174
getDiff,

tests/equivalence.test.ts

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
createGraphNode,
4+
createGraphEdge,
5+
areEntitiesEqual,
6+
isLayoutEqual,
7+
isNonLayoutEqual,
8+
LAYOUT_KEYS,
9+
} from '../src/index';
10+
11+
describe('areEntitiesEqual', () => {
12+
describe('nodes', () => {
13+
const baseNode = createGraphNode({ id: 'n1', label: 'A', x: 10, y: 20 });
14+
15+
it('returns true for identical nodes (no keys = all own keys)', () => {
16+
const other = createGraphNode({ id: 'n1', label: 'A', x: 10, y: 20 });
17+
expect(areEntitiesEqual(baseNode, other)).toBe(true);
18+
});
19+
20+
it('returns false when any own key differs (no keys specified)', () => {
21+
const other = createGraphNode({ id: 'n1', label: 'B', x: 10, y: 20 });
22+
expect(areEntitiesEqual(baseNode, other)).toBe(false);
23+
});
24+
25+
it('compares only specified keys', () => {
26+
const other = createGraphNode({ id: 'n1', label: 'B', x: 10, y: 20 });
27+
expect(areEntitiesEqual(baseNode, other, ['id', 'x', 'y'])).toBe(true);
28+
});
29+
30+
it('detects difference in specified keys', () => {
31+
const other = createGraphNode({ id: 'n1', label: 'A', x: 999, y: 20 });
32+
expect(areEntitiesEqual(baseNode, other, ['x'])).toBe(false);
33+
});
34+
35+
it('compares data deeply', () => {
36+
const a = createGraphNode({ id: 'n1', data: { foo: 1 } });
37+
const b = createGraphNode({ id: 'n1', data: { foo: 1 } });
38+
const c = createGraphNode({ id: 'n1', data: { foo: 2 } });
39+
expect(areEntitiesEqual(a, b, ['data'])).toBe(true);
40+
expect(areEntitiesEqual(a, c, ['data'])).toBe(false);
41+
});
42+
43+
it('compares style deeply', () => {
44+
const a = createGraphNode({ id: 'n1', style: { fill: 'red' } });
45+
const b = createGraphNode({ id: 'n1', style: { fill: 'red' } });
46+
const c = createGraphNode({ id: 'n1', style: { fill: 'blue' } });
47+
expect(areEntitiesEqual(a, b, ['style'])).toBe(true);
48+
expect(areEntitiesEqual(a, c, ['style'])).toBe(false);
49+
});
50+
});
51+
52+
describe('edges', () => {
53+
const baseEdge = createGraphEdge({
54+
id: 'e1',
55+
sourceId: 'a',
56+
targetId: 'b',
57+
label: 'go',
58+
x: 5,
59+
});
60+
61+
it('returns true for identical edges', () => {
62+
const other = createGraphEdge({
63+
id: 'e1',
64+
sourceId: 'a',
65+
targetId: 'b',
66+
label: 'go',
67+
x: 5,
68+
});
69+
expect(areEntitiesEqual(baseEdge, other)).toBe(true);
70+
});
71+
72+
it('returns false when connection differs', () => {
73+
const other = createGraphEdge({
74+
id: 'e1',
75+
sourceId: 'a',
76+
targetId: 'c',
77+
label: 'go',
78+
x: 5,
79+
});
80+
expect(areEntitiesEqual(baseEdge, other)).toBe(false);
81+
});
82+
});
83+
});
84+
85+
describe('isLayoutEqual', () => {
86+
it('ignores non-layout fields on nodes', () => {
87+
const a = createGraphNode({
88+
id: 'n1',
89+
label: 'A',
90+
x: 10,
91+
y: 20,
92+
width: 100,
93+
height: 50,
94+
});
95+
const b = createGraphNode({
96+
id: 'n1',
97+
label: 'B',
98+
x: 10,
99+
y: 20,
100+
width: 100,
101+
height: 50,
102+
});
103+
expect(isLayoutEqual(a, b)).toBe(true);
104+
});
105+
106+
it('detects layout differences on nodes', () => {
107+
const a = createGraphNode({ id: 'n1', x: 10, y: 20 });
108+
const b = createGraphNode({ id: 'n1', x: 999, y: 20 });
109+
expect(isLayoutEqual(a, b)).toBe(false);
110+
});
111+
112+
it('detects color difference on nodes', () => {
113+
const a = createGraphNode({ id: 'n1', color: 'red' });
114+
const b = createGraphNode({ id: 'n1', color: 'blue' });
115+
expect(isLayoutEqual(a, b)).toBe(false);
116+
});
117+
118+
it('detects shape difference on nodes', () => {
119+
const a = createGraphNode({ id: 'n1', shape: 'rect' });
120+
const b = createGraphNode({ id: 'n1', shape: 'circle' });
121+
expect(isLayoutEqual(a, b)).toBe(false);
122+
});
123+
124+
it('ignores non-layout fields on edges', () => {
125+
const a = createGraphEdge({
126+
id: 'e1',
127+
sourceId: 'a',
128+
targetId: 'b',
129+
x: 10,
130+
});
131+
const b = createGraphEdge({
132+
id: 'e1',
133+
sourceId: 'a',
134+
targetId: 'c',
135+
x: 10,
136+
});
137+
expect(isLayoutEqual(a, b)).toBe(true);
138+
});
139+
140+
it('detects layout differences on edges', () => {
141+
const a = createGraphEdge({
142+
id: 'e1',
143+
sourceId: 'a',
144+
targetId: 'b',
145+
x: 10,
146+
});
147+
const b = createGraphEdge({
148+
id: 'e1',
149+
sourceId: 'a',
150+
targetId: 'b',
151+
x: 999,
152+
});
153+
expect(isLayoutEqual(a, b)).toBe(false);
154+
});
155+
});
156+
157+
describe('isNonLayoutEqual', () => {
158+
it('ignores layout fields on nodes', () => {
159+
const a = createGraphNode({ id: 'n1', label: 'A', x: 0, y: 0 });
160+
const b = createGraphNode({ id: 'n1', label: 'A', x: 999, y: 999 });
161+
expect(isNonLayoutEqual(a, b)).toBe(true);
162+
});
163+
164+
it('detects non-layout differences on nodes', () => {
165+
const a = createGraphNode({ id: 'n1', label: 'A', x: 0 });
166+
const b = createGraphNode({ id: 'n1', label: 'B', x: 0 });
167+
expect(isNonLayoutEqual(a, b)).toBe(false);
168+
});
169+
170+
it('detects parentId change', () => {
171+
const a = createGraphNode({ id: 'n1', parentId: 'p1' });
172+
const b = createGraphNode({ id: 'n1', parentId: 'p2' });
173+
expect(isNonLayoutEqual(a, b)).toBe(false);
174+
});
175+
176+
it('detects data change', () => {
177+
const a = createGraphNode({ id: 'n1', data: { v: 1 } });
178+
const b = createGraphNode({ id: 'n1', data: { v: 2 } });
179+
expect(isNonLayoutEqual(a, b)).toBe(false);
180+
});
181+
182+
it('ignores layout fields on edges', () => {
183+
const a = createGraphEdge({
184+
id: 'e1',
185+
sourceId: 'a',
186+
targetId: 'b',
187+
x: 0,
188+
});
189+
const b = createGraphEdge({
190+
id: 'e1',
191+
sourceId: 'a',
192+
targetId: 'b',
193+
x: 999,
194+
});
195+
expect(isNonLayoutEqual(a, b)).toBe(true);
196+
});
197+
198+
it('detects connection change on edges', () => {
199+
const a = createGraphEdge({ id: 'e1', sourceId: 'a', targetId: 'b' });
200+
const b = createGraphEdge({ id: 'e1', sourceId: 'a', targetId: 'c' });
201+
expect(isNonLayoutEqual(a, b)).toBe(false);
202+
});
203+
204+
it('detects weight change on edges', () => {
205+
const a = createGraphEdge({
206+
id: 'e1',
207+
sourceId: 'a',
208+
targetId: 'b',
209+
weight: 1,
210+
});
211+
const b = createGraphEdge({
212+
id: 'e1',
213+
sourceId: 'a',
214+
targetId: 'b',
215+
weight: 5,
216+
});
217+
expect(isNonLayoutEqual(a, b)).toBe(false);
218+
});
219+
220+
it('detects port change on edges', () => {
221+
const a = createGraphEdge({
222+
id: 'e1',
223+
sourceId: 'a',
224+
targetId: 'b',
225+
sourcePort: 'out1',
226+
});
227+
const b = createGraphEdge({
228+
id: 'e1',
229+
sourceId: 'a',
230+
targetId: 'b',
231+
sourcePort: 'out2',
232+
});
233+
expect(isNonLayoutEqual(a, b)).toBe(false);
234+
});
235+
});
236+
237+
describe('key sets', () => {
238+
it('LAYOUT_KEYS has node and edge arrays', () => {
239+
expect(LAYOUT_KEYS.node).toContain('x');
240+
expect(LAYOUT_KEYS.node).toContain('style');
241+
expect(LAYOUT_KEYS.node).toContain('shape');
242+
expect(LAYOUT_KEYS.edge).toContain('x');
243+
expect(LAYOUT_KEYS.edge).not.toContain('shape');
244+
});
245+
246+
it('non-layout keys are the inverse of layout keys', () => {
247+
// isNonLayoutEqual should ignore layout keys and compare the rest
248+
const node = createGraphNode({ id: 'n1', label: 'A', x: 10 });
249+
const moved = { ...node, x: 999 };
250+
const renamed = { ...node, label: 'B' };
251+
252+
expect(isNonLayoutEqual(node, moved)).toBe(true);
253+
expect(isNonLayoutEqual(node, renamed)).toBe(false);
254+
});
255+
});

0 commit comments

Comments
 (0)