Skip to content

Commit 16b35f4

Browse files
kubeclaude
andcommitted
Add isSDCPNEqual for structural comparison of SDCPN definitions
Replace the field-by-field JSON.stringify isDirty check with a typed isSDCPNEqual function that recursively deep-compares two SDCPN objects. This automatically stays correct if SDCPN gains new fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7c6df60 commit 16b35f4

4 files changed

Lines changed: 198 additions & 54 deletions

File tree

apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx

Lines changed: 5 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
PropertyObjectWithMetadata,
55
} from "@blockprotocol/type-system";
66
import type { SDCPN } from "@hashintel/petrinaut";
7+
import { isSDCPNEqual } from "@hashintel/petrinaut";
78
import { HashEntity } from "@local/hash-graph-sdk/entity";
89
import {
910
blockProtocolDataTypes,
@@ -95,60 +96,10 @@ export const useProcessSaveAndLoad = ({
9596
return true;
9697
}
9798

98-
if (title !== persistedNet.title) {
99-
return true;
100-
}
101-
102-
if (petriNet.places.length !== persistedNet.definition.places.length) {
103-
return true;
104-
}
105-
106-
if (
107-
petriNet.transitions.length !== persistedNet.definition.transitions.length
108-
) {
109-
return true;
110-
}
111-
112-
if (petriNet.types.length !== persistedNet.definition.types.length) {
113-
return true;
114-
}
115-
116-
if (
117-
JSON.stringify(petriNet.places) !==
118-
JSON.stringify(persistedNet.definition.places)
119-
) {
120-
return true;
121-
}
122-
123-
if (
124-
JSON.stringify(petriNet.transitions) !==
125-
JSON.stringify(persistedNet.definition.transitions)
126-
) {
127-
return true;
128-
}
129-
130-
if (
131-
JSON.stringify(petriNet.types) !==
132-
JSON.stringify(persistedNet.definition.types)
133-
) {
134-
return true;
135-
}
136-
137-
if (
138-
JSON.stringify(petriNet.differentialEquations) !==
139-
JSON.stringify(persistedNet.definition.differentialEquations)
140-
) {
141-
return true;
142-
}
143-
144-
if (
145-
JSON.stringify(petriNet.parameters) !==
146-
JSON.stringify(persistedNet.definition.parameters)
147-
) {
148-
return true;
149-
}
150-
151-
return false;
99+
return (
100+
title !== persistedNet.title ||
101+
!isSDCPNEqual(petriNet, persistedNet.definition)
102+
);
152103
}, [petriNet, persistedNet, title]);
153104

