From 1cc6d693c1201ad5f9af0b5354c97d5aa05b436a Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Mon, 1 Sep 2025 15:43:18 +0200 Subject: [PATCH] feat(tDataTypeTemplates): updateLNodeType --- index.ts | 9 +- tDataTypeTemplates/foundation.ts | 142 +++ tDataTypeTemplates/importLNodeType.spec.ts | 36 +- .../importLNodeType.testfiles.ts | 42 + tDataTypeTemplates/importLNodeType.ts | 24 +- tDataTypeTemplates/insertSelectedLNodeType.ts | 139 +-- tDataTypeTemplates/updateLNodeType.spec.ts | 65 ++ .../updateLNodeType.testfiles.ts | 207 +++++ tDataTypeTemplates/updateLNodeType.ts | 31 + tSubstation/updateLnType.spec.ts | 365 ++++++++ tSubstation/updateLnType.testfiles.ts | 843 ++++++++++++++++++ tSubstation/updateLnType.ts | 126 +++ 12 files changed, 1880 insertions(+), 149 deletions(-) create mode 100644 tDataTypeTemplates/foundation.ts create mode 100644 tDataTypeTemplates/updateLNodeType.spec.ts create mode 100644 tDataTypeTemplates/updateLNodeType.testfiles.ts create mode 100644 tDataTypeTemplates/updateLNodeType.ts create mode 100644 tSubstation/updateLnType.spec.ts create mode 100644 tSubstation/updateLnType.testfiles.ts create mode 100644 tSubstation/updateLnType.ts diff --git a/index.ts b/index.ts index cc78dc8..1594814 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ export { updateBay } from "./tBay/updateBay.js"; export { updateVoltageLevel } from "./tVoltageLevel/updateVoltageLevel.js"; export { updateSubstation } from "./tSubstation/updateSubstation.js"; export { removeProcessElement } from "./tSubstation/removeProcessElement.js"; +export { updateLnType } from "./tSubstation/updateLnType.js"; export { InsertIedOptions, insertIed } from "./tIED/insertIED.js"; export { updateIED } from "./tIED/updateIED.js"; @@ -71,8 +72,7 @@ export { export { sourceControlBlock } from "./tExtRef/sourceControlBlock.js"; export { isSubscribed } from "./tExtRef/isSubscribed.js"; -export { importLNodeType } from "./tDataTypeTemplates/importLNodeType.js"; -export { lNodeTypeToSelection } from "./tDataTypeTemplates/lNodeTypeToSelection.js"; + export { LNodeDescription, @@ -82,7 +82,10 @@ export { export { insertSelectedLNodeType } from "./tDataTypeTemplates/insertSelectedLNodeType.js"; -export {removeDataType, RemoveDataTypeOptions} from "./tDataTypeTemplates/removeDataType.js" +export { removeDataType, RemoveDataTypeOptions } from "./tDataTypeTemplates/removeDataType.js" +export { importLNodeType } from "./tDataTypeTemplates/importLNodeType.js"; +export { updateLNodeType } from "./tDataTypeTemplates/updateLNodeType.js"; +export { lNodeTypeToSelection } from "./tDataTypeTemplates/lNodeTypeToSelection.js"; export { Supervision, diff --git a/tDataTypeTemplates/foundation.ts b/tDataTypeTemplates/foundation.ts new file mode 100644 index 0000000..aff274c --- /dev/null +++ b/tDataTypeTemplates/foundation.ts @@ -0,0 +1,142 @@ + + +function describeEnumType(element: Element): { vals: Record } { + const vals: Record = {}; + + const sortedEnumVals = Array.from(element.children) + .filter((child) => child.tagName === "EnumVal") + .sort( + (v1, v2) => + parseInt(v1.getAttribute("ord")!, 10) - + parseInt(v2.getAttribute("ord")!, 10), + ); + for (const val of sortedEnumVals) + vals[val.getAttribute("ord")!] = val.textContent ?? ""; + + return { vals }; +} + +function describeDAType(element: Element): { + bdas: Record>; +} { + const bdas: Record> = {}; + for (const bda of Array.from(element.children) + .filter((child) => child.tagName === "BDA") + .sort((c1, c2) => c1.outerHTML.localeCompare(c2.outerHTML))) { + const [bType, type, dchg, dupd, qchg] = [ + "bType", + "type", + "dchg", + "dupd", + "qchg", + ].map((attr) => bda.getAttribute(attr)); + bdas[bda.getAttribute("name")!] = { bType, type, dchg, dupd, qchg }; + } + return { bdas }; +} + +function describeDOType(element: Element) { + const sdos: Record> = {}; + for (const sdo of Array.from(element.children) + .filter((child) => child.tagName === "SDO") + .sort((c1, c2) => c1.outerHTML.localeCompare(c2.outerHTML))) { + const [name, type, transient] = ["name", "type", "transient"].map((attr) => + sdo.getAttribute(attr), + ); + sdos[name!] = { type, transient }; + } + const das: Record> = {}; + for (const da of Array.from(element.children) + .filter((child) => child.tagName === "DA") + .sort((c1, c2) => c1.outerHTML.localeCompare(c2.outerHTML))) { + const [name, fc, bType, type, dchg, dupd, qchg] = [ + "name", + "fc", + "bType", + "type", + "dchg", + "dupd", + "qchg", + ].map((attr) => da.getAttribute(attr)); + das[name!] = { + fc, + bType, + type, + dchg, + dupd, + qchg, + }; + } + return { + sdos, + das, + cdc: element.getAttribute("cdc"), + }; +} + +function describeLNodeType(element: Element) { + const dos: Record> = {}; + for (const doElement of Array.from(element.children) + .filter((child) => child.tagName === "DO") + .sort((c1, c2) => c1.outerHTML.localeCompare(c2.outerHTML))) { + const [name, type, transient] = ["name", "type", "transient"].map((attr) => + doElement.getAttribute(attr), + ); + dos[name!] = { type, transient }; + } + return { + dos, + lnClass: element.getAttribute("lnClass"), + }; +} + +const typeDescriptions = { + EnumType: describeEnumType, + DAType: describeDAType, + DOType: describeDOType, + LNodeType: describeLNodeType, +} as Partial object>>; + +function describeElement(element: Element): object { + const describe = typeDescriptions[element.tagName]!; + + return describe(element); +} + +export function hashElement(element: Element): string { + /** A direct copy from www.github.com/openscd/open-scd-core/foundation/cyrb64.ts */ + + /** + * Hashes `str` using the cyrb64 variant of + * https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js + * @returns digest - a rather insecure hash, very quickly + */ + function cyrb64(str: string): string { + /* eslint-disable no-bitwise */ + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + /* eslint-disable-next-line no-plusplus */ + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ + Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = + Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ + Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return ( + (h2 >>> 0).toString(16).padStart(8, "0") + + (h1 >>> 0).toString(16).padStart(8, "0") + ); + /* eslint-enable no-bitwise */ + } + + return cyrb64(JSON.stringify(describeElement(element))); +} + +export function isEqualNode(ours: Element, theirs: Element): boolean { + return JSON.stringify(describeElement(ours)) === JSON.stringify(describeElement(theirs)); +} \ No newline at end of file diff --git a/tDataTypeTemplates/importLNodeType.spec.ts b/tDataTypeTemplates/importLNodeType.spec.ts index 288bb05..5cc86cd 100644 --- a/tDataTypeTemplates/importLNodeType.spec.ts +++ b/tDataTypeTemplates/importLNodeType.spec.ts @@ -1,11 +1,14 @@ import { expect } from "chai"; +import { Insert, isInsert, isRemove } from "../foundation/utils.js"; + import { importLNodeType } from "./importLNodeType.js"; import { baseDataTypes, emptyBayTemplate, competeBayTemplate, invalidBayTemplate, + hardUpdate, } from "./importLNodeType.testfiles.js"; import { findElement } from "../foundation/helpers.test.js"; @@ -21,6 +24,10 @@ const mmxuLNodeType = findElement( baseDataTypes, 'LNodeType[id="Dummy.MMXU"]' ) as Element; +const tctrHardUpdate = findElement( + hardUpdate, + 'LNodeType[id="Dummy.TCTR"]' +) as Element; describe("Function to import LNodeType with its sub data", () => { it("is returning an empty string on invalid SCL files", () => { @@ -30,14 +37,14 @@ describe("Function to import LNodeType with its sub data", () => { }); it("is inserting the LNodeType element itself when missing", () => { - const edits = importLNodeType(tctrLNodeType, emptyTemplate); + const edits = importLNodeType(tctrLNodeType, emptyTemplate) as Insert[]; expect(edits.length).to.equal(6); expect((edits[1].node as Element).tagName).to.equal(tctrLNodeType.tagName); }); it("is inserting DataTypeTemplate element when missing", () => { - const edits = importLNodeType(tctrLNodeType, emptyTemplate); + const edits = importLNodeType(tctrLNodeType, emptyTemplate) as Insert[]; expect(edits.length).to.equal(6); expect((edits[0].node as Element).tagName).to.equal("DataTypeTemplates"); @@ -50,14 +57,35 @@ describe("Function to import LNodeType with its sub data", () => { }); it("is checking for duplicate data types", () => { - const edits = importLNodeType(tctrLNodeType, completeTemplate); + const edits = importLNodeType(tctrLNodeType, completeTemplate) as Insert[]; expect(edits.length).to.equal(0); }); it("does not cut out data type from the base project", () => { - const edits = importLNodeType(mmxuLNodeType, emptyTemplate); + const edits = importLNodeType(mmxuLNodeType, emptyTemplate) as Insert[]; edits.forEach((edit) => expect(edit.node.isConnected).to.be.false); }); + + it("insert when not duplicate", () => { + const edits = importLNodeType(tctrHardUpdate, completeTemplate) as Insert[]; + + expect(edits.length).to.equal(1); + }); + + it("allows to overwrite existing LNodeType", () => { + const edits1 = importLNodeType(tctrHardUpdate, completeTemplate, { overwrite: true }) as Insert[]; + + expect(edits1.length).to.equal(2); + + expect(edits1[0]).to.satisfies(isInsert); + expect(edits1[1]).to.satisfies(isRemove); + + const edits2 = importLNodeType(tctrHardUpdate, completeTemplate, { overwrite: false }) as Insert[]; + + expect(edits2.length).to.equal(1); + + expect(edits2[0]).to.satisfies(isInsert); + }); }); diff --git a/tDataTypeTemplates/importLNodeType.testfiles.ts b/tDataTypeTemplates/importLNodeType.testfiles.ts index 43cdf6e..23f4b7b 100644 --- a/tDataTypeTemplates/importLNodeType.testfiles.ts +++ b/tDataTypeTemplates/importLNodeType.testfiles.ts @@ -33,6 +33,9 @@ export const competeBayTemplate = ` + + + on blocked @@ -44,6 +47,45 @@ export const competeBayTemplate = ``; export const invalidBayTemplate = ``; +export const hardUpdate = ` +
+ + + 110 + + + + + + + + + + + + + + + + + + + + + + + + + + on + blocked + test + test/blocked + off + + +`; + export const baseDataTypes = `
diff --git a/tDataTypeTemplates/importLNodeType.ts b/tDataTypeTemplates/importLNodeType.ts index 2d8caa8..f7b787e 100644 --- a/tDataTypeTemplates/importLNodeType.ts +++ b/tDataTypeTemplates/importLNodeType.ts @@ -1,6 +1,11 @@ -import { Insert, createElement } from "../foundation/utils.js"; +import { Edit, Insert, createElement } from "../foundation/utils.js"; import { getReference } from "../tBaseElement/getReference.js"; +import { isEqualNode } from "./foundation.js"; + +type ImportLNodeTypeOptions = { + overwrite?: boolean; +} function removeDuplicates(inserts: Insert[]): Insert[] { const uniqueInserts: Insert[] = []; @@ -25,7 +30,7 @@ function insertDataType( const existingDataType = targetDataTypeTemplate.querySelector( `${dataType.tagName}[id="${dataType.getAttribute("id")}"] ` ); - if (existingDataType && dataType.isEqualNode(existingDataType)) return; + if (existingDataType && isEqualNode(dataType, existingDataType)) return; const node = dataType.cloneNode(true); // const node = dataType; @@ -112,8 +117,9 @@ function getDoTypes(parent: Element): Element[] { */ export function importLNodeType( lNodeType: Element, - targetDoc: XMLDocument -): Insert[] { + targetDoc: XMLDocument, + option: ImportLNodeTypeOptions = {} +): Edit[] { const doc = lNodeType.ownerDocument; const targetScl = targetDoc.querySelector("SCL"); @@ -134,8 +140,16 @@ export function importLNodeType( .filter((enumType) => !!enumType) as Element[] ); - return insertDataTypes( + const inserts = insertDataTypes( [lNodeType, ...doTypes, ...daTypes, ...enumTypes], targetScl ); + if (option.overwrite === undefined || option.overwrite === false) return inserts; + + const duplicatedLNodeType = targetScl.querySelector( + `:root > DataTypeTemplates > LNodeType[id="${lNodeType.getAttribute("id")}"]` + ); + if (!duplicatedLNodeType) return inserts; + + return [...inserts, { node: duplicatedLNodeType }] } diff --git a/tDataTypeTemplates/insertSelectedLNodeType.ts b/tDataTypeTemplates/insertSelectedLNodeType.ts index 24fa892..2a57a0b 100644 --- a/tDataTypeTemplates/insertSelectedLNodeType.ts +++ b/tDataTypeTemplates/insertSelectedLNodeType.ts @@ -8,6 +8,8 @@ import { createElement, Insert, TreeSelection } from "../foundation/utils.js"; import { getReference } from "../tBaseElement/getReference.js"; +import { hashElement } from "./foundation.js"; + type Templates = { EnumType: Element[]; DAType: Element[]; @@ -15,143 +17,6 @@ type Templates = { LNodeType: Element[]; }; -function describeEnumType(element: Element): { vals: Record } { - const vals: Record = {}; - - const sortedEnumVals = Array.from(element.children) - .filter((child) => child.tagName === "EnumVal") - .sort( - (v1, v2) => - parseInt(v1.getAttribute("ord")!, 10) - - parseInt(v2.getAttribute("ord")!, 10), - ); - for (const val of sortedEnumVals) - vals[val.getAttribute("ord")!] = val.textContent ?? ""; - - return { vals }; -} - -function describeDAType(element: Element): { - bdas: Record>; -} { - const bdas: Record> = {}; - for (const bda of Array.from(element.children) - .filter((child) => child.tagName === "BDA") - .sort((c1, c2) => c1.outerHTML.localeCompare(c2.outerHTML))) { - const [bType, type, dchg, dupd, qchg] = [ - "bType", - "type", - "dchg", - "dupd", - "qchg", - ].map((attr) => bda.getAttribute(attr)); - bdas[bda.getAttribute("name")!] = { bType, type, dchg, dupd, qchg }; - } - return { bdas }; -} - -function describeDOType(element: Element) { - const sdos: Record> = {}; - for (const sdo of Array.from(element.children) - .filter((child) => child.tagName === "SDO") - .sort((c1, c2) => c1.outerHTML.localeCompare(c2.outerHTML))) { - const [name, type, transient] = ["name", "type", "transient"].map((attr) => - sdo.getAttribute(attr), - ); - sdos[name!] = { type, transient }; - } - const das: Record> = {}; - for (const da of Array.from(element.children) - .filter((child) => child.tagName === "DA") - .sort((c1, c2) => c1.outerHTML.localeCompare(c2.outerHTML))) { - const [name, fc, bType, type, dchg, dupd, qchg] = [ - "name", - "fc", - "bType", - "type", - "dchg", - "dupd", - "qchg", - ].map((attr) => da.getAttribute(attr)); - das[name!] = { - fc, - bType, - type, - dchg, - dupd, - qchg, - }; - } - return { - sdos, - das, - cdc: element.getAttribute("cdc"), - }; -} - -function describeLNodeType(element: Element) { - const dos: Record> = {}; - for (const doElement of Array.from(element.children) - .filter((child) => child.tagName === "DO") - .sort((c1, c2) => c1.outerHTML.localeCompare(c2.outerHTML))) { - const [name, type, transient] = ["name", "type", "transient"].map((attr) => - doElement.getAttribute(attr), - ); - dos[name!] = { type, transient }; - } - return { - dos, - lnClass: element.getAttribute("lnClass"), - }; -} - -const typeDescriptions = { - EnumType: describeEnumType, - DAType: describeDAType, - DOType: describeDOType, - LNodeType: describeLNodeType, -} as Partial object>>; - -function describeElement(element: Element): object { - const describe = typeDescriptions[element.tagName]!; - - return describe(element); -} - -function hashElement(element: Element): string { - /** A direct copy from www.github.com/openscd/open-scd-core/foundation/cyrb64.ts */ - - /** - * Hashes `str` using the cyrb64 variant of - * https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js - * @returns digest - a rather insecure hash, very quickly - */ - function cyrb64(str: string): string { - /* eslint-disable no-bitwise */ - let h1 = 0xdeadbeef; - let h2 = 0x41c6ce57; - /* eslint-disable-next-line no-plusplus */ - for (let i = 0, ch; i < str.length; i++) { - ch = str.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = - Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ - Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = - Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ - Math.imul(h1 ^ (h1 >>> 13), 3266489909); - return ( - (h2 >>> 0).toString(16).padStart(8, "0") + - (h1 >>> 0).toString(16).padStart(8, "0") - ); - /* eslint-enable no-bitwise */ - } - - return cyrb64(JSON.stringify(describeElement(element))); -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any function data(lnData: any, path: string[]): any { let d = lnData; diff --git a/tDataTypeTemplates/updateLNodeType.spec.ts b/tDataTypeTemplates/updateLNodeType.spec.ts new file mode 100644 index 0000000..a46739a --- /dev/null +++ b/tDataTypeTemplates/updateLNodeType.spec.ts @@ -0,0 +1,65 @@ +import { expect } from "chai"; + +import { Insert, Remove } from "../foundation/utils.js" + +import { docWithComplexMmxuTarget, newMmxuLNodeTypeWithChanges } from "./updateLNodeType.testfiles.js"; + +import { updateLNodeType } from "./updateLNodeType.js"; + +describe("updateLnType", () => { + it("handles complex MMXU update scenario with multiple changes", () => { + const doc = new DOMParser().parseFromString(docWithComplexMmxuTarget, "application/xml"); + + // Create the new LNodeType with changes (external source) + const newDoc = new DOMParser().parseFromString(newMmxuLNodeTypeWithChanges, + "application/xml" + ); + const newLNodeType = newDoc.querySelector('LNodeType[id="MMXU_1"]')!; + + const edits = updateLNodeType(newLNodeType, doc) as (Insert | Remove)[]; + + // Expected removals: + // 1. DOS name="PhV" (entire DO removed from LNodeType) + // 2. SDS name="ang" in PhPh.phsAB.cVal (ang BDA removed from Vector type) + // 3. SDS name="ang" in PhPh.phsBC.cVal (ang BDA removed from Vector type) + // 4-9. 6 SourceRef elements that reference invalid paths + expect(edits).to.have.lengthOf(15); + + // Check data structure removals + const removedDataElements = edits.filter(r => + (r.node as Element).tagName === "eTr_6-100:DOS" || + (r.node as Element).tagName === "eTr_6-100:SDS" || + (r.node as Element).tagName === "eTr_6-100:DAS" + ); + expect(removedDataElements).to.have.lengthOf(3); + + const removedDataNames = removedDataElements.map(r => (r.node as Element).getAttribute("name")); + expect(removedDataNames).to.include("PhV"); // DOS removed + expect(removedDataNames.filter(name => name === "ang")).to.have.lengthOf(2); // 2 SDS ang removed + + // Check SourceRef removals + const removedSourceRefs = edits.filter(r => (r.node as Element).localName === "SourceRef"); + expect(removedSourceRefs).to.have.lengthOf(6); + + const removedResourceNames = removedSourceRefs.map(r => + (r.node as Element).getAttribute("resourceName") + ); + + // SourceRefs that should be removed due to PhV removal + expect(removedResourceNames).to.include("InvalidPhVRef"); + expect(removedResourceNames).to.include("InvalidPhVRef2"); + expect(removedResourceNames).to.include("CrossInvalidPhVRef"); + + // SourceRefs that should be removed due to ang removal in PhPh + expect(removedResourceNames).to.include("InvalidPhPhAngRef"); + expect(removedResourceNames).to.include("InvalidPhPhAngRef2"); + expect(removedResourceNames).to.include("CrossInvalidPhPhAngRef"); + + // SourceRefs that should NOT be removed (valid paths) + expect(removedResourceNames).to.not.include("ValidAPhsAMag"); + expect(removedResourceNames).to.not.include("ValidAPhsAAng"); + expect(removedResourceNames).to.not.include("ValidARes"); // A.res.cVal.mag.f is valid with new LNodeType + expect(removedResourceNames).to.not.include("ValidPhPhMag"); + expect(removedResourceNames).to.not.include("CrossValidARef"); + }); +}) \ No newline at end of file diff --git a/tDataTypeTemplates/updateLNodeType.testfiles.ts b/tDataTypeTemplates/updateLNodeType.testfiles.ts new file mode 100644 index 0000000..fe78e99 --- /dev/null +++ b/tDataTypeTemplates/updateLNodeType.testfiles.ts @@ -0,0 +1,207 @@ + +export const docWithComplexMmxuTarget = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const newMmxuLNodeTypeWithChanges = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/tDataTypeTemplates/updateLNodeType.ts b/tDataTypeTemplates/updateLNodeType.ts new file mode 100644 index 0000000..1aef998 --- /dev/null +++ b/tDataTypeTemplates/updateLNodeType.ts @@ -0,0 +1,31 @@ +import { Edit } from "../foundation/utils.js"; + +import { importLNodeType } from "./importLNodeType.js"; +import { updateLnType } from "../tSubstation/updateLnType.js"; + + +function findExistingLNodeType(lNodeType: Element, targetDoc: XMLDocument): Element | null { + const targetScl = targetDoc.querySelector("SCL"); + if (!targetScl) return null; + return targetScl.querySelector( + `:root > DataTypeTemplates > LNodeType[id="${lNodeType.getAttribute("id")}"]` + ); +} + +export function updateLNodeType(lNodeType: Element, targetDoc: XMLDocument): Edit[] { + + // Find existing LNodeType in targetDoc + const existingLNodeType = findExistingLNodeType(lNodeType, targetDoc); + if (!existingLNodeType) return []; + + // Import the new one including its children + const inserts = importLNodeType(lNodeType, targetDoc); + + // Remove the existing LNodeType + const removeEdit: Edit = { node: existingLNodeType }; + + // Update the substation section + const removes = updateLnType(lNodeType, targetDoc); + + return [...inserts, removeEdit, ...removes]; +} \ No newline at end of file diff --git a/tSubstation/updateLnType.spec.ts b/tSubstation/updateLnType.spec.ts new file mode 100644 index 0000000..8587e89 --- /dev/null +++ b/tSubstation/updateLnType.spec.ts @@ -0,0 +1,365 @@ +import { expect } from "chai"; + +import { updateLnType } from "./updateLnType.js"; +import { + docWithLNodesAndDataTypes, + docWithMissingDOs, + docWithMissingSDOs, + docWithMissingDAs, + docWithMissingBDAs, + docWithNoLNodes, + docWithDifferentLnType, + docWithoutDataTypeTemplates, + docWithSourceRefs, + docWithMalformedSourceRefs, + docWithComplexNestedStructures, + docWithMixedLNodes, +} from "./updateLnType.testfiles.js"; + +describe("updateLnType", () => { + describe("with valid LNodeType and matching LNode instances", () => { + it("returns empty array when all DOS, SDS, and DAS elements are valid", () => { + const doc = new DOMParser().parseFromString( + docWithLNodesAndDataTypes, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(0); + }); + + it("removes DOS elements when corresponding DO is missing from LNodeType", () => { + const doc = new DOMParser().parseFromString( + docWithMissingDOs, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(1); + expect((removes[0].node as Element).tagName).to.equal("eTr_6-100:DOS"); + expect((removes[0].node as Element).getAttribute("name")).to.equal("PhV"); + }); + + it("removes SDS elements when corresponding SDO is missing from DOType", () => { + const doc = new DOMParser().parseFromString( + docWithMissingSDOs, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(2); + removes.forEach(remove => { + expect((remove.node as Element).tagName).to.equal("eTr_6-100:SDS"); + expect(["phsB", "phsC"]).to.include((remove.node as Element).getAttribute("name")); + }); + }); + + it("removes DAS elements when corresponding DA is missing from DOType", () => { + const doc = new DOMParser().parseFromString( + docWithMissingDAs, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(4); + removes.forEach(remove => { + expect((remove.node as Element).tagName).to.equal("eTr_6-100:DAS"); + expect(["q", "t"]).to.include((remove.node as Element).getAttribute("name")); + }); + }); + + it("removes SDS elements when corresponding BDA is missing from DAType", () => { + const doc = new DOMParser().parseFromString( + docWithMissingBDAs, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(1); + expect((removes[0].node as Element).tagName).to.equal("eTr_6-100:SDS"); + expect((removes[0].node as Element).getAttribute("name")).to.equal("ang"); + }); + + it("handles multiple LNode instances with the same lnType", () => { + const doc = new DOMParser().parseFromString( + docWithMissingDOs, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + // Add another LNode with the same lnType and missing DOS + const substation = doc.querySelector("Substation")!; + const newLNode = doc.createElement("LNode"); + newLNode.setAttribute("lnClass", "MMXU"); + newLNode.setAttribute("lnType", "MMXU_1"); + newLNode.setAttribute("lnInst", "3"); + + const privateEl = doc.createElement("Private"); + privateEl.setAttribute("type", "eIEC61850-6-100"); + + const dos = doc.createElementNS("http://www.iec.ch/61850/2019/SCL/6-100", "eTr_6-100:DOS"); + dos.setAttribute("name", "PhV"); + const sds = doc.createElementNS("http://www.iec.ch/61850/2019/SCL/6-100", "eTr_6-100:SDS"); + sds.setAttribute("name", "phsA"); + sds.setAttribute("name", "phsA"); + dos.appendChild(sds); + privateEl.appendChild(dos); + newLNode.appendChild(privateEl); + substation.appendChild(newLNode); + + const removes = updateLnType(lNodeType, doc); + + // Should remove PhV DOS from both LNode instances + expect(removes).to.have.lengthOf(2); + removes.forEach(remove => { + expect((remove.node as Element).tagName).to.equal("eTr_6-100:DOS"); + expect((remove.node as Element).getAttribute("name")).to.equal("PhV"); + }); + }); + }); + + describe("with edge cases", () => { + it("returns empty array when no LNode instances match the lnType", () => { + const doc = new DOMParser().parseFromString( + docWithDifferentLnType, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(0); + }); + + it("returns empty array when no LNode instances exist", () => { + const doc = new DOMParser().parseFromString( + docWithNoLNodes, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(0); + }); + + it("handles LNodeType without id attribute", () => { + const doc = new DOMParser().parseFromString( + docWithLNodesAndDataTypes, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + lNodeType.removeAttribute("id"); + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(0); + }); + + it("handles LNodeType from external document (without DataTypeTemplates in target)", () => { + const doc = new DOMParser().parseFromString( + docWithoutDataTypeTemplates, + "application/xml" + ); + + // Create an external LNodeType (e.g., from template library) + const externalLNodeType = doc.createElement("LNodeType"); + externalLNodeType.setAttribute("id", "MMXU_1"); + externalLNodeType.setAttribute("lnClass", "MMXU"); + + // Add DO that matches the LNode instance + const behDO = doc.createElement("DO"); + behDO.setAttribute("name", "Beh"); + behDO.setAttribute("type", "BehaviourDO"); + externalLNodeType.appendChild(behDO); + + const removes = updateLnType(externalLNodeType, doc); + + // Should work with external LNodeType and not remove valid DOS + expect(removes).to.have.lengthOf(0); + }); + + it("removes elements when using external LNodeType with different structure", () => { + const doc = new DOMParser().parseFromString( + docWithoutDataTypeTemplates, + "application/xml" + ); + + // Create an external LNodeType that doesn't include the "Beh" DO + const externalLNodeType = doc.createElement("LNodeType"); + externalLNodeType.setAttribute("id", "MMXU_1"); + externalLNodeType.setAttribute("lnClass", "MMXU"); + + // Add a different DO that doesn't match the LNode instance + const otherDO = doc.createElement("DO"); + otherDO.setAttribute("name", "A"); + otherDO.setAttribute("type", "WYE"); + externalLNodeType.appendChild(otherDO); + + const removes = updateLnType(externalLNodeType, doc); + + // Should remove the "Beh" DOS since it's not in the external LNodeType + expect(removes).to.have.lengthOf(1); + expect((removes[0].node as Element).tagName).to.equal("eTr_6-100:DOS"); + expect((removes[0].node as Element).getAttribute("name")).to.equal("Beh"); + }); + + it("handles LNode without Private element", () => { + const doc = new DOMParser().parseFromString( + docWithLNodesAndDataTypes, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + // Remove Private element from LNode + const lNode = doc.querySelector('LNode[lnType="MMXU_1"]')!; + const privateEl = lNode.querySelector("Private"); + if (privateEl) { + lNode.removeChild(privateEl); + } + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(0); + }); + + it("handles LNode with Private element but no DOS children", () => { + const doc = new DOMParser().parseFromString( + docWithLNodesAndDataTypes, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + // Remove all DOS elements from Private + const lNode = doc.querySelector('LNode[lnType="MMXU_1"]')!; + const privateEl = lNode.querySelector("Private")!; + const dosElements = Array.from(privateEl.querySelectorAll("DOS")); + dosElements.forEach(dos => privateEl.removeChild(dos)); + + const removes = updateLnType(lNodeType, doc); + + expect(removes).to.have.lengthOf(0); + }); + + it("handles nested data structures with missing elements at different levels", () => { + const doc = new DOMParser().parseFromString(docWithComplexNestedStructures, "application/xml"); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + // Should remove: PhV DOS, phsB SDS, q DAS, ang SDS + expect(removes).to.have.lengthOf(4); + + const removedNames = removes.map(r => (r.node as Element).getAttribute("name")); + expect(removedNames).to.include("PhV"); + expect(removedNames).to.include("phsB"); + expect(removedNames).to.include("q"); + expect(removedNames).to.include("ang"); + }); + }); + + describe("SourceRef removal", () => { + it("removes SourceRef elements when referenced data structures are missing", () => { + const doc = new DOMParser().parseFromString( + docWithSourceRefs, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + // Should remove: 1 DOS (PhV), 1 SDS (phsC), 1 SDS (ang), 1 DAS (t), and 5 SourceRefs + expect(removes).to.have.lengthOf(9); + + const removedSourceRefs = removes.filter(r => (r.node as Element).localName === "SourceRef"); + expect(removedSourceRefs).to.have.lengthOf(5); + + const removedResourceNames = removedSourceRefs.map(r => + (r.node as Element).getAttribute("resourceName") + ); + + expect(removedResourceNames).to.include("InvalidPhVReference"); + expect(removedResourceNames).to.include("InvalidPhsCReference"); + expect(removedResourceNames).to.include("InvalidAngReference"); + expect(removedResourceNames).to.include("InvalidTReference"); + expect(removedResourceNames).to.include("CrossLNodeReference"); + + // Valid SourceRefs should not be removed + expect(removedResourceNames).to.not.include("ValidReference"); + }); + + it("removes SourceRef elements from different LNodes when they reference invalid data", () => { + const doc = new DOMParser().parseFromString( + docWithSourceRefs, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + const removedSourceRefs = removes.filter(r => (r.node as Element).localName === "SourceRef"); + const crossLNodeRef = removedSourceRefs.find(r => + (r.node as Element).getAttribute("resourceName") === "CrossLNodeReference" + ); + + expect(crossLNodeRef).to.exist; + + // Valid cross-LNode reference should not be removed + const validCrossRef = removedSourceRefs.find(r => + (r.node as Element).getAttribute("resourceName") === "ValidCrossReference" + ); + expect(validCrossRef).to.be.undefined; + }); + + it("handles documents without SourceRef elements", () => { + const doc = new DOMParser().parseFromString( + docWithMissingDOs, + "application/xml" + ); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + // Should only remove the PhV DOS, no SourceRefs to remove + expect(removes).to.have.lengthOf(1); + expect((removes[0].node as Element).tagName).to.equal("eTr_6-100:DOS"); + expect((removes[0].node as Element).getAttribute("name")).to.equal("PhV"); + }); + + it("handles SourceRef elements with malformed source attributes", () => { + const doc = new DOMParser().parseFromString(docWithMalformedSourceRefs, "application/xml"); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + // Should not crash and should not remove any elements (all data is valid) + expect(removes).to.have.lengthOf(0); + }); + }); + + describe("selector specificity", () => { + it("finds LNode instances both inside and outside Bay elements", () => { + const doc = new DOMParser().parseFromString(docWithMixedLNodes, "application/xml"); + const lNodeType = doc.querySelector('LNodeType[id="MMXU_1"]')!; + + const removes = updateLnType(lNodeType, doc); + + // Should find and process both LNode instances + expect(removes).to.have.lengthOf(2); + removes.forEach(remove => { + expect((remove.node as Element).tagName).to.equal("eTr_6-100:DOS"); + expect((remove.node as Element).getAttribute("name")).to.equal("MissingDO"); + }); + }); + }); +}); diff --git a/tSubstation/updateLnType.testfiles.ts b/tSubstation/updateLnType.testfiles.ts new file mode 100644 index 0000000..42a0920 --- /dev/null +++ b/tSubstation/updateLnType.testfiles.ts @@ -0,0 +1,843 @@ +export const docWithLNodesAndDataTypes = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithMissingDOs = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithMissingSDOs = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithMissingDAs = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithMissingBDAs = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithNoLNodes = ` +
+ + + + + + + + + + + + + + + +`; + +export const docWithDifferentLnType = ` +
+ + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithoutDataTypeTemplates = ` +
+ + + + + + + + + + + + + +`; + +export const docWithSourceRefs = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithMalformedSourceRefs = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithComplexNestedStructures = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const docWithMixedLNodes = ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/tSubstation/updateLnType.ts b/tSubstation/updateLnType.ts new file mode 100644 index 0000000..706e524 --- /dev/null +++ b/tSubstation/updateLnType.ts @@ -0,0 +1,126 @@ +import { Remove } from "../foundation/utils.js"; + + +function getDataType(data: Element): Element | null { + const dataTypeTemplates = data.closest("DataTypeTemplates"); + if (!dataTypeTemplates) return null; + + const type = data.getAttribute("type"); + + return dataTypeTemplates.querySelector(`DAType[id="${type}"], DOType[id="${type}"]`); +} + +function removeMissingSdsOrDas(dataType: Element, dosOrSds: Element): Remove[] { + const removes: Remove[] = []; + + dosOrSds.querySelectorAll(":scope > SDS, :scope > DAS").forEach((sdsOrDas) => { + const name = sdsOrDas.getAttribute("name"); + const childData = dataType.querySelector( + `:scope > DA[name="${name}"], :scope > BDA[name="${name}"], :scope > SDO[name="${name}"]` + ); + if (!childData) removes.push({ node: sdsOrDas }); + else { + const childDataType = getDataType(childData); + if (childDataType) + removes.push(...removeMissingSdsOrDas(childDataType, sdsOrDas)); + } + }); + + return removes; +} + +function removeMissingDos(lNodeType: Element, lNode: Element): Remove[] { + const removes: Remove[] = []; + + lNode.querySelectorAll(":scope > Private > DOS").forEach((dos) => { + const doElement = lNodeType.querySelector( + `:scope > DO[name="${dos.getAttribute("name")}"]` + ); + if (!doElement) removes.push({ node: dos }); + else { + const dataType = getDataType(doElement); + if (dataType) + removes.push(...removeMissingSdsOrDas(dataType, dos)); + } + }); + + return removes; +} + +function lNodeReference(lNode: Element): string { + let reference = ""; + const lNodeTag = `${lNode.getAttribute("prefix") || ""}${lNode.getAttribute("lnClass") || ""}${lNode.getAttribute("lnInst") || ""}`; + + reference = lNodeTag; + + let child = lNode; + while (child.parentElement !== null) { + if (child.parentElement.tagName === "SCL") break; + const name = child.parentElement.getAttribute("name"); + + // refix the name to the existing prefix seperated by / + if (name) reference = `${name}/${reference}`; + child = child.parentElement; + } + + return reference +} + +function invalidPath(path: string[], dataType: Element, index = 0): boolean { + const dataName = path[index]; + + const doElement = dataType.querySelector( + `:scope > DO[name="${dataName}"], :scope > SDO[name="${dataName}"], :scope > DA[name="${dataName}"], :scope > BDA[name="${dataName}"]` + ); + if (!doElement) return true; + + const childDataType = getDataType(doElement); + if (!childDataType) return false; + + if (index + 1 < path.length) return invalidPath(path, childDataType, index + 1); + + return false +} + +function removeInvalidSourceRef(lNodeType: Element, lNode: Element): Remove[] { + const removes: Remove[] = []; + + const lNodeRef = lNodeReference(lNode); + + const srcRefs = Array.from(lNode.ownerDocument.querySelectorAll(":root Private SourceRef")).filter(srcRef => srcRef.getAttribute("source")?.startsWith(lNodeRef)); + + srcRefs.forEach((srcRef) => { + // source without the starting lNodeRef + const srcRefPath = srcRef.getAttribute("source")!.substring(lNodeRef.length + 1); + + const path = srcRefPath.split('.'); + + if (invalidPath(path, lNodeType)) removes.push({ node: srcRef }); + }); + + return removes; +} + +/** + * Removes LNode children DOS, SDS or DAS when missing in the corresponding data type LNodeType + * @param lNodeType the data structure for the LNode instance + * @param targetDoc the target XML document where the LNode instance is located + * @returns an array of Remove edits for the missing elements + */ +export function updateLnType(lNodeType: Element, targetDoc: XMLDocument): Remove[] { + + // find all LNode instances in targetDoc with the same lnType + const lnType = lNodeType.getAttribute("id"); + const lNodes = Array.from(targetDoc.querySelectorAll( + `:root > Substation LNode[lnType="${lnType}"], :root > Substation > LNode[lnType="${lnType}"]` + )); + if (lNodes.length === 0) return []; + + // for each LNode instance remove missing DOS, SDS or DAS + const removeInstanceData: Remove[] = lNodes.flatMap((lNode) => removeMissingDos(lNodeType, lNode)); + + // remove SourceRef when reference is invalid + const removeSrcRef = lNodes.flatMap(lNode => removeInvalidSourceRef(lNodeType, lNode)); + + return [...removeInstanceData, ...removeSrcRef]; +} \ No newline at end of file