Skip to content

Commit ef29cf4

Browse files
committed
fix: change update to avoid data loss
1 parent eaf131b commit ef29cf4

4 files changed

Lines changed: 175 additions & 9 deletions

File tree

foundation/utils.spec.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable no-unused-expressions */
22
import { expect } from '@open-wc/testing';
3-
import { removeDOsNotInSelection } from './utils.js';
3+
import { removeDOsNotInSelection, getOrphanedTypes } from './utils.js';
44

55
describe('foundation/utils', () => {
66
describe('removeDOsNotInSelection', () => {
@@ -85,4 +85,75 @@ describe('foundation/utils', () => {
8585
expect(result.tagName).to.equal('LNodeType');
8686
});
8787
});
88+
89+
describe('getOrphanedTypes', () => {
90+
it('returns candidates that are unreferenced in the document', () => {
91+
// Post-edit doc: LNodeType only has Beh — A and its chain are orphaned
92+
const doc = new DOMParser().parseFromString(
93+
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
94+
<DataTypeTemplates>
95+
<LNodeType id="TestLN" lnClass="MMXU">
96+
<DO name="Beh" type="DOType_Beh"/>
97+
</LNodeType>
98+
<DOType id="DOType_Beh" cdc="ENS"/>
99+
<DOType id="DOType_A" cdc="WYE">
100+
<SDO name="sub" type="DOType_Sub"/>
101+
</DOType>
102+
<DOType id="DOType_Sub" cdc="CMV"/>
103+
</DataTypeTemplates>
104+
</SCL>`,
105+
'application/xml'
106+
);
107+
const candidates = new Set(['DOType_A', 'DOType_Sub', 'DOType_Beh']);
108+
const orphaned = getOrphanedTypes(doc, candidates);
109+
const orphanedIds = orphaned.map(el => el.getAttribute('id'));
110+
111+
// DOType_A is unreferenced → orphaned
112+
expect(orphanedIds).to.include('DOType_A');
113+
// DOType_Sub was only referenced by DOType_A → cascade orphan
114+
expect(orphanedIds).to.include('DOType_Sub');
115+
// DOType_Beh is still referenced by the LNodeType → NOT orphaned
116+
expect(orphanedIds).to.not.include('DOType_Beh');
117+
});
118+
119+
it('does not return types still referenced by other LNodeTypes', () => {
120+
// Two LNodeTypes share DOType_Shared; one is removed but the other remains
121+
const doc = new DOMParser().parseFromString(
122+
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
123+
<DataTypeTemplates>
124+
<LNodeType id="LN_Kept" lnClass="MMXU">
125+
<DO name="X" type="DOType_Shared"/>
126+
</LNodeType>
127+
<DOType id="DOType_Removed" cdc="ENS"/>
128+
<DOType id="DOType_Shared" cdc="SPS"/>
129+
</DataTypeTemplates>
130+
</SCL>`,
131+
'application/xml'
132+
);
133+
const candidates = new Set(['DOType_Removed', 'DOType_Shared']);
134+
const orphaned = getOrphanedTypes(doc, candidates);
135+
const orphanedIds = orphaned.map(el => el.getAttribute('id'));
136+
137+
expect(orphanedIds).to.include('DOType_Removed');
138+
expect(orphanedIds).to.not.include('DOType_Shared');
139+
});
140+
141+
it('returns empty array when all candidates are still referenced', () => {
142+
const doc = new DOMParser().parseFromString(
143+
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
144+
<DataTypeTemplates>
145+
<LNodeType id="TestLN" lnClass="MMXU">
146+
<DO name="A" type="DOType_A"/>
147+
</LNodeType>
148+
<DOType id="DOType_A" cdc="WYE"/>
149+
</DataTypeTemplates>
150+
</SCL>`,
151+
'application/xml'
152+
);
153+
const candidates = new Set(['DOType_A']);
154+
const orphaned = getOrphanedTypes(doc, candidates);
155+
156+
expect(orphaned).to.have.lengthOf(0);
157+
});
158+
});
88159
});

foundation/utils.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,44 @@ export function filterSelection(
4040
return filteredTree;
4141
}
4242

43+
/**
44+
* Finds and returns elements from candidateIds that are no longer referenced in
45+
* the given document after edits, including those affected by cascading removals.
46+
* Always provide all type IDs that existed before the edit as candidateIds, so that
47+
* any new types added during the edit are not mistakenly removed.
48+
*/
49+
export function getOrphanedTypes(
50+
doc: XMLDocument,
51+
candidateIds: Set<string>
52+
): Element[] {
53+
const dtt = doc.querySelector(':root > DataTypeTemplates');
54+
if (!dtt) return [];
55+
56+
const dttClone = dtt.cloneNode(true) as Element;
57+
const toRemove: string[] = [];
58+
59+
let changed = true;
60+
while (changed) {
61+
changed = false;
62+
for (const id of candidateIds) {
63+
if (!toRemove.includes(id)) {
64+
if (!dttClone.querySelector(`:scope *[type="${id}"]`)) {
65+
const el = dttClone.querySelector(`:scope > *[id="${id}"]`);
66+
if (el) {
67+
toRemove.push(id);
68+
el.remove();
69+
changed = true;
70+
}
71+
}
72+
}
73+
}
74+
}
75+
76+
return toRemove
77+
.map(id => dtt.querySelector(`:scope > *[id="${id}"]`))
78+
.filter((el): el is Element => el !== null);
79+
}
80+
4381
/**
4482
* Creates a clone of an LNodeType with only the DOs that are present in the selection.
4583
* DOs not included in the selection are removed from the cloned element.

scl-template-update.spec.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ describe('NsdTemplateUpdater', () => {
8888
(element.shadowRoot?.querySelector('md-fab') as HTMLElement).click();
8989
await element.updateComplete;
9090

91-
expect(listener).to.have.been.calledOnce;
91+
expect(listener).to.have.been.called;
9292
const updateEdits = listener.args[0][0].detail.edit;
9393
expect(updateEdits).to.have.length.greaterThan(0);
9494

@@ -97,6 +97,38 @@ describe('NsdTemplateUpdater', () => {
9797
);
9898
}).timeout(5000);
9999

100+
it('preserves DOTypes still referenced by the updated LNodeType', async () => {
101+
localStorage.removeItem('template-update-setting');
102+
103+
const event = {
104+
detail: { id: 'MMXU$oscd$_c53e78191fabefa3' },
105+
} as CustomEvent;
106+
element.onLNodeTypeSelect(event);
107+
await new Promise(res => {
108+
setTimeout(res, 0);
109+
});
110+
111+
element.treeUI.selection = mmxuSelection;
112+
await element.updateComplete;
113+
114+
(element.shadowRoot?.querySelector('md-fab') as HTMLElement).click();
115+
await element.updateComplete;
116+
117+
// The updated LNodeType must still carry DO[name="A"] and its referenced
118+
// DOType must still exist — i.e. the document must be schema-valid.
119+
const updatedLNodeType = element.doc?.querySelector(
120+
'LNodeType[id="MMXU$oscd$_c53e78191fabefa3"]'
121+
);
122+
expect(updatedLNodeType).to.exist;
123+
expect(updatedLNodeType?.querySelector('DO[name="A"]')).to.exist;
124+
125+
const aDoTypeId = updatedLNodeType
126+
?.querySelector('DO[name="A"]')
127+
?.getAttribute('type');
128+
expect(aDoTypeId).to.exist;
129+
expect(element.doc?.querySelector(`DOType[id="${aDoTypeId}"]`)).to.exist;
130+
}).timeout(5000);
131+
100132
it('swaps MMXU when swap mode is configured', async () => {
101133
localStorage.setItem('template-update-setting', 'swap');
102134

@@ -162,7 +194,7 @@ describe('NsdTemplateUpdater', () => {
162194
setTimeout(res, 200);
163195
});
164196

165-
expect(listener).to.have.been.calledOnce;
197+
expect(listener).to.have.been.called;
166198
const updateEdits = listener.args[0][0].detail.edit;
167199
expect(updateEdits).to.have.length.greaterThan(0);
168200

@@ -304,7 +336,7 @@ describe('NsdTemplateUpdater', () => {
304336
// First update
305337
(element.shadowRoot?.querySelector('md-fab') as HTMLElement).click();
306338
await element.updateComplete;
307-
expect(listener).to.have.been.calledOnce;
339+
expect(listener).to.have.been.called;
308340

309341
// Second update with same selection should not delete the LNodeType
310342
listener.resetHistory();
@@ -365,6 +397,12 @@ describe('NsdTemplateUpdater', () => {
365397
// Verify 'Beh' DO still exists
366398
const doBeh = lNodeType?.querySelector('DO[name="Beh"]');
367399
expect(doBeh).to.exist;
400+
401+
// Verify the DOType that was exclusively used by the removed DO A is
402+
// cleaned up as an orphan (targeted post-edit cleanup).
403+
expect(
404+
element.doc?.querySelector('DOType[id="A$oscd$_41824603f63b26ac"]')
405+
).to.not.exist;
368406
}).timeout(5000);
369407

370408
it('updates description when making selection changes', async () => {

scl-template-update.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
isLNodeTypeReferenced,
4545
filterSelection,
4646
removeDOsNotInSelection,
47+
getOrphanedTypes,
4748
} from './foundation/utils.js';
4849

4950
export default class NsdTemplateUpdated extends ScopedElementsMixin(
@@ -255,6 +256,15 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
255256
});
256257

257258
if (updateSetting === UpdateSetting.Update) {
259+
const oldCandidateIds = new Set(
260+
Array.from(
261+
this.doc.querySelectorAll(
262+
':root > DataTypeTemplates > DOType, :root > DataTypeTemplates > DAType, :root > DataTypeTemplates > EnumType'
263+
)
264+
)
265+
.map(el => el.getAttribute('id')!)
266+
.filter(Boolean)
267+
);
258268
const allEdits = this.buildUpdateEdits(
259269
inserts,
260270
currentLNodeType,
@@ -268,6 +278,19 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
268278
title: `Update ${lnID}`,
269279
})
270280
);
281+
282+
const orphaned = getOrphanedTypes(this.doc, oldCandidateIds);
283+
if (orphaned.length > 0) {
284+
this.dispatchEvent(
285+
newEditEvent(
286+
orphaned.map(node => ({ node })),
287+
{
288+
squash: true,
289+
title: `Update ${lnID}`,
290+
}
291+
)
292+
);
293+
}
271294
}
272295

273296
this.showSuccessFeedback(lnID, 'update');
@@ -337,10 +360,6 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
337360
const supportingTypes = inserts.filter(
338361
insert => insert !== lNodeTypeInsert
339362
);
340-
const removeOld = removeDataType(
341-
{ node: currentLNodeType },
342-
{ force: true }
343-
);
344363

345364
return [
346365
...supportingTypes,
@@ -349,7 +368,7 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
349368
node: newLNodeType,
350369
reference: lNodeTypeInsert.reference,
351370
},
352-
...removeOld,
371+
{ node: currentLNodeType },
353372
];
354373
}
355374

0 commit comments

Comments
 (0)