diff --git a/foundation/utils.spec.ts b/foundation/utils.spec.ts index 638051d..95e2ede 100644 --- a/foundation/utils.spec.ts +++ b/foundation/utils.spec.ts @@ -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', () => { @@ -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( + ` + + + + + + + + + + + `, + '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( + ` + + + + + + + + `, + '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( + ` + + + + + + + `, + 'application/xml' + ); + const candidates = new Set(['DOType_A']); + const orphaned = getOrphanedTypes(doc, candidates); + + expect(orphaned).to.have.lengthOf(0); + }); + }); }); diff --git a/foundation/utils.ts b/foundation/utils.ts index 1c01287..dd1c03e 100644 --- a/foundation/utils.ts +++ b/foundation/utils.ts @@ -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 +): 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. diff --git a/scl-template-update.spec.ts b/scl-template-update.spec.ts index e77848f..92dfd8d 100644 --- a/scl-template-update.spec.ts +++ b/scl-template-update.spec.ts @@ -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); @@ -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'); @@ -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); @@ -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(); @@ -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 () => { diff --git a/scl-template-update.ts b/scl-template-update.ts index 808e278..6e4aaf1 100644 --- a/scl-template-update.ts +++ b/scl-template-update.ts @@ -44,6 +44,7 @@ import { isLNodeTypeReferenced, filterSelection, removeDOsNotInSelection, + getOrphanedTypes, } from './foundation/utils.js'; export default class NsdTemplateUpdated extends ScopedElementsMixin( @@ -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, @@ -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'); @@ -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, @@ -349,7 +368,7 @@ export default class NsdTemplateUpdated extends ScopedElementsMixin( node: newLNodeType, reference: lNodeTypeInsert.reference, }, - ...removeOld, + { node: currentLNodeType }, ]; }