Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion foundation/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable no-unused-expressions */
import { expect } from '@open-wc/testing';
import { removeDOsNotInSelection } from './utils.js';
import { removeDOsNotInSelection, getOrphanedTypes } from './utils.js';

describe('foundation/utils', () => {
describe('removeDOsNotInSelection', () => {
Expand Down Expand Up @@ -85,4 +85,75 @@ describe('foundation/utils', () => {
expect(result.tagName).to.equal('LNodeType');
});
});

describe('getOrphanedTypes', () => {
it('returns candidates that are unreferenced in the document', () => {
// Post-edit doc: LNodeType only has Beh — A and its chain are orphaned
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
<DataTypeTemplates>
<LNodeType id="TestLN" lnClass="MMXU">
<DO name="Beh" type="DOType_Beh"/>
</LNodeType>
<DOType id="DOType_Beh" cdc="ENS"/>
<DOType id="DOType_A" cdc="WYE">
<SDO name="sub" type="DOType_Sub"/>
</DOType>
<DOType id="DOType_Sub" cdc="CMV"/>
</DataTypeTemplates>
</SCL>`,
'application/xml'
);
const candidates = new Set(['DOType_A', 'DOType_Sub', 'DOType_Beh']);
const orphaned = getOrphanedTypes(doc, candidates);
const orphanedIds = orphaned.map(el => el.getAttribute('id'));

// DOType_A is unreferenced → orphaned
expect(orphanedIds).to.include('DOType_A');
// DOType_Sub was only referenced by DOType_A → cascade orphan
expect(orphanedIds).to.include('DOType_Sub');
// DOType_Beh is still referenced by the LNodeType → NOT orphaned
expect(orphanedIds).to.not.include('DOType_Beh');
});

it('does not return types still referenced by other LNodeTypes', () => {
// Two LNodeTypes share DOType_Shared; one is removed but the other remains
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
<DataTypeTemplates>
<LNodeType id="LN_Kept" lnClass="MMXU">
<DO name="X" type="DOType_Shared"/>
</LNodeType>
<DOType id="DOType_Removed" cdc="ENS"/>
<DOType id="DOType_Shared" cdc="SPS"/>
</DataTypeTemplates>
</SCL>`,
'application/xml'
);
const candidates = new Set(['DOType_Removed', 'DOType_Shared']);
const orphaned = getOrphanedTypes(doc, candidates);
const orphanedIds = orphaned.map(el => el.getAttribute('id'));

expect(orphanedIds).to.include('DOType_Removed');
expect(orphanedIds).to.not.include('DOType_Shared');
});

it('returns empty array when all candidates are still referenced', () => {
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL">
<DataTypeTemplates>
<LNodeType id="TestLN" lnClass="MMXU">
<DO name="A" type="DOType_A"/>
</LNodeType>
<DOType id="DOType_A" cdc="WYE"/>
</DataTypeTemplates>
</SCL>`,
'application/xml'
);
const candidates = new Set(['DOType_A']);
const orphaned = getOrphanedTypes(doc, candidates);

expect(orphaned).to.have.lengthOf(0);
});
});
});
38 changes: 38 additions & 0 deletions foundation/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,44 @@ export function filterSelection(
return filteredTree;
}

/**
* Finds and returns elements from candidateIds that are no longer referenced in
* the given document after edits, including those affected by cascading removals.
* Always provide all type IDs that existed before the edit as candidateIds, so that
* any new types added during the edit are not mistakenly removed.
*/
export function getOrphanedTypes(
doc: XMLDocument,
candidateIds: Set<string>
): Element[] {
const dtt = doc.querySelector(':root > DataTypeTemplates');
if (!dtt) return [];

const dttClone = dtt.cloneNode(true) as Element;
const toRemove: string[] = [];

let changed = true;
while (changed) {
changed = false;
for (const id of candidateIds) {
if (!toRemove.includes(id)) {
if (!dttClone.querySelector(`:scope *[type="${id}"]`)) {
const el = dttClone.querySelector(`:scope > *[id="${id}"]`);
if (el) {
toRemove.push(id);
el.remove();
changed = true;
}
}
}
}
}

return toRemove
.map(id => dtt.querySelector(`:scope > *[id="${id}"]`))
.filter((el): el is Element => el !== null);
}

/**
* Creates a clone of an LNodeType with only the DOs that are present in the selection.
* DOs not included in the selection are removed from the cloned element.
Expand Down
44 changes: 41 additions & 3 deletions scl-template-update.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe('NsdTemplateUpdater', () => {
(element.shadowRoot?.querySelector('md-fab') as HTMLElement).click();
await element.updateComplete;

expect(listener).to.have.been.calledOnce;
expect(listener).to.have.been.called;
const updateEdits = listener.args[0][0].detail.edit;
expect(updateEdits).to.have.length.greaterThan(0);

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

it('preserves DOTypes still referenced by the updated LNodeType', async () => {
localStorage.removeItem('template-update-setting');

const event = {
detail: { id: 'MMXU$oscd$_c53e78191fabefa3' },
} as CustomEvent;
element.onLNodeTypeSelect(event);
await new Promise(res => {
setTimeout(res, 0);
});

element.treeUI.selection = mmxuSelection;
await element.updateComplete;

(element.shadowRoot?.querySelector('md-fab') as HTMLElement).click();
await element.updateComplete;

// The updated LNodeType must still carry DO[name="A"] and its referenced
// DOType must still exist — i.e. the document must be schema-valid.
const updatedLNodeType = element.doc?.querySelector(
'LNodeType[id="MMXU$oscd$_c53e78191fabefa3"]'
);
expect(updatedLNodeType).to.exist;
expect(updatedLNodeType?.querySelector('DO[name="A"]')).to.exist;

const aDoTypeId = updatedLNodeType
?.querySelector('DO[name="A"]')
?.getAttribute('type');
expect(aDoTypeId).to.exist;
expect(element.doc?.querySelector(`DOType[id="${aDoTypeId}"]`)).to.exist;
}).timeout(5000);

it('swaps MMXU when swap mode is configured', async () => {
localStorage.setItem('template-update-setting', 'swap');

Expand Down Expand Up @@ -162,7 +194,7 @@ describe('NsdTemplateUpdater', () => {
setTimeout(res, 200);
});

expect(listener).to.have.been.calledOnce;
expect(listener).to.have.been.called;
const updateEdits = listener.args[0][0].detail.edit;
expect(updateEdits).to.have.length.greaterThan(0);

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

// Second update with same selection should not delete the LNodeType
listener.resetHistory();
Expand Down Expand Up @@ -365,6 +397,12 @@ describe('NsdTemplateUpdater', () => {
// Verify 'Beh' DO still exists
const doBeh = lNodeType?.querySelector('DO[name="Beh"]');
expect(doBeh).to.exist;

// Verify the DOType that was exclusively used by the removed DO A is
// cleaned up as an orphan (targeted post-edit cleanup).
expect(
element.doc?.querySelector('DOType[id="A$oscd$_41824603f63b26ac"]')
).to.not.exist;
}).timeout(5000);

it('updates description when making selection changes', async () => {
Expand Down
29 changes: 24 additions & 5 deletions scl-template-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
isLNodeTypeReferenced,
filterSelection,
removeDOsNotInSelection,
getOrphanedTypes,
} from './foundation/utils.js';

export default class NsdTemplateUpdated extends ScopedElementsMixin(
Expand Down Expand Up @@ -255,6 +256,15 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
});

if (updateSetting === UpdateSetting.Update) {
const oldCandidateIds = new Set(
Array.from(
this.doc.querySelectorAll(
':root > DataTypeTemplates > DOType, :root > DataTypeTemplates > DAType, :root > DataTypeTemplates > EnumType'
)
)
.map(el => el.getAttribute('id')!)
.filter(Boolean)
);
const allEdits = this.buildUpdateEdits(
inserts,
currentLNodeType,
Expand All @@ -268,6 +278,19 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
title: `Update ${lnID}`,
})
);

const orphaned = getOrphanedTypes(this.doc, oldCandidateIds);
if (orphaned.length > 0) {
this.dispatchEvent(
newEditEvent(
orphaned.map(node => ({ node })),
{
squash: true,
title: `Update ${lnID}`,
}
)
);
}
}

this.showSuccessFeedback(lnID, 'update');
Expand Down Expand Up @@ -337,10 +360,6 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
const supportingTypes = inserts.filter(
insert => insert !== lNodeTypeInsert
);
const removeOld = removeDataType(
{ node: currentLNodeType },
{ force: true }
);

return [
...supportingTypes,
Expand All @@ -349,7 +368,7 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin(
node: newLNodeType,
reference: lNodeTypeInsert.reference,
},
...removeOld,
{ node: currentLNodeType },
];
}

Expand Down
Loading