154105
const loadPersistedNet = useCallback(
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { SDCPN } from "../core/types/sdcpn";
4+
import { isSDCPNEqual } from "./deep-equal";
5+
6+
const emptyNet: SDCPN = {
7+
places: [],
8+
transitions: [],
9+
types: [],
10+
differentialEquations: [],
11+
parameters: [],
12+
};
13+
14+
const sampleNet: SDCPN = {
15+
places: [
16+
{
17+
id: "p1",
18+
name: "Place 1",
19+
colorId: "c1",
20+
dynamicsEnabled: false,
21+
differentialEquationId: null,
22+
x: 100,
23+
y: 200,
24+
},
25+
],
26+
transitions: [
27+
{
28+
id: "t1",
29+
name: "Transition 1",
30+
inputArcs: [{ placeId: "p1", weight: 1 }],
31+
outputArcs: [{ placeId: "p1", weight: 2 }],
32+
lambdaType: "predicate",
33+
lambdaCode: "return true;",
34+
transitionKernelCode: "return input;",
35+
x: 300,
36+
y: 200,
37+
},
38+
],
39+
types: [
40+
{
41+
id: "c1",
42+
name: "Token",
43+
iconSlug: "circle",
44+
displayColor: "#FF0000",
45+
elements: [{ elementId: "e1", name: "value", type: "real" }],
46+
},
47+
],
48+
differentialEquations: [],
49+
parameters: [
50+
{
51+
id: "param1",
52+
name: "Rate",
53+
variableName: "rate",
54+
type: "real",
55+
defaultValue: "1.0",
56+
},
57+
],
58+
};
59+
60+
describe("isSDCPNEqual", () => {
61+
it("returns true for two empty nets", () => {
62+
expect(isSDCPNEqual(emptyNet, { ...emptyNet })).toBe(true);
63+
});
64+
65+
it("returns true for identical nets", () => {
66+
const copy = JSON.parse(JSON.stringify(sampleNet)) as SDCPN;
67+
expect(isSDCPNEqual(sampleNet, copy)).toBe(true);
68+
});
69+
70+
it("returns true for same reference", () => {
71+
expect(isSDCPNEqual(sampleNet, sampleNet)).toBe(true);
72+
});
73+
74+
it("returns false when a place differs", () => {
75+
const modified = JSON.parse(JSON.stringify(sampleNet)) as SDCPN;
76+
modified.places[0]!.name = "Renamed";
77+
expect(isSDCPNEqual(sampleNet, modified)).toBe(false);
78+
});
79+
80+
it("returns false when a transition arc weight differs", () => {
81+
const modified = JSON.parse(JSON.stringify(sampleNet)) as SDCPN;
82+
modified.transitions[0]!.outputArcs[0]!.weight = 99;
83+
expect(isSDCPNEqual(sampleNet, modified)).toBe(false);
84+
});
85+
86+
it("returns false when a parameter is added", () => {
87+
const modified = JSON.parse(JSON.stringify(sampleNet)) as SDCPN;
88+
modified.parameters.push({
89+
id: "param2",
90+
name: "Extra",
91+
variableName: "extra",
92+
type: "integer",
93+
defaultValue: "0",
94+
});
95+
expect(isSDCPNEqual(sampleNet, modified)).toBe(false);
96+
});
97+
98+
it("returns false when a color element differs", () => {
99+
const modified = JSON.parse(JSON.stringify(sampleNet)) as SDCPN;
100+
modified.types[0]!.elements[0]!.type = "boolean";
101+
expect(isSDCPNEqual(sampleNet, modified)).toBe(false);
102+
});
103+
104+
it("returns false when places array has different length", () => {
105+
const modified = JSON.parse(JSON.stringify(sampleNet)) as SDCPN;
106+
modified.places.push({
107+
id: "p2",
108+
name: "Place 2",
109+
colorId: null,
110+
dynamicsEnabled: false,
111+
differentialEquationId: null,
112+
x: 0,
113+
y: 0,
114+
});
115+
expect(isSDCPNEqual(sampleNet, modified)).toBe(false);
116+
});
117+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { SDCPN } from "../core/types/sdcpn";
2+
3+
/**
4+
* Recursively compare two values for structural equality.
5+
*
6+
* Handles primitives, arrays, and plain objects. Does not handle
7+
* special types like Date, RegExp, Map, Set, etc. — those are not
8+
* used in SDCPN definitions.
9+
*/
10+
const deepEqual = (a: unknown, b: unknown): boolean => {
11+
// Same reference or identical primitive
12+
if (a === b) {
13+
return true;
14+
}
15+
16+
// Different types can never be equal
17+
if (typeof a !== typeof b) {
18+
return false;
19+
}
20+
21+
// One is null but not the other (both-null is caught by `a === b` above)
22+
if (a === null || b === null) {
23+
return false;
24+
}
25+
26+
// Compare arrays element-by-element
27+
if (Array.isArray(a)) {
28+
if (!Array.isArray(b) || a.length !== b.length) {
29+
return false;
30+
}
31+
for (let i = 0; i < a.length; i++) {
32+
if (!deepEqual(a[i], b[i])) {
33+
return false;
34+
}
35+
}
36+
return true;
37+
}
38+
39+
// Compare plain objects by own properties
40+
if (typeof a === "object") {
41+
const objA = a as Record<string, unknown>;
42+
const objB = b as Record<string, unknown>;
43+
44+
const propsA = Object.getOwnPropertyNames(objA);
45+
const propsB = Object.getOwnPropertyNames(objB);
46+
47+
// Different number of properties means not equal
48+
if (propsA.length !== propsB.length) {
49+
return false;
50+
}
51+
52+
// Every property in `a` must exist in `b` with the same value
53+
for (const prop of propsA) {
54+
if (
55+
!Object.prototype.hasOwnProperty.call(objB, prop) ||
56+
!deepEqual(objA[prop], objB[prop])
57+
) {
58+
return false;
59+
}
60+
}
61+
62+
return true;
63+
}
64+
65+
return false;
66+
};
67+
68+
/**
69+
* Check if two SDCPN definitions are structurally identical.
70+
*
71+
* Performs a recursive deep comparison of all fields, so that
72+
* additions to the SDCPN type are automatically covered.
73+
*/
74+
export const isSDCPNEqual = (a: SDCPN, b: SDCPN): boolean => deepEqual(a, b);

libs/@hashintel/petrinaut/src/petrinaut.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { EditorProvider } from "./state/editor-provider";
2020
import { SDCPNProvider } from "./state/sdcpn-provider";
2121
import { EditorView } from "./views/Editor/editor-view";
2222

23+
export { isSDCPNEqual } from "./lib/deep-equal";
24+
2325
export type {
2426
Color,
2527
DifferentialEquation,

0 commit comments

Comments
 (0)