From 75504bb7d3d56a42c06cf6b275f1c94d9688ac30 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Tue, 13 Oct 2026 09:30:49 +0200 Subject: [PATCH 01/18] feat(semantic constraints): add ConnectedAP checks --- src/SemanticConstraints/ConnectedAP.ts | 98 +++++++++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/ConnectedAP.test.ts | 170 +++++++++++++++++++ 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/ConnectedAP.ts create mode 100644 test/SemanticConstraints/ConnectedAP.test.ts diff --git a/src/SemanticConstraints/ConnectedAP.ts b/src/SemanticConstraints/ConnectedAP.ts new file mode 100644 index 0000000..4671c3a --- /dev/null +++ b/src/SemanticConstraints/ConnectedAP.ts @@ -0,0 +1,98 @@ +import { getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: ConnectedAP_ied_exists +export function ConnectedAP_ied_exists(scl: XMLDocument, docName: string): ValidationIssue[] { + const iedNames = new Set( + Array.from(scl.querySelectorAll(':root > IED')).map((ied) => ied.getAttribute('name') || '') + ); + + return Array.from(scl.querySelectorAll(':root > Communication > SubNetwork > ConnectedAP')) + .filter((connectedAP) => { + const iedName = getAttributeValue(connectedAP, 'iedName'); + return iedName && !iedNames.has(iedName); + }) + .map((connectedAP) => { + const iedName = getAttributeValue(connectedAP, 'iedName'); + return { + severity: 'ERROR', + id: 'ConnectedAP_ied_exists', + filename: docName, + message: `iedName attribute shall refer to an existing IED in ConnectedAP. Current value is ${iedName}`, + element: connectedAP, + }; + }); +} + +// Rule: ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork +export function ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork( + scl: XMLDocument, + docName: string +): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > Communication > SubNetwork > ConnectedAP')) + .filter((connectedAP) => { + const gses = connectedAP.querySelectorAll('GSE'); + const combinations = Array.from(gses).map((gse) => { + const cbName = gse.getAttribute('cbName') || ''; + const ldInst = gse.getAttribute('ldInst') || ''; + return `${cbName}|${ldInst}`; + }); + const uniqueCombinations = new Set(combinations); + return uniqueCombinations.size !== combinations.length; + }) + .map((connectedAP) => ({ + severity: 'ERROR', + id: 'ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork', + filename: docName, + message: 'Unique GSE cbName and ldInst combination in ConnectedAP from SubNetwork', + element: connectedAP, + })); +} + +// Rule: ConnectedAP_unique_attributes_combination_SMV_from_SubNetwork +export function ConnectedAP_unique_attributes_combination_SMV_from_SubNetwork( + scl: XMLDocument, + docName: string +): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > Communication > SubNetwork > ConnectedAP')) + .filter((connectedAP) => { + const smvs = connectedAP.querySelectorAll('SMV'); + const combinations = Array.from(smvs).map((smv) => { + const cbName = smv.getAttribute('cbName') || ''; + const ldInst = smv.getAttribute('ldInst') || ''; + return `${cbName}|${ldInst}`; + }); + const uniqueCombinations = new Set(combinations); + return uniqueCombinations.size !== combinations.length; + }) + .map((connectedAP) => ({ + severity: 'ERROR', + id: 'ConnectedAP_unique_attributes_combination_SMV_from_SubNetwork', + filename: docName, + message: 'Unique SMV cbName and ldInst combination in ConnectedAP from SubNetwork', + element: connectedAP, + })); +} + +// Rule: ConnectedAP_RefersToAccessPoint +export function ConnectedAP_RefersToAccessPoint(scl: XMLDocument, docName: string): ValidationIssue[] { + const accessPointNames = new Set( + Array.from(scl.querySelectorAll(':root > IED > AccessPoint')).map((ap) => ap.getAttribute('name') || '') + ); + + return Array.from(scl.querySelectorAll(':root > Communication > SubNetwork > ConnectedAP')) + .filter((connectedAP) => { + const apName = getAttributeValue(connectedAP, 'apName'); + return apName && !accessPointNames.has(apName); + }) + .map((connectedAP) => { + const apName = getAttributeValue(connectedAP, 'apName'); + return { + severity: 'ERROR', + id: 'ConnectedAP_RefersToAccessPoint', + filename: docName, + message: `ConnectedAP (apName=${apName}) does not refer an existing AccessPoint in IED section`, + element: connectedAP, + }; + }); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index a4a8f29..4f7781c 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -12,7 +12,7 @@ // export * from './BDA'; // export * from './ClientLN'; export * from './Communication'; -// export * from './ConnectedAP'; +export * from './ConnectedAP'; // export * from './ConnectivityNode'; // export * from './Control'; // export * from './ControlBlock'; diff --git a/test/SemanticConstraints/ConnectedAP.test.ts b/test/SemanticConstraints/ConnectedAP.test.ts new file mode 100644 index 0000000..0126b72 --- /dev/null +++ b/test/SemanticConstraints/ConnectedAP.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import { + ConnectedAP_ied_exists, + ConnectedAP_RefersToAccessPoint, + ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork, + ConnectedAP_unique_attributes_combination_SMV_from_SubNetwork +} from '../../src/SemanticConstraints/ConnectedAP'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +function scl(communication: string, ieds: string): string { + return ` + + ${communication} + ${ieds} + `; +} + +const IED1 = ` + +`; + +describe('ConnectedAP', () => { + describe('ConnectedAP_ied_exists', () => { + it('should return no issues when iedName refers to an existing IED', () => { + const doc = parseXMLString(scl( + '', + IED1 + )); + expect(ConnectedAP_ied_exists(doc, DOC_NAME)).toEqual([]); + }); + + it('should report a ConnectedAP referring to a missing IED', () => { + const doc = parseXMLString(scl( + '', + IED1 + )); + const issues = ConnectedAP_ied_exists(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'ConnectedAP_ied_exists', + filename: DOC_NAME, + message: 'iedName attribute shall refer to an existing IED in ConnectedAP. Current value is MISSING', + }); + expect(issues[0].element.tagName).toBe('ConnectedAP'); + }); + + it('should return no issues when there are no ConnectedAP elements', () => { + const doc = parseXMLString(scl('', IED1)); + expect(ConnectedAP_ied_exists(doc, DOC_NAME)).toEqual([]); + }); + }); + + describe('ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork', () => { + it('should return no issues when GSE cbName/ldInst combinations are unique', () => { + const doc = parseXMLString(scl( + ` + + + + + `, + IED1 + )); + expect(ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when only ldInst differs', () => { + const doc = parseXMLString(scl( + ` + + + + + `, + IED1 + )); + expect(ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork(doc, DOC_NAME)).toEqual([]); + }); + + it('should report duplicate GSE cbName/ldInst combinations', () => { + const doc = parseXMLString(scl( + ` + + + + + `, + IED1 + )); + const issues = ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'ConnectedAP_unique_attributes_combination_GSE_from_SubNetwork', + filename: DOC_NAME, + message: 'Unique GSE cbName and ldInst combination in ConnectedAP from SubNetwork', + }); + expect(issues[0].element.tagName).toBe('ConnectedAP'); + }); + }); + + describe('ConnectedAP_unique_attributes_combination_SMV_from_SubNetwork', () => { + it('should return no issues when SMV cbName/ldInst combinations are unique', () => { + const doc = parseXMLString(scl( + ` + + + + + `, + IED1 + )); + expect(ConnectedAP_unique_attributes_combination_SMV_from_SubNetwork(doc, DOC_NAME)).toEqual([]); + }); + + it('should report duplicate SMV cbName/ldInst combinations', () => { + const doc = parseXMLString(scl( + ` + + + + + `, + IED1 + )); + const issues = ConnectedAP_unique_attributes_combination_SMV_from_SubNetwork(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'ConnectedAP_unique_attributes_combination_SMV_from_SubNetwork', + filename: DOC_NAME, + message: 'Unique SMV cbName and ldInst combination in ConnectedAP from SubNetwork', + }); + expect(issues[0].element.tagName).toBe('ConnectedAP'); + }); + }); + + describe('ConnectedAP_RefersToAccessPoint', () => { + it('should return no issues when apName refers to an existing AccessPoint', () => { + const doc = parseXMLString(scl( + '', + IED1 + )); + expect(ConnectedAP_RefersToAccessPoint(doc, DOC_NAME)).toEqual([]); + }); + + it('should report a ConnectedAP referring to a missing AccessPoint', () => { + const doc = parseXMLString(scl( + '', + IED1 + )); + const issues = ConnectedAP_RefersToAccessPoint(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'ConnectedAP_RefersToAccessPoint', + filename: DOC_NAME, + message: 'ConnectedAP (apName=MISSING) does not refer an existing AccessPoint in IED section', + }); + expect(issues[0].element.tagName).toBe('ConnectedAP'); + }); + }); +}); From 23b0fe1f48b3ca21e8c694f68d13998061a9ff21 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Tue, 13 Oct 2026 09:53:26 +0200 Subject: [PATCH 02/18] feat(SemConstr): add checks for ConductingEquipment and TransformerWinding --- .../AbstractConductingEquipment.ts | 22 +++++ src/SemanticConstraints/index.ts | 2 +- .../AbstractConductingEquipment.test.ts | 93 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/AbstractConductingEquipment.ts create mode 100644 test/SemanticConstraints/AbstractConductingEquipment.test.ts diff --git a/src/SemanticConstraints/AbstractConductingEquipment.ts b/src/SemanticConstraints/AbstractConductingEquipment.ts new file mode 100644 index 0000000..b7a65da --- /dev/null +++ b/src/SemanticConstraints/AbstractConductingEquipment.ts @@ -0,0 +1,22 @@ +import { ValidationIssue } from '../ValidationIssue'; + +/** + * The SubEquipment in AbstractConductingEquipment shall have unique child name (from SCL_Substation.xsd) + * ConductingEquipment and TransformerWinding are concrete implementations of AbstractConductingEquipment + * Rule: AbstractConductingEquipment_unique_name_of_SubEquipment + */ +export function AbstractConductingEquipment_unique_name_of_SubEquipment(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root ConductingEquipment, :root TransformerWinding')) + .filter(ace => { + const names = Array.from(ace.querySelectorAll(':scope > SubEquipment')) + .map(se => se.getAttribute('name') || ''); + return new Set(names).size !== names.length; + }) + .map((ace): ValidationIssue => ({ + severity: 'ERROR', + id: 'AbstractConductingEquipment_unique_name_of_SubEquipment', + filename: docName, + message: 'Unique child name of SubEquipment in AbstractConductingEquipment', + element: ace, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index 4f7781c..f2e04d8 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -1,6 +1,6 @@ // SemanticConstraints index - exports all semantic constraint validation functions // -// export * from './AbstractConductingEquipment'; +export * from './AbstractConductingEquipment'; // export * from './Bay'; // export * from './ConductingEquipment'; // export * from './SCL'; diff --git a/test/SemanticConstraints/AbstractConductingEquipment.test.ts b/test/SemanticConstraints/AbstractConductingEquipment.test.ts new file mode 100644 index 0000000..357ff83 --- /dev/null +++ b/test/SemanticConstraints/AbstractConductingEquipment.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { AbstractConductingEquipment_unique_name_of_SubEquipment } from '../../src/SemanticConstraints/AbstractConductingEquipment'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +function sclWithConductingEquipment(ceContent: string): string { + return ` + + + + + + ${ceContent} + + + + + `; +} + +function sclWithTransformerWinding(twContent: string): string { + return ` + + + + + + ${twContent} + + + + + `; +} + +describe('AbstractConductingEquipment', () => { + describe('AbstractConductingEquipment_unique_name_of_SubEquipment', () => { + it('should return no issues when SubEquipment names are unique', () => { + const doc = parseXMLString(sclWithConductingEquipment( + '' + )); + expect(AbstractConductingEquipment_unique_name_of_SubEquipment(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when two SubEquipments share a name in ConductingEquipment', () => { + const doc = parseXMLString(sclWithConductingEquipment( + '' + )); + const issues = AbstractConductingEquipment_unique_name_of_SubEquipment(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AbstractConductingEquipment_unique_name_of_SubEquipment', + filename: DOC_NAME, + message: 'Unique child name of SubEquipment in AbstractConductingEquipment', + }); + expect(issues[0].element.tagName).toBe('ConductingEquipment'); + }); + + it('should return an issue when two SubEquipments share a name in TransformerWinding', () => { + const doc = parseXMLString(sclWithTransformerWinding( + '' + )); + const issues = AbstractConductingEquipment_unique_name_of_SubEquipment(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('TransformerWinding'); + }); + + it('should return one issue per faulty equipment', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + `); + expect(AbstractConductingEquipment_unique_name_of_SubEquipment(doc, DOC_NAME)).toHaveLength(2); + }); + }); +}); From ca9ce153610f956806156b8a57b1fe79c67b1d3a Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Tue, 13 Oct 2026 11:10:31 +0200 Subject: [PATCH 03/18] feat(SemConstr): add AbstractDataObject checks --- src/SemanticConstraints/AbstractDataObject.ts | 21 ++++++ src/SemanticConstraints/index.ts | 2 +- .../AbstractDataObject.test.ts | 74 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/AbstractDataObject.ts create mode 100644 test/SemanticConstraints/AbstractDataObject.test.ts diff --git a/src/SemanticConstraints/AbstractDataObject.ts b/src/SemanticConstraints/AbstractDataObject.ts new file mode 100644 index 0000000..ff67e69 --- /dev/null +++ b/src/SemanticConstraints/AbstractDataObject.ts @@ -0,0 +1,21 @@ +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: AbstractDataObject_RefersToDOType +export function abstractDataObject_RefersToDOType(scl: XMLDocument, docName: string): ValidationIssue[] { + // AbstractDataObject is the common base type of DO and SDO + return Array.from(scl.querySelectorAll(':root DataTypeTemplates > LNodeType > DO, :root DataTypeTemplates > DOType > SDO')) + .filter((dataObject) => { + const type = dataObject.getAttribute('type'); + return type && !scl.querySelector(`DataTypeTemplates DOType[id="${type}"]`); + }) + .map((dataObject) => { + const name = dataObject.getAttribute('name') ?? ''; + return { + severity: 'ERROR', + id: 'AbstractDataObject_RefersToDOType', + filename: docName, + message: `DO or SDO (name=${name}) does not refer an existing DOType in DataTypeTemplates section`, + element: dataObject, + }; + }); +} \ No newline at end of file diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index f2e04d8..c1b69e9 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -1,12 +1,12 @@ // SemanticConstraints index - exports all semantic constraint validation functions // export * from './AbstractConductingEquipment'; +export * from './AbstractDataObject'; // export * from './Bay'; // export * from './ConductingEquipment'; // export * from './SCL'; // // Add more exports as files are created -// export * from './AbstractDataObject'; // export * from './AnyLN'; // export * from './Association'; // export * from './BDA'; diff --git a/test/SemanticConstraints/AbstractDataObject.test.ts b/test/SemanticConstraints/AbstractDataObject.test.ts new file mode 100644 index 0000000..f1750af --- /dev/null +++ b/test/SemanticConstraints/AbstractDataObject.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { abstractDataObject_RefersToDOType } from '../../src/SemanticConstraints/AbstractDataObject'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +function sclWithTemplates(templates: string): string { + return ` + + + ${templates} + + `; +} + +describe('AbstractDataObject', () => { + describe('abstractDataObject_RefersToDOType', () => { + it('should return no issues when DO and SDO refer to existing DOTypes', () => { + const doc = parseXMLString(sclWithTemplates(` + + + + + + + `)); + expect(abstractDataObject_RefersToDOType(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when there are no DO or SDO elements', () => { + const doc = parseXMLString(` + `); + expect(abstractDataObject_RefersToDOType(doc, DOC_NAME)).toEqual([]); + }); + + it('should report a DO referring to a missing DOType', () => { + const doc = parseXMLString(sclWithTemplates(` + + + `)); + const issues = abstractDataObject_RefersToDOType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AbstractDataObject_RefersToDOType', + filename: DOC_NAME, + message: 'DO or SDO (name=Mod) does not refer an existing DOType in DataTypeTemplates section', + }); + expect(issues[0].element.tagName).toBe('DO'); + }); + + it('should report an SDO referring to a missing DOType', () => { + const doc = parseXMLString(sclWithTemplates(` + + + `)); + const issues = abstractDataObject_RefersToDOType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('SDO'); + }); + + it('should skip a DO without a type attribute', () => { + const doc = parseXMLString(sclWithTemplates(` + + + `)); + expect(abstractDataObject_RefersToDOType(doc, DOC_NAME)).toEqual([]); + }); + }); +}); From ae943a12cf0a7c80345c79258143d4570e70cb03 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Tue, 13 Oct 2026 11:25:19 +0200 Subject: [PATCH 04/18] feat(SemConstr): add AnyLN checks --- src/SemanticConstraints/AnyLN.ts | 121 +++++++++++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/AnyLN.test.ts | 224 +++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/AnyLN.ts create mode 100644 test/SemanticConstraints/AnyLN.test.ts diff --git a/src/SemanticConstraints/AnyLN.ts b/src/SemanticConstraints/AnyLN.ts new file mode 100644 index 0000000..2d0e7c1 --- /dev/null +++ b/src/SemanticConstraints/AnyLN.ts @@ -0,0 +1,121 @@ +import { areNamesUnique } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +const selector = ':root > IED > AccessPoint > LN, :root > IED > AccessPoint > Server > LDevice >LN0, :root > IED > AccessPoint > Server > LDevice > LN'; +// Rule: AnyLN_LogControl_name_unique +export function AnyLN_LogControl_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector)) + .filter((anyLN) => !areNamesUnique(anyLN.querySelectorAll(':scope > LogControl'))) + .map((anyLN) => ({ + severity: 'ERROR', + id: 'AnyLN_LogControl_name_unique', + filename: docName, + message: 'name of the log control block shall be unique within the AnyLN', + element: anyLN, + })); +} + +// Rule: AnyLN_ReportControl_name_unique +export function AnyLN_ReportControl_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector)) + .filter((anyLN) => !areNamesUnique(anyLN.querySelectorAll(':scope > ReportControl'))) + .map((anyLN) => ({ + severity: 'ERROR', + id: 'AnyLN_ReportControl_name_unique', + filename: docName, + message: 'name of the report control block shall be unique within the AnyLN', + element: anyLN, + })); +} + +// Rule: AnyLN_Log_name_unique +export function AnyLN_Log_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector)) + .filter((anyLN) => !areNamesUnique(anyLN.querySelectorAll(':scope > Log'))) + .map((anyLN) => ({ + severity: 'ERROR', + id: 'AnyLN_Log_name_unique', + filename: docName, + message: 'name of the Log shall be unique within the AnyLN', + element: anyLN, + })); +} + +// Rule: AnyLN_DOI_name_unique +export function AnyLN_DOI_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector)) + .filter((anyLN) => { + const dois = anyLN.querySelectorAll(':scope > DOI'); + return dois.length > 0 && !areNamesUnique(dois); + }) + .map((anyLN) => ({ + severity: 'ERROR', + id: 'AnyLN_DOI_name_unique', + filename: docName, + message: 'name of the DOI shall be unique within the AnyLN', + element: anyLN, + })); +} + +// Rule: AnyLN_LNodeType_lnClass_consistent +export function AnyLN_LNodeType_lnClass_consistent(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector)) + .filter((anyLN) => { + const refersToLNodeType = anyLN.getAttribute('lnType'); + const lnClass = anyLN.getAttribute('lnClass'); + + if (!refersToLNodeType) return false; + + // Find the referenced LNodeType + const lNodeType = scl.querySelector(`:root > DataTypeTemplates > LNodeType[id="${refersToLNodeType}"]`); + if (!lNodeType) return false; + + const lNodeTypeLnClass = lNodeType.getAttribute('lnClass'); + return lNodeTypeLnClass !== lnClass; + }) + .map((anyLN) => ({ + severity: 'ERROR', + id: 'AnyLN_LNodeType_lnClass_consistent', + filename: docName, + message: 'lnClass shall be the same as the referenced LNodeType lnClass', + element: anyLN, + })); +} + +// Rule: AnyLN_RefersToLNodeType +export function AnyLN_RefersToLNodeType(scl: XMLDocument, docName: string): ValidationIssue[] { + const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); + + if (!dataTypeTemplates) { + // If no DataTypeTemplates section, all logical nodes must not reference LNodeTypes + return Array.from(scl.querySelectorAll(selector)) + .filter((anyLN) => anyLN.getAttribute('lnType') !== null) + .map((anyLN) => { + const lnType = anyLN.getAttribute('lnType') ?? ''; + return { + severity: 'ERROR', + id: 'AnyLN_RefersToLNodeType', + filename: docName, + message: `AnyLN (lnType=${lnType}) does not refer an existing LNodeType in DataTypeTemplates section`, + element: anyLN, + }; + }); + } + + return Array.from(scl.querySelectorAll(selector)) + .filter((anyLN) => { + const refersToLNodeType = anyLN.getAttribute('lnType'); + if (!refersToLNodeType) return false; + return !dataTypeTemplates.querySelector(`:root > DataTypeTemplates > LNodeType[id="${refersToLNodeType}"]`); + }) + .map((anyLN) => { + const lnType = anyLN.getAttribute('lnType') ?? ''; + return { + severity: 'ERROR', + id: 'AnyLN_RefersToLNodeType', + filename: docName, + message: `AnyLN (lnType=${lnType}) does not refer an existing LNodeType in DataTypeTemplates section`, + element: anyLN, + }; + }); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index c1b69e9..c794299 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -2,12 +2,12 @@ // export * from './AbstractConductingEquipment'; export * from './AbstractDataObject'; +export * from './AnyLN'; // export * from './Bay'; // export * from './ConductingEquipment'; // export * from './SCL'; // // Add more exports as files are created -// export * from './AnyLN'; // export * from './Association'; // export * from './BDA'; // export * from './ClientLN'; diff --git a/test/SemanticConstraints/AnyLN.test.ts b/test/SemanticConstraints/AnyLN.test.ts new file mode 100644 index 0000000..8cd3968 --- /dev/null +++ b/test/SemanticConstraints/AnyLN.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnyLN_LogControl_name_unique, + AnyLN_ReportControl_name_unique, + AnyLN_Log_name_unique, + AnyLN_DOI_name_unique, + AnyLN_LNodeType_lnClass_consistent, + AnyLN_RefersToLNodeType +} from '../../src/SemanticConstraints/AnyLN'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +/** Wraps inner LN/LN0 content in a minimal SCL structure */ +function sclWithLn(lnInner: string, lnTag: 'LN' | 'LN0' = 'LN0'): string { + const ln = lnTag === 'LN0' + ? `${lnInner}` + : `${lnInner}`; + return ` + + + + + + ${ln} + + + + + `; +} + +/** Minimal SCL with a DataTypeTemplates section and the given LNodeTypes */ +function sclWithTypes(lNodeTypes: string, lnTypeAttr: string, lnClassAttr = 'LLN0'): string { + return ` + + + + + + + + + + + + ${lNodeTypes} + + `; +} + +describe('AnyLN', () => { + describe('AnyLN_LogControl_name_unique', () => { + it('should return no issues when LogControl names are unique', () => { + const doc = parseXMLString(sclWithLn( + '' + )); + expect(AnyLN_LogControl_name_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when two LogControls share a name', () => { + const doc = parseXMLString(sclWithLn( + '' + )); + const issues = AnyLN_LogControl_name_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_LogControl_name_unique', + filename: DOC_NAME, + message: 'name of the log control block shall be unique within the AnyLN', + }); + expect(issues[0].element.tagName).toBe('LN0'); + }); + }); + + describe('AnyLN_ReportControl_name_unique', () => { + it('should return no issues when ReportControl names are unique', () => { + const doc = parseXMLString(sclWithLn( + '' + )); + expect(AnyLN_ReportControl_name_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when two ReportControls share a name', () => { + const doc = parseXMLString(sclWithLn( + '' + )); + const issues = AnyLN_ReportControl_name_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_ReportControl_name_unique', + filename: DOC_NAME, + message: 'name of the report control block shall be unique within the AnyLN', + }); + }); + }); + + describe('AnyLN_Log_name_unique', () => { + it('should return no issues when Log names are unique', () => { + const doc = parseXMLString(sclWithLn( + '' + )); + expect(AnyLN_Log_name_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when two Logs share a name', () => { + const doc = parseXMLString(sclWithLn('')); + const issues = AnyLN_Log_name_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_Log_name_unique', + filename: DOC_NAME, + message: 'name of the Log shall be unique within the AnyLN', + }); + }); + }); + + describe('AnyLN_DOI_name_unique', () => { + it('should return no issues when DOI names are unique', () => { + const doc = parseXMLString(sclWithLn( + '' + )); + expect(AnyLN_DOI_name_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when there are no DOIs', () => { + const doc = parseXMLString(sclWithLn('')); + expect(AnyLN_DOI_name_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when two DOIs share a name', () => { + const doc = parseXMLString(sclWithLn('')); + const issues = AnyLN_DOI_name_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_DOI_name_unique', + filename: DOC_NAME, + message: 'name of the DOI shall be unique within the AnyLN', + }); + }); + }); + + describe('AnyLN_LNodeType_lnClass_consistent', () => { + it('should return no issues when lnClass matches the referenced LNodeType', () => { + const doc = parseXMLString(sclWithTypes( + '', + 'lnType="T1"' + )); + expect(AnyLN_LNodeType_lnClass_consistent(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when lnClass differs from the referenced LNodeType', () => { + const doc = parseXMLString(sclWithTypes( + '', + 'lnType="T1"', + 'LLN0' + )); + const issues = AnyLN_LNodeType_lnClass_consistent(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_LNodeType_lnClass_consistent', + filename: DOC_NAME, + message: 'lnClass shall be the same as the referenced LNodeType lnClass', + }); + }); + + it('should return no issues when lnType is not set', () => { + const doc = parseXMLString(sclWithTypes('', '')); + expect(AnyLN_LNodeType_lnClass_consistent(doc, DOC_NAME)).toEqual([]); + }); + }); + + describe('AnyLN_RefersToLNodeType', () => { + it('should return no issues when lnType references an existing LNodeType', () => { + const doc = parseXMLString(sclWithTypes( + '', + 'lnType="T1"' + )); + expect(AnyLN_RefersToLNodeType(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when lnType references a missing LNodeType', () => { + const doc = parseXMLString(sclWithTypes( + '', + 'lnType="MISSING"' + )); + const issues = AnyLN_RefersToLNodeType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_RefersToLNodeType', + filename: DOC_NAME, + message: 'AnyLN (lnType=MISSING) does not refer an existing LNodeType in DataTypeTemplates section', + }); + }); + + it('should return an issue when lnType is set but there is no DataTypeTemplates section', () => { + const doc = parseXMLString(` + + + + + + + + + + + `); + const issues = AnyLN_RefersToLNodeType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('AnyLN_RefersToLNodeType'); + }); + }); +}); From da9e4c817bb6bd2b59cbd690712178e348639033 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 14 Oct 2026 11:42:24 +0200 Subject: [PATCH 05/18] feat(SemConstr): add Association checks --- src/Helpers/BaseSimpleTypes.ts | 31 +++----- src/SemanticConstraints/Association.ts | 21 +++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/Association.test.ts | 80 ++++++++++++++++++++ 4 files changed, 111 insertions(+), 23 deletions(-) create mode 100644 src/SemanticConstraints/Association.ts create mode 100644 test/SemanticConstraints/Association.test.ts diff --git a/src/Helpers/BaseSimpleTypes.ts b/src/Helpers/BaseSimpleTypes.ts index cc98c22..f92361c 100644 --- a/src/Helpers/BaseSimpleTypes.ts +++ b/src/Helpers/BaseSimpleTypes.ts @@ -48,33 +48,20 @@ export function areUnique(elements: NodeListOf | HTMLCollectionOf IED[name="${iedName}"] > AccessPoint > Server > LDevice[inst="${ldInst}"] > `; + const selectorAnyLN = `${base}LN0[lnClass="${lnClass}"], ${base}LN[lnClass="${lnClass}"]`; - return null; + return Array.from(scl.querySelectorAll(selectorAnyLN)).find(ln => { + if (prefix !== getAttributeValue(ln, 'prefix')) return false; + if (lnInst !== getAttributeValue(ln, 'inst')) return false; + return true; + }) || null; } diff --git a/src/SemanticConstraints/Association.ts b/src/SemanticConstraints/Association.ts new file mode 100644 index 0000000..38add2f --- /dev/null +++ b/src/SemanticConstraints/Association.ts @@ -0,0 +1,21 @@ +import { findReferencedServerLN, getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: Association_RefersToAnyLN +export function Association_RefersToAnyLN(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => { + const associationID = getAttributeValue(association, 'associationID'); + return associationID && !findReferencedServerLN(scl, association); + }) + .map((association) => { + const associationID = getAttributeValue(association, 'associationID'); + return { + severity: 'ERROR', + id: 'Association_RefersToAnyLN', + filename: docName, + message: `Association (ID=${associationID}) does not refer an existing LN`, + element: association, + }; + }); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index c794299..a613c47 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -3,12 +3,12 @@ export * from './AbstractConductingEquipment'; export * from './AbstractDataObject'; export * from './AnyLN'; +export * from './Association'; // export * from './Bay'; // export * from './ConductingEquipment'; // export * from './SCL'; // // Add more exports as files are created -// export * from './Association'; // export * from './BDA'; // export * from './ClientLN'; export * from './Communication'; diff --git a/test/SemanticConstraints/Association.test.ts b/test/SemanticConstraints/Association.test.ts new file mode 100644 index 0000000..6bd8a06 --- /dev/null +++ b/test/SemanticConstraints/Association.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { Association_RefersToAnyLN } from '../../src/SemanticConstraints/Association'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +function sclWithAssociation(association: string, extraIeds = ''): string { + return ` + + + + + + + + ${association} + + + + ${extraIeds} + `; +} + +const RECEIVER_IED = ` + + + + + + + +`; + +describe('Association', () => { + describe('Association_RefersToAnyLN', () => { + it('should return no issues when associationID is not set', () => { + const doc = parseXMLString(sclWithAssociation( + '' + )); + expect(Association_RefersToAnyLN(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when the Association refers to an existing LN', () => { + const doc = parseXMLString(sclWithAssociation( + '', + RECEIVER_IED + )); + expect(Association_RefersToAnyLN(doc, DOC_NAME)).toEqual([]); + }); + + it('should report an Association referring to a missing LN', () => { + const doc = parseXMLString(sclWithAssociation( + '', + RECEIVER_IED + )); + const issues = Association_RefersToAnyLN(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_RefersToAnyLN', + filename: DOC_NAME, + message: 'Association (ID=A1) does not refer an existing LN', + }); + expect(issues[0].element.tagName).toBe('Association'); + }); + + it('should report an Association referring to a missing IED', () => { + const doc = parseXMLString(sclWithAssociation( + '' + )); + const issues = Association_RefersToAnyLN(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('Association_RefersToAnyLN'); + }); + }); +}); From e9629b7f497aa68a51b922140062397122c6ff9e Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 14 Oct 2026 11:50:19 +0200 Subject: [PATCH 06/18] feat(SemConstr): add BDA checks --- src/SemanticConstraints/BDA.ts | 47 +++++++++++++++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/BDA.test.ts | 75 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/BDA.ts create mode 100644 test/SemanticConstraints/BDA.test.ts diff --git a/src/SemanticConstraints/BDA.ts b/src/SemanticConstraints/BDA.ts new file mode 100644 index 0000000..c512454 --- /dev/null +++ b/src/SemanticConstraints/BDA.ts @@ -0,0 +1,47 @@ +import { getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: BDA_BDAType_allowedWhen_Enum +export function BDA_BDAType_allowedWhen_Enum(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll('BDA')) + .filter((bda) => { + const bType = getAttributeValue(bda, 'bType'); + const type = getAttributeValue(bda, 'type'); + return bType !== 'Struct' && bType !== 'Enum' && type !== ''; + }) + .map((bda) => ({ + severity: 'ERROR', + id: 'BDA_BDAType_allowedWhen_Enum', + filename: docName, + message: 'BDA.type is allowed only if bType = Struct or Enum', + element: bda, + })); +} + +// Rule: BDA_RefersToDATypeorEnumType +export function BDA_RefersToDATypeorEnumType(scl: XMLDocument, docName: string): ValidationIssue[] { + const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); + return Array.from(scl.querySelectorAll(':root > DataTypeTemplates > DAType > BDA')) + .filter((bda) => { + const bType = getAttributeValue(bda, 'bType'); + + if (bType === 'Struct') { + const refersToDAType = getAttributeValue(bda, 'type'); + return refersToDAType && !dataTypeTemplates?.querySelector(`:scope > DAType[id="${refersToDAType}"]`); + } else if (bType === 'Enum') { + const refersToEnumType = getAttributeValue(bda, 'type'); + return refersToEnumType && !dataTypeTemplates?.querySelector(`:scope > EnumType[id="${refersToEnumType}"]`); + } + return false; + }) + .map((bda) => { + const name = getAttributeValue(bda, 'name'); + return { + severity: 'ERROR', + id: 'BDA_RefersToDATypeorEnumType', + filename: docName, + message: `BDA (name=${name}) does not refer an existing DAType or EnumType in DataTypeTemplates section`, + element: bda, + }; + }); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index a613c47..cfb8373 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -5,11 +5,11 @@ export * from './AbstractDataObject'; export * from './AnyLN'; export * from './Association'; // export * from './Bay'; +export * from './BDA'; // export * from './ConductingEquipment'; // export * from './SCL'; // // Add more exports as files are created -// export * from './BDA'; // export * from './ClientLN'; export * from './Communication'; export * from './ConnectedAP'; diff --git a/test/SemanticConstraints/BDA.test.ts b/test/SemanticConstraints/BDA.test.ts new file mode 100644 index 0000000..35719a7 --- /dev/null +++ b/test/SemanticConstraints/BDA.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { + BDA_BDAType_allowedWhen_Enum, + BDA_RefersToDATypeorEnumType +} from '../../src/SemanticConstraints/BDA'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +function sclWithBDA(bda: string, templates = ''): string { + return ` + + + ${templates} + ${bda} + + `; +} + +describe('BDA', () => { + describe('BDA_BDAType_allowedWhen_Enum', () => { + it('should return no issues when type is absent for a primitive bType', () => { + const doc = parseXMLString(sclWithBDA('')); + expect(BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); + }); + + it.each(['Struct', 'Enum'])('should allow type when bType is %s', bType => { + const doc = parseXMLString(sclWithBDA(``)); + expect(BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); + }); + + it('should report type when bType is neither Struct nor Enum', () => { + const doc = parseXMLString(sclWithBDA('')); + const issues = BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'BDA_BDAType_allowedWhen_Enum', + filename: DOC_NAME, + message: 'BDA.type is allowed only if bType = Struct or Enum', + }); + expect(issues[0].element.tagName).toBe('BDA'); + }); + }); + + describe('BDA_RefersToDATypeorEnumType', () => { + it('should return no issues when Struct and Enum types exist', () => { + const doc = parseXMLString(sclWithBDA( + '', + '' + )); + expect(BDA_RefersToDATypeorEnumType(doc, DOC_NAME)).toEqual([]); + }); + + it('should report references to missing DAType and EnumType elements', () => { + const doc = parseXMLString(sclWithBDA( + '' + )); + const issues = BDA_RefersToDATypeorEnumType(doc, DOC_NAME); + expect(issues).toHaveLength(2); + expect(issues.map(issue => issue.message)).toEqual([ + 'BDA (name=structValue) does not refer an existing DAType or EnumType in DataTypeTemplates section', + 'BDA (name=enumValue) does not refer an existing DAType or EnumType in DataTypeTemplates section', + ]); + expect(issues.map(issue => issue.element.tagName)).toEqual(['BDA', 'BDA']); + expect(issues.every(issue => issue.severity === 'ERROR' + && issue.id === 'BDA_RefersToDATypeorEnumType' + && issue.filename === DOC_NAME)).toBe(true); + }); + }); +}); \ No newline at end of file From 0bb8565d31a4dfe5aa68661e541d08be7d40b63a Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 14 Oct 2026 13:51:33 +0200 Subject: [PATCH 07/18] feat(SemConstr): add ClientLN checks --- src/Helpers/BaseSimpleTypes.ts | 22 ++ src/SemanticConstraints/ClientLN.ts | 60 ++++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/ClientLN.test.ts | 251 ++++++++++++++++++++++ 4 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/ClientLN.ts create mode 100644 test/SemanticConstraints/ClientLN.test.ts diff --git a/src/Helpers/BaseSimpleTypes.ts b/src/Helpers/BaseSimpleTypes.ts index f92361c..9fe2e79 100644 --- a/src/Helpers/BaseSimpleTypes.ts +++ b/src/Helpers/BaseSimpleTypes.ts @@ -65,3 +65,25 @@ export function findReferencedServerLN(scl: XMLDocument, ref: Element): Element return true; }) || null; } + +/** + * Resolves an LN reference (iedName/ldInst/prefix/lnClass/lnInst attributes, + * as used by Association, ClientLN, ...) to an LN0 or LN element. + * Returns null when the reference cannot be resolved. + */ +export function findReferencedClientLN(scl: XMLDocument, ref: Element): Element | null { + + const iedName = getAttributeValue(ref, 'iedName'); + const apRef = getAttributeValue(ref, 'apRef'); + const prefix = getAttributeValue(ref, 'prefix'); + const lnClass = getAttributeValue(ref, 'lnClass'); + const lnInst = getAttributeValue(ref, 'lnInst'); + + const selectorAnyLN = `IED[name="${iedName}"] > AccessPoint[name="${apRef}"] > Server > LDevice > LN0[lnClass="${lnClass}"], IED[name="${iedName}"] > AccessPoint[name="${apRef}"] > Server > LDevice > LN[lnClass="${lnClass}"]`; + + return Array.from(scl.querySelectorAll(selectorAnyLN)).find(ln => { + if (prefix !== getAttributeValue(ln, 'prefix')) return false; + if (lnInst !== getAttributeValue(ln, 'inst')) return false; + return true; + }) || null; +} diff --git a/src/SemanticConstraints/ClientLN.ts b/src/SemanticConstraints/ClientLN.ts new file mode 100644 index 0000000..9676449 --- /dev/null +++ b/src/SemanticConstraints/ClientLN.ts @@ -0,0 +1,60 @@ +import { findReferencedClientLN, getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +const selector = [ + ':root > IED > AccessPoint > Server > LDevice > LN0 > ReportControl > RptEnabled > ClientLN', + ':root > IED > AccessPoint > Server > LDevice > LN > ReportControl > RptEnabled > ClientLN', +]; + +// Rule: ClientLN_RefersToLNodeType +export function ClientLN_RefersToLNodeType(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from( + scl.querySelectorAll(selector.join(',')) + ) + .filter((clientLN) => !findReferencedClientLN(scl, clientLN)) + .map((clientLN) => ({ + severity: 'ERROR', + id: 'ClientLN_RefersToLNodeType', + filename: docName, + message: 'ClientLN does not refer an existing LN', + element: clientLN, + })); +} + +// Rule: ClientLN_Sender_and_receiver_not_in_same_subnetwork +export function ClientLN_Sender_and_receiver_not_in_same_subnetwork( + scl: XMLDocument, + docName: string +): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector.join(','))) + .filter((clientLN) => { + // Skip check if there is no Communication section in the SCL document + const communication = scl.querySelector(':root > Communication'); + if (!communication) return false; + + // Find Client SubNetwork + const iedName = getAttributeValue(clientLN, 'iedName'); + const apRef = getAttributeValue(clientLN, 'apRef'); + const clientSubNetwork = communication.querySelector( + `:scope > SubNetwork > ConnectedAP[iedName="${iedName}"][apName="${apRef}"]` + )?.parentElement; + if (clientSubNetwork === null) return true; + + //Find Server SubNetwork + const serverApRef = clientLN?.closest('AccessPoint')?.getAttribute('name'); + const serverIedName = clientLN?.closest('IED')?.getAttribute('name'); + const senderSubNetwork = communication.querySelector( + `ConnectedAP[iedName="${serverIedName}"][apName="${serverApRef}"]` + )?.parentElement; + if (senderSubNetwork === null) return true; + + return clientSubNetwork !== senderSubNetwork; + }) + .map((clientLN) => ({ + severity: 'ERROR', + id: 'ClientLN_Sender_and_receiver_not_in_same_subnetwork', + filename: docName, + message: 'Sender and receiver of the Report are not in the same Subnetwork', + element: clientLN, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index cfb8373..56d45c6 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -6,11 +6,11 @@ export * from './AnyLN'; export * from './Association'; // export * from './Bay'; export * from './BDA'; +export * from './ClientLN'; // export * from './ConductingEquipment'; // export * from './SCL'; // // Add more exports as files are created -// export * from './ClientLN'; export * from './Communication'; export * from './ConnectedAP'; // export * from './ConnectivityNode'; diff --git a/test/SemanticConstraints/ClientLN.test.ts b/test/SemanticConstraints/ClientLN.test.ts new file mode 100644 index 0000000..5ae5cac --- /dev/null +++ b/test/SemanticConstraints/ClientLN.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; + +import { ClientLN_RefersToLNodeType, ClientLN_Sender_and_receiver_not_in_same_subnetwork } from '../../src/SemanticConstraints/ClientLN'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +const SERVER_IED = ` + + + + + + + + + + + + +`; + +const RECEIVER_LN_IED = ` + + + + + + + + + + + + + +`; + +describe('ClientLN', () => { + describe('ClientLN_RefersToLNodeType', () => { + it('should return no issues when ClientLN refers to an existing LN', () => { + const doc = parseXMLString(` + + ${SERVER_IED} + ${RECEIVER_LN_IED} + `); + expect(ClientLN_RefersToLNodeType(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when ClientLN refers to LN0', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + + + + + + + `); + expect(ClientLN_RefersToLNodeType(doc, DOC_NAME)).toEqual([]); + }); + + it('should report ClientLN referring to a missing LN', () => { + const doc = parseXMLString(` + + ${SERVER_IED} + + + + + + + + + + + + + + + + `); + const issues = ClientLN_RefersToLNodeType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'ClientLN_RefersToLNodeType', + filename: DOC_NAME, + message: 'ClientLN does not refer an existing LN', + }); + expect(issues[0].element.tagName).toBe('ClientLN'); + }); + + it('should report ClientLN referring to a missing IED', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + `); + const issues = ClientLN_RefersToLNodeType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('ClientLN_RefersToLNodeType'); + }); + + it('should report ClientLN with wrong prefix', () => { + const doc = parseXMLString(` + + ${SERVER_IED} + + + + + + + + + + + + + + + + `); + const issues = ClientLN_RefersToLNodeType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('ClientLN_RefersToLNodeType'); + }); + }); + + describe('ClientLN_Sender_and_receiver_not_in_same_subnetwork', () => { + const communicationWithSubnets = ` + + + + + + + `; + + const communicationSameSubnet = ` + + + + + `; + + it('should return no issues when sender and receiver are in the same subnetwork', () => { + const doc = parseXMLString(` + + ${communicationSameSubnet} + ${SERVER_IED} + ${RECEIVER_LN_IED} + `); + expect(ClientLN_Sender_and_receiver_not_in_same_subnetwork(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when no Communication section exists', () => { + const doc = parseXMLString(` + + ${SERVER_IED} + ${RECEIVER_LN_IED} + `); + expect(ClientLN_Sender_and_receiver_not_in_same_subnetwork(doc, DOC_NAME)).toEqual([]); + }); + + it('should report when sender and receiver are in different subnetworks', () => { + const doc = parseXMLString(` + + ${communicationWithSubnets} + ${SERVER_IED} + ${RECEIVER_LN_IED} + `); + const issues = ClientLN_Sender_and_receiver_not_in_same_subnetwork(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'ClientLN_Sender_and_receiver_not_in_same_subnetwork', + filename: DOC_NAME, + message: 'Sender and receiver of the Report are not in the same Subnetwork', + }); + expect(issues[0].element.tagName).toBe('ClientLN'); + }); + + it('should report when ConnectedAP for client is missing', () => { + const doc = parseXMLString(` + + + + + + + ${SERVER_IED} + ${RECEIVER_LN_IED} + `); + const issues = ClientLN_Sender_and_receiver_not_in_same_subnetwork(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('ClientLN_Sender_and_receiver_not_in_same_subnetwork'); + }); + + it('should report when ConnectedAP for server is missing', () => { + const doc = parseXMLString(` + + + + + + + ${SERVER_IED} + ${RECEIVER_LN_IED} + `); + const issues = ClientLN_Sender_and_receiver_not_in_same_subnetwork(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('ClientLN_Sender_and_receiver_not_in_same_subnetwork'); + }); + }); +}); From 2250aa02248528e2c8db0c33c8992076f7b922c9 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 14 Oct 2026 13:55:59 +0200 Subject: [PATCH 08/18] chore: fix formating issues --- package.json | 2 +- .../AbstractConductingEquipment.ts | 12 +- src/SemanticConstraints/AbstractDataObject.ts | 6 +- src/SemanticConstraints/AnyLN.ts | 3 +- src/SemanticConstraints/BDA.ts | 4 +- src/SemanticConstraints/ClientLN.ts | 4 +- src/validate.ts | 4 +- test/SemanticConstraints/BDA.test.ts | 211 ++++++++++++++---- 8 files changed, 192 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 290dbf3..7c23e90 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "test:required-elements": "vitest run test/RequiredElements/", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", - "format": "prettier --write \"src/validate.ts\" \"src/ValidationIssue.ts\" \"src/index.ts\" \"src/RequiredElements/**/*.ts\" \"test/validate*.ts\"", + "format": "prettier --write \"src/validate.ts\" \"src/ValidationIssue.ts\" \"src/index.ts\" \"src/RequiredElements/**/*.ts\" \"src/SemanticConstraints/**/*.ts\" \"test/validate*.ts\"", "format:check": "prettier --check \"src/validate.ts\" \"src/ValidationIssue.ts\" \"src/index.ts\" \"src/RequiredElements/**/*.ts\" \"test/validate*.ts\"", "prepare": "npm run build" }, diff --git a/src/SemanticConstraints/AbstractConductingEquipment.ts b/src/SemanticConstraints/AbstractConductingEquipment.ts index b7a65da..69d1588 100644 --- a/src/SemanticConstraints/AbstractConductingEquipment.ts +++ b/src/SemanticConstraints/AbstractConductingEquipment.ts @@ -5,11 +5,15 @@ import { ValidationIssue } from '../ValidationIssue'; * ConductingEquipment and TransformerWinding are concrete implementations of AbstractConductingEquipment * Rule: AbstractConductingEquipment_unique_name_of_SubEquipment */ -export function AbstractConductingEquipment_unique_name_of_SubEquipment(scl: XMLDocument, docName: string): ValidationIssue[] { +export function AbstractConductingEquipment_unique_name_of_SubEquipment( + scl: XMLDocument, + docName: string +): ValidationIssue[] { return Array.from(scl.querySelectorAll(':root ConductingEquipment, :root TransformerWinding')) - .filter(ace => { - const names = Array.from(ace.querySelectorAll(':scope > SubEquipment')) - .map(se => se.getAttribute('name') || ''); + .filter((ace) => { + const names = Array.from(ace.querySelectorAll(':scope > SubEquipment')).map( + (se) => se.getAttribute('name') || '' + ); return new Set(names).size !== names.length; }) .map((ace): ValidationIssue => ({ diff --git a/src/SemanticConstraints/AbstractDataObject.ts b/src/SemanticConstraints/AbstractDataObject.ts index ff67e69..cd15004 100644 --- a/src/SemanticConstraints/AbstractDataObject.ts +++ b/src/SemanticConstraints/AbstractDataObject.ts @@ -3,7 +3,9 @@ import { ValidationIssue } from '../ValidationIssue'; // Rule: AbstractDataObject_RefersToDOType export function abstractDataObject_RefersToDOType(scl: XMLDocument, docName: string): ValidationIssue[] { // AbstractDataObject is the common base type of DO and SDO - return Array.from(scl.querySelectorAll(':root DataTypeTemplates > LNodeType > DO, :root DataTypeTemplates > DOType > SDO')) + return Array.from( + scl.querySelectorAll(':root DataTypeTemplates > LNodeType > DO, :root DataTypeTemplates > DOType > SDO') + ) .filter((dataObject) => { const type = dataObject.getAttribute('type'); return type && !scl.querySelector(`DataTypeTemplates DOType[id="${type}"]`); @@ -18,4 +20,4 @@ export function abstractDataObject_RefersToDOType(scl: XMLDocument, docName: str element: dataObject, }; }); -} \ No newline at end of file +} diff --git a/src/SemanticConstraints/AnyLN.ts b/src/SemanticConstraints/AnyLN.ts index 2d0e7c1..5614e9f 100644 --- a/src/SemanticConstraints/AnyLN.ts +++ b/src/SemanticConstraints/AnyLN.ts @@ -1,7 +1,8 @@ import { areNamesUnique } from '../Helpers/BaseSimpleTypes'; import { ValidationIssue } from '../ValidationIssue'; -const selector = ':root > IED > AccessPoint > LN, :root > IED > AccessPoint > Server > LDevice >LN0, :root > IED > AccessPoint > Server > LDevice > LN'; +const selector = + ':root > IED > AccessPoint > LN, :root > IED > AccessPoint > Server > LDevice >LN0, :root > IED > AccessPoint > Server > LDevice > LN'; // Rule: AnyLN_LogControl_name_unique export function AnyLN_LogControl_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] { return Array.from(scl.querySelectorAll(selector)) diff --git a/src/SemanticConstraints/BDA.ts b/src/SemanticConstraints/BDA.ts index c512454..08b2659 100644 --- a/src/SemanticConstraints/BDA.ts +++ b/src/SemanticConstraints/BDA.ts @@ -30,7 +30,9 @@ export function BDA_RefersToDATypeorEnumType(scl: XMLDocument, docName: string): return refersToDAType && !dataTypeTemplates?.querySelector(`:scope > DAType[id="${refersToDAType}"]`); } else if (bType === 'Enum') { const refersToEnumType = getAttributeValue(bda, 'type'); - return refersToEnumType && !dataTypeTemplates?.querySelector(`:scope > EnumType[id="${refersToEnumType}"]`); + return ( + refersToEnumType && !dataTypeTemplates?.querySelector(`:scope > EnumType[id="${refersToEnumType}"]`) + ); } return false; }) diff --git a/src/SemanticConstraints/ClientLN.ts b/src/SemanticConstraints/ClientLN.ts index 9676449..ed71a5b 100644 --- a/src/SemanticConstraints/ClientLN.ts +++ b/src/SemanticConstraints/ClientLN.ts @@ -8,9 +8,7 @@ const selector = [ // Rule: ClientLN_RefersToLNodeType export function ClientLN_RefersToLNodeType(scl: XMLDocument, docName: string): ValidationIssue[] { - return Array.from( - scl.querySelectorAll(selector.join(',')) - ) + return Array.from(scl.querySelectorAll(selector.join(','))) .filter((clientLN) => !findReferencedClientLN(scl, clientLN)) .map((clientLN) => ({ severity: 'ERROR', diff --git a/src/validate.ts b/src/validate.ts index 9090ad0..9f136d5 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -36,9 +36,7 @@ export async function* validate( ]; // Run all enabled categories in parallel - const results = await Promise.all( - categories.filter((c) => c.enabled).map((c) => c.run()) - ); + const results = await Promise.all(categories.filter((c) => c.enabled).map((c) => c.run())); // Yield each category's results if they have issues for (const issues of results) { diff --git a/test/SemanticConstraints/BDA.test.ts b/test/SemanticConstraints/BDA.test.ts index 35719a7..606dbfb 100644 --- a/test/SemanticConstraints/BDA.test.ts +++ b/test/SemanticConstraints/BDA.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - BDA_BDAType_allowedWhen_Enum, - BDA_RefersToDATypeorEnumType -} from '../../src/SemanticConstraints/BDA'; +import { BDA_BDAType_allowedWhen_Enum, BDA_RefersToDATypeorEnumType } from '../../src/SemanticConstraints/BDA'; const DOC_NAME = 'test.scd'; @@ -11,30 +8,80 @@ function parseXMLString(xmlString: string): XMLDocument { return new DOMParser().parseFromString(xmlString, 'text/xml'); } -function sclWithBDA(bda: string, templates = ''): string { - return ` - - - ${templates} - ${bda} - - `; -} - describe('BDA', () => { describe('BDA_BDAType_allowedWhen_Enum', () => { - it('should return no issues when type is absent for a primitive bType', () => { - const doc = parseXMLString(sclWithBDA('')); + it('should return no issues when BDA has bType=Struct with type attribute', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + expect(BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when BDA has bType=Enum with type attribute', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + expect(BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when BDA has bType=Struct without type attribute', () => { + const doc = parseXMLString(` + + + + + + + + + + `); expect(BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); }); - it.each(['Struct', 'Enum'])('should allow type when bType is %s', bType => { - const doc = parseXMLString(sclWithBDA(``)); + it('should return no issues when BDA has bType=Enum without type attribute', () => { + const doc = parseXMLString(` + + + + + + + + + + `); expect(BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); }); - it('should report type when bType is neither Struct nor Enum', () => { - const doc = parseXMLString(sclWithBDA('')); + it('should report BDA with type attribute but bType is not Struct or Enum', () => { + const doc = parseXMLString(` + + + + + + + + + + `); const issues = BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME); expect(issues).toHaveLength(1); expect(issues[0]).toMatchObject({ @@ -45,31 +92,117 @@ describe('BDA', () => { }); expect(issues[0].element.tagName).toBe('BDA'); }); + + it('should report BDA with bType=BOOLEAN and type attribute', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + const issues = BDA_BDAType_allowedWhen_Enum(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('BDA_BDAType_allowedWhen_Enum'); + }); }); describe('BDA_RefersToDATypeorEnumType', () => { - it('should return no issues when Struct and Enum types exist', () => { - const doc = parseXMLString(sclWithBDA( - '', - '' - )); + it('should return no issues when BDA with bType=Struct refers to existing DAType', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + expect(BDA_RefersToDATypeorEnumType(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when BDA with bType=Enum refers to existing EnumType', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + expect(BDA_RefersToDATypeorEnumType(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when BDA with bType=INT32 has no type', () => { + const doc = parseXMLString(` + + + + + + + `); expect(BDA_RefersToDATypeorEnumType(doc, DOC_NAME)).toEqual([]); }); - it('should report references to missing DAType and EnumType elements', () => { - const doc = parseXMLString(sclWithBDA( - '' - )); + it('should report BDA with bType=Struct referring to missing DAType', () => { + const doc = parseXMLString(` + + + + + + + `); + const issues = BDA_RefersToDATypeorEnumType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'BDA_RefersToDATypeorEnumType', + filename: DOC_NAME, + message: /BDA \(name=test\) does not refer an existing DAType or EnumType/, + }); + expect(issues[0].element.tagName).toBe('BDA'); + }); + + it('should report BDA with bType=Enum referring to missing EnumType', () => { + const doc = parseXMLString(` + + + + + + + `); + const issues = BDA_RefersToDATypeorEnumType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('BDA_RefersToDATypeorEnumType'); + }); + + it('should report BDA with bType=Struct referring to EnumType', () => { + const doc = parseXMLString(` + + + + + + + + + + `); const issues = BDA_RefersToDATypeorEnumType(doc, DOC_NAME); - expect(issues).toHaveLength(2); - expect(issues.map(issue => issue.message)).toEqual([ - 'BDA (name=structValue) does not refer an existing DAType or EnumType in DataTypeTemplates section', - 'BDA (name=enumValue) does not refer an existing DAType or EnumType in DataTypeTemplates section', - ]); - expect(issues.map(issue => issue.element.tagName)).toEqual(['BDA', 'BDA']); - expect(issues.every(issue => issue.severity === 'ERROR' - && issue.id === 'BDA_RefersToDATypeorEnumType' - && issue.filename === DOC_NAME)).toBe(true); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('BDA_RefersToDATypeorEnumType'); }); }); -}); \ No newline at end of file +}); From 121c801e9fe4a64ac584a572446ccf5e2045f9b3 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 14 Oct 2026 22:46:24 +0200 Subject: [PATCH 09/18] feat(SemConstr): add Control checks --- src/SemanticConstraints/Control.ts | 36 ++++++ src/SemanticConstraints/index.ts | 8 +- test/SemanticConstraints/Control.test.ts | 155 +++++++++++++++++++++++ 3 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 src/SemanticConstraints/Control.ts create mode 100644 test/SemanticConstraints/Control.test.ts diff --git a/src/SemanticConstraints/Control.ts b/src/SemanticConstraints/Control.ts new file mode 100644 index 0000000..0cae866 --- /dev/null +++ b/src/SemanticConstraints/Control.ts @@ -0,0 +1,36 @@ +import { getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +const selector = [ + ':root > IED > AccessPoint > Server > LDevice > LN0 > ReportControl', + ':root > IED > AccessPoint > Server > LDevice > LN > ReportControl', + ':root > IED > AccessPoint > Server > LDevice > LN0 > LogControl', + ':root > IED > AccessPoint > Server > LDevice > LN > LogControl', + ':root > IED > AccessPoint > Server > LDevice > LN0 > GSEControl', + ':root > IED > AccessPoint > Server > LDevice > LN0 > SampledValueControl', +]; + +// Rule: Control_RefersToDataSet +export function Control_RefersToDataSet(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector.join(','))) + .filter((control) => { + const datSet = getAttributeValue(control, 'datSet'); + if (!datSet) return false; + + const parent = control.parentElement; + if (!parent) return true; + + const dataSet = parent.querySelector(`DataSet[name="${datSet}"]`); + return !dataSet; + }) + .map((control) => { + const name = getAttributeValue(control, 'name'); + return { + severity: 'ERROR', + id: 'Control_RefersToDataSet', + filename: docName, + message: `Control (name=${name}) does not refer an existing DataSet`, + element: control, + }; + }); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index 56d45c6..3383ecf 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -7,14 +7,11 @@ export * from './Association'; // export * from './Bay'; export * from './BDA'; export * from './ClientLN'; -// export * from './ConductingEquipment'; -// export * from './SCL'; -// -// Add more exports as files are created export * from './Communication'; +// export * from './ConductingEquipment'; +export * from './Control'; export * from './ConnectedAP'; // export * from './ConnectivityNode'; -// export * from './Control'; // export * from './ControlBlock'; // export * from './DA'; // export * from './DAI'; @@ -43,6 +40,7 @@ export * from './ConnectedAP'; // export * from './ReportControl'; // export * from './SDI'; // export * from './SampledValueControl'; +// export * from './SCL'; // export * from './Server'; // export * from './ServerAt'; // export * from './SubEquipment'; diff --git a/test/SemanticConstraints/Control.test.ts b/test/SemanticConstraints/Control.test.ts new file mode 100644 index 0000000..29f4448 --- /dev/null +++ b/test/SemanticConstraints/Control.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import { Control_RefersToDataSet } from '../../src/SemanticConstraints/Control'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +describe('Control', () => { + describe('Control_RefersToDataSet', () => { + it('should return no issues when a ReportControl refers to an existing DataSet', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + `); + expect(Control_RefersToDataSet(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when a LogControl refers to an existing DataSet', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + `); + expect(Control_RefersToDataSet(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when there are no Control elements', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + `); + expect(Control_RefersToDataSet(doc, DOC_NAME)).toEqual([]); + }); + + it('should report a ReportControl referring to a missing DataSet', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + `); + const issues = Control_RefersToDataSet(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Control_RefersToDataSet', + filename: DOC_NAME, + message: 'Control (name=RC1) does not refer an existing DataSet', + }); + expect(issues[0].element.tagName).toBe('ReportControl'); + }); + + it('should report a GSEControl referring to a missing DataSet', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + `); + const issues = Control_RefersToDataSet(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('Control_RefersToDataSet'); + expect(issues[0].element.tagName).toBe('GSEControl'); + }); + + it('should return no issues when datSet is not set', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + `); + expect(Control_RefersToDataSet(doc, DOC_NAME)).toEqual([]); + }); + + it('should report a SampledValueControl referring to a missing DataSet', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + `); + const issues = Control_RefersToDataSet(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('Control_RefersToDataSet'); + expect(issues[0].element.tagName).toMatch(/^(ReportControl|LogControl|GSEControl|SampledValueControl)$/); + }); + }); +}); From c5fceafafca0a8c71e90d3cabb519db158b474a6 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 14 Oct 2026 22:59:27 +0200 Subject: [PATCH 10/18] feat(SemConstr): add ControlBlock checks --- src/SemanticConstraints/ControlBlock.ts | 32 +++ src/SemanticConstraints/index.ts | 3 +- test/SemanticConstraints/ControlBlock.test.ts | 199 ++++++++++++++++++ 3 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 src/SemanticConstraints/ControlBlock.ts create mode 100644 test/SemanticConstraints/ControlBlock.test.ts diff --git a/src/SemanticConstraints/ControlBlock.ts b/src/SemanticConstraints/ControlBlock.ts new file mode 100644 index 0000000..fdd68b9 --- /dev/null +++ b/src/SemanticConstraints/ControlBlock.ts @@ -0,0 +1,32 @@ +import { getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +const selector = [ + ':root > Communication > SubNetwork > ConnectedAP > GSE', + ':root > Communication > SubNetwork > ConnectedAP > SMV', +]; + +// Rule: ControlBlock_RefersToControlWithIEDName +export function ControlBlock_RefersToControlWithIEDName(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector.join(','))) + .filter((controlBlock) => { + const iedName = controlBlock.closest('ConnectedAP')?.getAttribute('iedName'); + const apName = controlBlock.closest('ConnectedAP')?.getAttribute('apName'); + const ldInst = controlBlock.getAttribute('ldInst'); + const cbName = controlBlock.getAttribute('cbName'); + + const controlSelector = [ + `:root > IED[name="${iedName}"] > AccessPoint[name="${apName}"] > Server > LDevice[inst="${ldInst}"] > LN0 > GSEControl[name="${cbName}"]`, + `:root > IED[name="${iedName}"] > AccessPoint[name="${apName}"] > Server > LDevice[inst="${ldInst}"] > LN0 > SampledValueControl[name="${cbName}"]`, + ]; + + return scl.querySelector(controlSelector.join(',')) === null; + }) + .map((controlBlock) => ({ + severity: 'ERROR', + id: 'ControlBlock_RefersToControlWithIEDName', + filename: docName, + message: 'ControlBlock does not refer an existing Control', + element: controlBlock, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index 3383ecf..48348f4 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -11,8 +11,7 @@ export * from './Communication'; // export * from './ConductingEquipment'; export * from './Control'; export * from './ConnectedAP'; -// export * from './ConnectivityNode'; -// export * from './ControlBlock'; +export * from './ControlBlock'; // export * from './DA'; // export * from './DAI'; // export * from './DAType'; diff --git a/test/SemanticConstraints/ControlBlock.test.ts b/test/SemanticConstraints/ControlBlock.test.ts new file mode 100644 index 0000000..5136b4c --- /dev/null +++ b/test/SemanticConstraints/ControlBlock.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { ControlBlock_RefersToControlWithIEDName } from '../../src/SemanticConstraints/ControlBlock'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +describe('ControlBlock', () => { + describe('ControlBlock_RefersToControlWithIEDName', () => { + it('should return no issues when GSE refers to an existing GSEControl', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + `); + expect(ControlBlock_RefersToControlWithIEDName(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when SMV refers to an existing SampledValueControl', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + `); + expect(ControlBlock_RefersToControlWithIEDName(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when there are no ControlBlock elements', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + `); + expect(ControlBlock_RefersToControlWithIEDName(doc, DOC_NAME)).toEqual([]); + }); + + it('should report GSE referring to a missing GSEControl', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + `); + const issues = ControlBlock_RefersToControlWithIEDName(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'ControlBlock_RefersToControlWithIEDName', + filename: DOC_NAME, + message: 'ControlBlock does not refer an existing Control', + }); + expect(issues[0].element.tagName).toBe('GSE'); + }); + + it('should report SMV referring to a missing SampledValueControl', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + `); + const issues = ControlBlock_RefersToControlWithIEDName(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('ControlBlock_RefersToControlWithIEDName'); + expect(issues[0].element.tagName).toBe('SMV'); + }); + + it('should report GSE with wrong ldInst', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + `); + const issues = ControlBlock_RefersToControlWithIEDName(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('ControlBlock_RefersToControlWithIEDName'); + }); + + it('should report GSE with wrong IED reference', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + `); + const issues = ControlBlock_RefersToControlWithIEDName(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('ControlBlock_RefersToControlWithIEDName'); + }); + }); +}); From bb2fc01a777c1d89b8611d4fae8eb531180f4662 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 14 Oct 2026 23:09:25 +0200 Subject: [PATCH 11/18] feat(SemConstr): add DA checks --- src/SemanticConstraints/DA.ts | 85 ++++++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/DA.test.ts | 314 ++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/DA.ts create mode 100644 test/SemanticConstraints/DA.test.ts diff --git a/src/SemanticConstraints/DA.ts b/src/SemanticConstraints/DA.ts new file mode 100644 index 0000000..c54fbb2 --- /dev/null +++ b/src/SemanticConstraints/DA.ts @@ -0,0 +1,85 @@ +import { getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: DA_unique_ProtNs_type_in_DA_from_DOType +export function DA_unique_ProtNs_type_in_DA_from_DOType(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > DataTypeTemplates > DOType > DA')) + .filter((da) => { + const protNsElements = da.querySelectorAll('ProtNs'); + const types = Array.from(protNsElements).map((p) => p.getAttribute('type') || ''); + const uniqueTypes = new Set(types); + return uniqueTypes.size !== types.length; + }) + .map((da) => ({ + severity: 'ERROR', + id: 'DA_unique_ProtNs_type_in_DA_from_DOType', + filename: docName, + message: 'Unique ProtNs type in DA from DOType', + element: da, + })); +} + +// Rule: DA_DAType_allowedWhen_Enum +export function DA_DAType_allowedWhen_Enum(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > DataTypeTemplates > DOType > DA')) + .filter((da) => { + const bType = getAttributeValue(da, 'bType'); + const type = getAttributeValue(da, 'type'); + return bType !== 'Struct' && bType !== 'Enum' && type !== ''; + }) + .map((da) => ({ + severity: 'ERROR', + id: 'DA_DAType_allowedWhen_Enum', + filename: docName, + message: 'DA.type is allowed only if bType = Struct or Enum', + element: da, + })); +} + +// Rule: DA_RefersToDATypeorEnumType +export function DA_RefersToDATypeorEnumType(scl: XMLDocument, docName: string): ValidationIssue[] { + const dataTypeTemplates = scl.querySelector('DataTypeTemplates'); + return Array.from(scl.querySelectorAll('DA')) + .filter((da) => { + const bType = getAttributeValue(da, 'bType'); + + if (bType === 'Struct') { + const refersToDAType = getAttributeValue(da, 'type'); + return refersToDAType && !dataTypeTemplates?.querySelector(`:scope > DAType[id="${refersToDAType}"]`); + } else if (bType === 'Enum') { + const refersToEnumType = getAttributeValue(da, 'type'); + return ( + refersToEnumType && !dataTypeTemplates?.querySelector(`:scope > EnumType[id="${refersToEnumType}"]`) + ); + } + return false; + }) + .map((da) => { + const name = getAttributeValue(da, 'name'); + return { + severity: 'ERROR', + id: 'DA_RefersToDATypeorEnumType', + filename: docName, + message: `DA (name=${name}) does not refer an existing DAType or EnumType in DataTypeTemplates section`, + element: da, + }; + }); +} + +// Rule: DA_Struct_no_valKind_or_valImport +export function DA_Struct_no_valKind_or_valImport(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll('DA')) + .filter((da) => { + const bType = getAttributeValue(da, 'bType'); + const valImport = getAttributeValue(da, 'valImport'); + const valKind = getAttributeValue(da, 'valKind'); + return bType === 'Struct' && ((valImport && valImport !== 'false') || (valKind && valKind !== 'Set')); + }) + .map((da) => ({ + severity: 'WARNING', + id: 'DA_Struct_no_valKind_or_valImport', + filename: docName, + message: 'The attributes valKind and valImport make no sense for DA elements with bType = Struct', + element: da, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index 48348f4..4af3d50 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -12,7 +12,7 @@ export * from './Communication'; export * from './Control'; export * from './ConnectedAP'; export * from './ControlBlock'; -// export * from './DA'; +export * from './DA'; // export * from './DAI'; // export * from './DAType'; // export * from './DOI'; diff --git a/test/SemanticConstraints/DA.test.ts b/test/SemanticConstraints/DA.test.ts new file mode 100644 index 0000000..65b1dfc --- /dev/null +++ b/test/SemanticConstraints/DA.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest'; + +import { + DA_unique_ProtNs_type_in_DA_from_DOType, + DA_DAType_allowedWhen_Enum, + DA_RefersToDATypeorEnumType, + DA_Struct_no_valKind_or_valImport +} from '../../src/SemanticConstraints/DA'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +describe('DA', () => { + describe('DA_unique_ProtNs_type_in_DA_from_DOType', () => { + it('should return no issues when ProtNs types are unique in DA from DOType', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + expect(DA_unique_ProtNs_type_in_DA_from_DOType(doc, DOC_NAME)).toEqual([]); + }); + + it('should report duplicate ProtNs types in DA from DOType', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + const issues = DA_unique_ProtNs_type_in_DA_from_DOType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DA_unique_ProtNs_type_in_DA_from_DOType', + filename: DOC_NAME, + message: 'Unique ProtNs type in DA from DOType', + }); + expect(issues[0].element.tagName).toBe('DA'); + }); + + it('should return no issues when DA has no ProtNs', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_unique_ProtNs_type_in_DA_from_DOType(doc, DOC_NAME)).toEqual([]); + }); + }); + + describe('DA_DAType_allowedWhen_Enum', () => { + it('should return no issues when DA has bType=Struct with type attribute', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_DAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DA has bType=Enum with type attribute', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_DAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DA has bType=Struct without type attribute', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_DAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DA has bType=Enum without type attribute', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_DAType_allowedWhen_Enum(doc, DOC_NAME)).toEqual([]); + }); + + it('should report DA with type attribute but bType is not Struct or Enum', () => { + const doc = parseXMLString(` + + + + + + + `); + const issues = DA_DAType_allowedWhen_Enum(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DA_DAType_allowedWhen_Enum', + filename: DOC_NAME, + message: 'DA.type is allowed only if bType = Struct or Enum', + }); + expect(issues[0].element.tagName).toBe('DA'); + }); + + it('should report DA with bType=BOOLEAN and type attribute', () => { + const doc = parseXMLString(` + + + + + + + `); + const issues = DA_DAType_allowedWhen_Enum(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('DA_DAType_allowedWhen_Enum'); + }); + }); + + describe('DA_RefersToDATypeorEnumType', () => { + it('should return no issues when DA with bType=Struct refers to existing DAType', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + expect(DA_RefersToDATypeorEnumType(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DA with bType=Enum refers to existing EnumType', () => { + const doc = parseXMLString(` + + + + + + + + + + `); + expect(DA_RefersToDATypeorEnumType(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DA with bType=INT32 has no type', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_RefersToDATypeorEnumType(doc, DOC_NAME)).toEqual([]); + }); + + it('should report DA with bType=Struct referring to missing DAType', () => { + const doc = parseXMLString(` + + + + + + + `); + const issues = DA_RefersToDATypeorEnumType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DA_RefersToDATypeorEnumType', + filename: DOC_NAME, + message: /DA \(name=da1\) does not refer an existing DAType or EnumType/, + }); + expect(issues[0].element.tagName).toBe('DA'); + }); + + it('should report DA with bType=Enum referring to missing EnumType', () => { + const doc = parseXMLString(` + + + + + + + `); + const issues = DA_RefersToDATypeorEnumType(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('DA_RefersToDATypeorEnumType'); + }); + }); + + describe('DA_Struct_no_valKind_or_valImport', () => { + it('should return no issues when DA with bType=Struct has no valKind or valImport', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_Struct_no_valKind_or_valImport(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DA with bType=Struct has valImport=false', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_Struct_no_valKind_or_valImport(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DA with bType=Struct has valKind=Set', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_Struct_no_valKind_or_valImport(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DA with bType=INT32 has valKind and valImport', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DA_Struct_no_valKind_or_valImport(doc, DOC_NAME)).toEqual([]); + }); + + it('should warn when DA with bType=Struct has valKind=RO', () => { + const doc = parseXMLString(` + + + + + + + `); + const issues = DA_Struct_no_valKind_or_valImport(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'WARNING', + id: 'DA_Struct_no_valKind_or_valImport', + filename: DOC_NAME, + message: 'The attributes valKind and valImport make no sense for DA elements with bType = Struct', + }); + expect(issues[0].element.tagName).toBe('DA'); + }); + + it('should warn when DA with bType=Struct has valImport=true', () => { + const doc = parseXMLString(` + + + + + + + `); + const issues = DA_Struct_no_valKind_or_valImport(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].id).toBe('DA_Struct_no_valKind_or_valImport'); + }); + }); +}); From a2b709e829a98527baf8b4359a9998a58bed2c88 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Thu, 15 Oct 2026 11:15:12 +0200 Subject: [PATCH 12/18] feat(SemConstr): add DAI checks --- src/Helpers/BaseSimpleTypes.ts | 35 +++ src/SemanticConstraints/DAI.ts | 18 ++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/DAI.test.ts | 376 +++++++++++++++++++++++++++ 4 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/DAI.ts create mode 100644 test/SemanticConstraints/DAI.test.ts diff --git a/src/Helpers/BaseSimpleTypes.ts b/src/Helpers/BaseSimpleTypes.ts index 9fe2e79..a4e9150 100644 --- a/src/Helpers/BaseSimpleTypes.ts +++ b/src/Helpers/BaseSimpleTypes.ts @@ -87,3 +87,38 @@ export function findReferencedClientLN(scl: XMLDocument, ref: Element): Element return true; }) || null; } + +/** + * Resolves a DA reference from DAI element in IED to DA in DataTypeTemplates. + * The function follows the path: LN/LN0 > DOI > (SDI*) > DAI + * and finds corresponding: LNodeType > DO > (SDO*) > DOType > DA (or nested in DAType > BDA) + * Returns null when the reference cannot be resolved. + */ +export function findReferencedDA(scl: XMLDocument, dai: Element): Element | null { + + const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); + if (!dataTypeTemplates) return null; + + const reference: string[] = [dai.getAttribute('name') || '']; + let parent: Element | null = dai.parentElement; + while (parent && !(parent.tagName === 'LN' || parent.tagName === 'LN0')) { + reference.unshift(parent.getAttribute('name') || ''); + parent = parent.parentElement; + } + + const lnType = dai.closest('LN, LN0')?.getAttribute('lnType') || ''; + let dataType: Element | null = dataTypeTemplates?.querySelector(`:scope > LNodeType[id="${lnType}"]`) || null; + + for (const ref of reference) { + if (!dataType) return null; + + let child: Element | null = dataType.querySelector(`:scope > DO[name="${ref}"], :scope > SDO[name="${ref}"], :scope > DA[name="${ref}"], :scope > BDA[name="${ref}"]`) || null; + + if (!child) return null; + if (child && reference[reference.length - 1] === ref) return child; + + dataType = dataTypeTemplates?.querySelector(`:scope >DOType[id="${child.getAttribute('type')}"], :scope > DAType[id="${child.getAttribute('type')}"]`) || null; + } + + return null; +} diff --git a/src/SemanticConstraints/DAI.ts b/src/SemanticConstraints/DAI.ts new file mode 100644 index 0000000..3bf927c --- /dev/null +++ b/src/SemanticConstraints/DAI.ts @@ -0,0 +1,18 @@ +import { findReferencedDA, getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: DAI_RefersToAbstractDataAttribute +export function DAI_RefersToAbstractDataAttribute(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll('DAI')) + .filter((dai) => { + const name = getAttributeValue(dai, 'name'); + return name && !findReferencedDA(scl, dai); + }) + .map((dai) => ({ + severity: 'ERROR', + id: 'DAI_RefersToAbstractDataAttribute', + filename: docName, + message: `DAI (name=${getAttributeValue(dai, 'name')}) does not refer an existing DA in DataTypeTemplates section`, + element: dai, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index 4af3d50..6213b6b 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -13,7 +13,7 @@ export * from './Control'; export * from './ConnectedAP'; export * from './ControlBlock'; export * from './DA'; -// export * from './DAI'; +export * from './DAI'; // export * from './DAType'; // export * from './DOI'; // export * from './DOType'; diff --git a/test/SemanticConstraints/DAI.test.ts b/test/SemanticConstraints/DAI.test.ts new file mode 100644 index 0000000..a4fa908 --- /dev/null +++ b/test/SemanticConstraints/DAI.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from 'vitest'; + +import { DAI_RefersToAbstractDataAttribute } from '../../src/SemanticConstraints/DAI'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +// Complete SCL with all three correct paths +const COMPLETE_DAI_DATATYPE_TEMPLATES = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const SERVER_IED_WITH_CORRECT_DAI_PATHS = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +describe('DAI', () => { + describe('DAI_RefersToAbstractDataAttribute', () => { + it('should return no issues for valid DAI path: PMMXU1 -> PhV -> phsA -> cVal -> mag -> f', () => { + const doc = parseXMLString(` + + ${COMPLETE_DAI_DATATYPE_TEMPLATES} + + + + + + + + + + + + + + + + + + + + `); + expect(DAI_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for valid DAI path: Phase1PTCO1 -> StrVal -> mag -> f', () => { + const doc = parseXMLString(` + + ${COMPLETE_DAI_DATATYPE_TEMPLATES} + + + + + + + + + + + + + + + + `); + expect(DAI_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for valid DAI path: Phase1TCTR1 -> AmpSv -> instMag -> f', () => { + const doc = parseXMLString(` + + ${COMPLETE_DAI_DATATYPE_TEMPLATES} + + + + + + + + + + + + + + + + `); + expect(DAI_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for all three valid DAI paths in same SCL', () => { + const doc = parseXMLString(` + + ${COMPLETE_DAI_DATATYPE_TEMPLATES} + ${SERVER_IED_WITH_CORRECT_DAI_PATHS} + `); + expect(DAI_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + + // Incorrect versions + + it('should report DAI with missing DA in DataTypeTemplates for PMMXU1 path', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `); + const issues = DAI_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DAI_RefersToAbstractDataAttribute', + filename: DOC_NAME, + message: 'DAI (name=f) does not refer an existing DA in DataTypeTemplates section', + }); + expect(issues[0].element.tagName).toBe('DAI'); + }); + + it('should report DAI with broken SDI chain for Phase1PTCO1 path', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + `); + const issues = DAI_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('DAI'); + }); + + it('should report DAI with missing LNodeType for Phase1TCTR1 path', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + `); + const issues = DAI_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('DAI'); + }); + + it('should report DAI with missing DO in LNodeType for PMMXU1 path', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `); + const issues = DAI_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('DAI'); + }); + + it('should report all three DAI elements when DataTypeTemplates is missing', () => { + const doc = parseXMLString(` + + ${SERVER_IED_WITH_CORRECT_DAI_PATHS} + `); + const issues = DAI_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(3); + expect(issues.every(i => i.element.tagName === 'DAI')).toBe(true); + }); + + it('should return no issues when DAI has no name attribute', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + `); + expect(DAI_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + }); +}); From bbf979d925a2e9e0b9db166d89b2fb3e7d3844d3 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Thu, 15 Oct 2026 11:31:35 +0200 Subject: [PATCH 13/18] feat(SemConstr): add DAType checks --- src/SemanticConstraints/DAType.ts | 46 +++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/DAType.test.ts | 236 ++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/DAType.ts create mode 100644 test/SemanticConstraints/DAType.test.ts diff --git a/src/SemanticConstraints/DAType.ts b/src/SemanticConstraints/DAType.ts new file mode 100644 index 0000000..f9602b9 --- /dev/null +++ b/src/SemanticConstraints/DAType.ts @@ -0,0 +1,46 @@ +import { areNamesUnique, getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates +export function DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates( + scl: XMLDocument, + docName: string +): ValidationIssue[] { + const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); + if (!dataTypeTemplates) return []; + + return Array.from(dataTypeTemplates.querySelectorAll(':scope > DAType')) + .filter((daType) => !areNamesUnique(daType.querySelectorAll(':scope > BDA'))) + .map((daType) => ({ + severity: 'ERROR', + id: 'DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates', + filename: docName, + message: 'Unique BDA name in DAType from DataTypeTemplates', + element: daType, + })); +} + +// Rule: DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates +export function DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates( + scl: XMLDocument, + docName: string +): ValidationIssue[] { + const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); + if (!dataTypeTemplates) return []; + + return Array.from(dataTypeTemplates.querySelectorAll(':scope > DAType')) + .filter((daType) => { + const protNss = daType.querySelectorAll(':scope > ProtNs'); + const types = Array.from(protNss).map((protNs) => getAttributeValue(protNs, 'type')); + const nonEmptyTypes = types.filter((type) => type !== ''); + const uniqueTypes = new Set(nonEmptyTypes); + return uniqueTypes.size !== nonEmptyTypes.length; + }) + .map((daType) => ({ + severity: 'ERROR', + id: 'DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates', + filename: docName, + message: 'Unique ProtNs type in DAType from DataTypeTemplates', + element: daType, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index 6213b6b..d55f3bc 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -14,7 +14,7 @@ export * from './ConnectedAP'; export * from './ControlBlock'; export * from './DA'; export * from './DAI'; -// export * from './DAType'; +export * from './DAType'; // export * from './DOI'; // export * from './DOType'; // export * from './EquipmentContainer'; diff --git a/test/SemanticConstraints/DAType.test.ts b/test/SemanticConstraints/DAType.test.ts new file mode 100644 index 0000000..4ae5991 --- /dev/null +++ b/test/SemanticConstraints/DAType.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; + +import { + DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates, + DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates, +} from '../../src/SemanticConstraints/DAType'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +describe('DAType', () => { + describe('DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates', () => { + it('should return no issues when all BDA names are unique', () => { + const doc = parseXMLString(` + + + + + + + + + `); + expect(DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DAType has a single BDA', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DAType has no BDA elements', () => { + const doc = parseXMLString(` + + + + + `); + expect(DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when multiple DATypes each have unique BDA names', () => { + const doc = parseXMLString(` + + + + + + + + + + + + `); + expect(DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should report duplicate BDA names within a single DAType', () => { + const doc = parseXMLString(` + + + + + + + + + `); + const issues = DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates', + filename: DOC_NAME, + message: 'Unique BDA name in DAType from DataTypeTemplates', + }); + expect(issues[0].element.tagName).toBe('DAType'); + }); + + it('should report multiple DATypes with duplicate BDA names', () => { + const doc = parseXMLString(` + + + + + + + + + + + + `); + const issues = DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates(doc, DOC_NAME); + expect(issues).toHaveLength(2); + expect(issues.every((issue) => issue.element.tagName === 'DAType')).toBe(true); + }); + + it('should return no issues when no DataTypeTemplates exists', () => { + const doc = parseXMLString(` + + + `); + expect(DAType_unique_BDA_name_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + }); + + describe('DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates', () => { + it('should return no issues when all ProtNs types are unique', () => { + const doc = parseXMLString(` + + + + + + + + + `); + expect(DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when ProtNs has a single entry', () => { + const doc = parseXMLString(` + + + + + + + `); + expect(DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DAType has no ProtNs elements', () => { + const doc = parseXMLString(` + + + + + `); + expect(DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when ProtNs has empty type attributes', () => { + const doc = parseXMLString(` + + + + + + + + + `); + expect(DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when multiple DATypes each have unique ProtNs types', () => { + const doc = parseXMLString(` + + + + + + + + + + + + `); + expect(DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + + it('should report duplicate ProtNs types within a single DAType', () => { + const doc = parseXMLString(` + + + + + + + + + `); + const issues = DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates', + filename: DOC_NAME, + message: 'Unique ProtNs type in DAType from DataTypeTemplates', + }); + expect(issues[0].element.tagName).toBe('DAType'); + }); + + it('should report multiple DATypes with duplicate ProtNs types', () => { + const doc = parseXMLString(` + + + + + + + + + + + + `); + const issues = DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates(doc, DOC_NAME); + expect(issues).toHaveLength(2); + expect(issues.every((issue) => issue.element.tagName === 'DAType')).toBe(true); + }); + + it('should return no issues when no DataTypeTemplates exists', () => { + const doc = parseXMLString(` + + + `); + expect(DAType_unique_ProtNs_type_in_DAType_from_DataTypeTemplates(doc, DOC_NAME)).toEqual([]); + }); + }); +}); From ec27adf26718be50be4f3dd7c8f3f42ce20ec4dc Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Thu, 15 Oct 2026 12:23:16 +0200 Subject: [PATCH 14/18] feat(SemConstr): add DOI checks --- src/Helpers/BaseSimpleTypes.ts | 12 +- src/SemanticConstraints/DAI.ts | 4 +- src/SemanticConstraints/DOI.ts | 70 +++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/DOI.test.ts | 436 +++++++++++++++++++++++++++ 5 files changed, 515 insertions(+), 9 deletions(-) create mode 100644 src/SemanticConstraints/DOI.ts create mode 100644 test/SemanticConstraints/DOI.test.ts diff --git a/src/Helpers/BaseSimpleTypes.ts b/src/Helpers/BaseSimpleTypes.ts index a4e9150..2c4b426 100644 --- a/src/Helpers/BaseSimpleTypes.ts +++ b/src/Helpers/BaseSimpleTypes.ts @@ -89,24 +89,24 @@ export function findReferencedClientLN(scl: XMLDocument, ref: Element): Element } /** - * Resolves a DA reference from DAI element in IED to DA in DataTypeTemplates. + * Resolves a DO, SDO, DA, BDA reference from DOI, SDI, DAI element in IED. * The function follows the path: LN/LN0 > DOI > (SDI*) > DAI - * and finds corresponding: LNodeType > DO > (SDO*) > DOType > DA (or nested in DAType > BDA) + * and finds corresponding: LNodeType > DO > DOType > DO(SDO*) > DOType > DA (or nested in DAType > BDA) * Returns null when the reference cannot be resolved. */ -export function findReferencedDA(scl: XMLDocument, dai: Element): Element | null { +export function findReferencedDataType(scl: XMLDocument, doiOrSdiOrDai: Element): Element | null { const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); if (!dataTypeTemplates) return null; - const reference: string[] = [dai.getAttribute('name') || '']; - let parent: Element | null = dai.parentElement; + const reference: string[] = [doiOrSdiOrDai.getAttribute('name') || '']; + let parent: Element | null = doiOrSdiOrDai.parentElement; while (parent && !(parent.tagName === 'LN' || parent.tagName === 'LN0')) { reference.unshift(parent.getAttribute('name') || ''); parent = parent.parentElement; } - const lnType = dai.closest('LN, LN0')?.getAttribute('lnType') || ''; + const lnType = doiOrSdiOrDai.closest('LN, LN0')?.getAttribute('lnType') || ''; let dataType: Element | null = dataTypeTemplates?.querySelector(`:scope > LNodeType[id="${lnType}"]`) || null; for (const ref of reference) { diff --git a/src/SemanticConstraints/DAI.ts b/src/SemanticConstraints/DAI.ts index 3bf927c..57c81fc 100644 --- a/src/SemanticConstraints/DAI.ts +++ b/src/SemanticConstraints/DAI.ts @@ -1,4 +1,4 @@ -import { findReferencedDA, getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { findReferencedDataType, getAttributeValue } from '../Helpers/BaseSimpleTypes'; import { ValidationIssue } from '../ValidationIssue'; // Rule: DAI_RefersToAbstractDataAttribute @@ -6,7 +6,7 @@ export function DAI_RefersToAbstractDataAttribute(scl: XMLDocument, docName: str return Array.from(scl.querySelectorAll('DAI')) .filter((dai) => { const name = getAttributeValue(dai, 'name'); - return name && !findReferencedDA(scl, dai); + return name && !findReferencedDataType(scl, dai); }) .map((dai) => ({ severity: 'ERROR', diff --git a/src/SemanticConstraints/DOI.ts b/src/SemanticConstraints/DOI.ts new file mode 100644 index 0000000..068aa1e --- /dev/null +++ b/src/SemanticConstraints/DOI.ts @@ -0,0 +1,70 @@ +import { findReferencedDataType, getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +const selector = [ + ':root > IED > AccessPoint > Server > LDevice > LN0 > DOI', + ':root > IED > AccessPoint > Server > LDevice > LN > DOI', +]; + +// Rule: DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN +export function DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector.join(','))) + .filter((doi) => { + const dais = doi.querySelectorAll(':scope > DAI'); + const sdis = doi.querySelectorAll(':scope > SDI'); + + const combinations = [ + ...Array.from(dais).map( + (dai) => `${getAttributeValue(dai, 'ix') || '0'}|${getAttributeValue(dai, 'name')}` + ), + ...Array.from(sdis).map( + (sdi) => `${getAttributeValue(sdi, 'ix') || '0'}|${getAttributeValue(sdi, 'name')}` + ), + ]; + + const uniqueCombinations = new Set(combinations); + return uniqueCombinations.size !== combinations.length; + }) + .map((doi) => ({ + severity: 'ERROR', + id: 'DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN', + filename: docName, + message: 'Unique DAI and SDI ix and name combination in DOI from AnyLN', + element: doi, + })); +} + +// Rule: DOI_AnyLN_LNType_reference +export function DOI_AnyLN_LNType_reference(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector.join(','))) + .filter((doi) => { + const anyLN = doi.closest('LN, LN0'); + return !anyLN || !getAttributeValue(anyLN, 'lnType'); + }) + .map((doi) => ({ + severity: 'ERROR', + id: 'DOI_AnyLN_LNType_reference', + filename: docName, + message: 'AnyLN with DOI must refer to a LNodeType', + element: doi, + })); +} + +// Rule: DOI_RefersToDO +export function DOI_RefersToDO(scl: XMLDocument, docName: string): ValidationIssue[] { + const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); + if (!dataTypeTemplates) return []; + + return Array.from(scl.querySelectorAll(selector.join(','))) + .filter((doi) => { + const name = getAttributeValue(doi, 'name'); + return name && !findReferencedDataType(scl, doi); + }) + .map((doi) => ({ + severity: 'ERROR', + id: 'DOI_RefersToDO', + filename: docName, + message: `DOI (name=${getAttributeValue(doi, 'name')}) does not refer an existing DO in DataTypeTemplates section`, + element: doi, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index d55f3bc..4e9aa1f 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -15,7 +15,7 @@ export * from './ControlBlock'; export * from './DA'; export * from './DAI'; export * from './DAType'; -// export * from './DOI'; +export * from './DOI'; // export * from './DOType'; // export * from './EquipmentContainer'; // export * from './FCDA'; diff --git a/test/SemanticConstraints/DOI.test.ts b/test/SemanticConstraints/DOI.test.ts new file mode 100644 index 0000000..e98ed22 --- /dev/null +++ b/test/SemanticConstraints/DOI.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it } from 'vitest'; + +import { + DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN, + DOI_AnyLN_LNType_reference, + DOI_RefersToDO, +} from '../../src/SemanticConstraints/DOI'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +const DATA_TYPE_TEMPLATES = ` + + + + + + + + + + + + + + +`; + +describe('DOI', () => { + describe('DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN', () => { + it('should return no issues when DAI and SDI have unique ix+name combinations', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + + + + + `); + expect(DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for MMXU1 -> PhV path with unique combinations', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + + + + + `); + expect(DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for PTOC1 -> StrVal path with unique combinations', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + + + + `); + expect(DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN(doc, DOC_NAME)).toEqual([]); + }); + + it('should report duplicate DAI ix+name combination', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + + + + + `); + const issues = DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN', + filename: DOC_NAME, + message: 'Unique DAI and SDI ix and name combination in DOI from AnyLN', + }); + expect(issues[0].element.tagName).toBe('DOI'); + }); + + it('should report duplicate SDI ix+name combination', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + + + + + `); + const issues = DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('DOI'); + }); + + it('should report when DAI and SDI share same ix+name combination', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + + + + `); + const issues = DOI_unique_DAI_SDI_ix_name_in_DOI_from_AnyLN(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('DOI'); + }); + }); + + describe('DOI_AnyLN_LNType_reference', () => { + it('should return no issues when LN has lnType', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + `); + expect(DOI_AnyLN_LNType_reference(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when LN0 has lnType', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + `); + expect(DOI_AnyLN_LNType_reference(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for PTOC1 -> StrVal path with lnType', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + `); + expect(DOI_AnyLN_LNType_reference(doc, DOC_NAME)).toEqual([]); + }); + + it('should report DOI in LN without lnType', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + `); + const issues = DOI_AnyLN_LNType_reference(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DOI_AnyLN_LNType_reference', + filename: DOC_NAME, + message: 'AnyLN with DOI must refer to a LNodeType', + }); + expect(issues[0].element.tagName).toBe('DOI'); + }); + + it('should report DOI in LN0 without lnType', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + `); + const issues = DOI_AnyLN_LNType_reference(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('DOI'); + }); + + it('should report multiple DOIs without lnType', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + + `); + const issues = DOI_AnyLN_LNType_reference(doc, DOC_NAME); + expect(issues).toHaveLength(2); + expect(issues.every((issue) => issue.element.tagName === 'DOI')).toBe(true); + }); + }); + + describe('DOI_RefersToDO', () => { + it('should return no issues when DOI refers to existing DO in MMXU1', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + `); + expect(DOI_RefersToDO(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DOI refers to existing DO in PTOC1', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + `); + expect(DOI_RefersToDO(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when DOI has no name attribute', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + `); + expect(DOI_RefersToDO(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when no DataTypeTemplates exists', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + `); + expect(DOI_RefersToDO(doc, DOC_NAME)).toEqual([]); + }); + + it('should report DOI referring to missing DO', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + `); + const issues = DOI_RefersToDO(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'DOI_RefersToDO', + filename: DOC_NAME, + message: 'DOI (name=MISSING_DO) does not refer an existing DO in DataTypeTemplates section', + }); + expect(issues[0].element.tagName).toBe('DOI'); + }); + + it('should report multiple DOIs referring to missing DOs', () => { + const doc = parseXMLString(` + + ${DATA_TYPE_TEMPLATES} + + + + + + + + + + + + + `); + const issues = DOI_RefersToDO(doc, DOC_NAME); + expect(issues).toHaveLength(2); + expect(issues.every((issue) => issue.element.tagName === 'DOI')).toBe(true); + }); + }); +}); From d990b7c9d53572d4a0b76a196a7ce5e08953d3f7 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Thu, 15 Oct 2026 22:39:55 +0200 Subject: [PATCH 15/18] feat(SemConstr): add FCDA checks --- src/Helpers/BaseSimpleTypes.ts | 45 ++++- src/SemanticConstraints/FCDA.ts | 22 +++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/FCDA.test.ts | 235 ++++++++++++++++++++++++++ 4 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 src/SemanticConstraints/FCDA.ts create mode 100644 test/SemanticConstraints/FCDA.test.ts diff --git a/src/Helpers/BaseSimpleTypes.ts b/src/Helpers/BaseSimpleTypes.ts index 2c4b426..6104ac1 100644 --- a/src/Helpers/BaseSimpleTypes.ts +++ b/src/Helpers/BaseSimpleTypes.ts @@ -94,21 +94,50 @@ export function findReferencedClientLN(scl: XMLDocument, ref: Element): Element * and finds corresponding: LNodeType > DO > DOType > DO(SDO*) > DOType > DA (or nested in DAType > BDA) * Returns null when the reference cannot be resolved. */ -export function findReferencedDataType(scl: XMLDocument, doiOrSdiOrDai: Element): Element | null { +export function findReferencedDataType(scl: XMLDocument, doiOrSdiOrDaiOrFcda: Element): Element | null { const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); if (!dataTypeTemplates) return null; - const reference: string[] = [doiOrSdiOrDai.getAttribute('name') || '']; - let parent: Element | null = doiOrSdiOrDai.parentElement; - while (parent && !(parent.tagName === 'LN' || parent.tagName === 'LN0')) { - reference.unshift(parent.getAttribute('name') || ''); - parent = parent.parentElement; + const reference: string[] = []; + let lnType: string = ''; + if (doiOrSdiOrDaiOrFcda.tagName === 'FCDA') { + const fcda = doiOrSdiOrDaiOrFcda; + const iedName = getAttributeValue(fcda, 'iedName'); + const ldInst = getAttributeValue(fcda, 'ldInst'); + const prefix = getAttributeValue(fcda, 'prefix') || ''; + const lnClass = getAttributeValue(fcda, 'lnClass'); + const lnInst = getAttributeValue(fcda, 'lnInst') || ''; + const doName = getAttributeValue(fcda, 'doName'); + const daName = getAttributeValue(fcda, 'daName'); + + const selector = [ + `:root > IED[name="${iedName}"] > AccessPoint > Server > LDevice[inst="${ldInst}"] > LN0[lnClass="${lnClass}"]`, + `:root > IED[name="${iedName}"] > AccessPoint > Server > LDevice[inst="${ldInst}"] > LN[lnClass="${lnClass}"]` + ]; + + lnType = Array.from(scl.querySelectorAll(selector.join(', '))).find(ln => { + if (prefix !== getAttributeValue(ln, 'prefix')) return false; + if (lnInst !== getAttributeValue(ln, 'inst')) return false; + return true; + })?.getAttribute('lnType') || ''; + + reference.push(...doName.split('.')); + if (!!daName) reference.push(...daName.split('.')); + + } else { + const doiOrSdiOrDai = doiOrSdiOrDaiOrFcda; + reference.push(doiOrSdiOrDai.getAttribute('name') || ''); + let parent: Element | null = doiOrSdiOrDai.parentElement; + while (parent && !(parent.tagName === 'LN' || parent.tagName === 'LN0')) { + reference.unshift(parent.getAttribute('name') || ''); + parent = parent.parentElement; + } + + lnType = doiOrSdiOrDai.closest('LN, LN0')?.getAttribute('lnType') || ''; } - const lnType = doiOrSdiOrDai.closest('LN, LN0')?.getAttribute('lnType') || ''; let dataType: Element | null = dataTypeTemplates?.querySelector(`:scope > LNodeType[id="${lnType}"]`) || null; - for (const ref of reference) { if (!dataType) return null; diff --git a/src/SemanticConstraints/FCDA.ts b/src/SemanticConstraints/FCDA.ts new file mode 100644 index 0000000..06620db --- /dev/null +++ b/src/SemanticConstraints/FCDA.ts @@ -0,0 +1,22 @@ +import { findReferencedDataType, getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +const selector = [ + ':root > IED > AccessPoint > Server > LDevice > LN0 > FCDA', + ':root > IED > AccessPoint > Server > LDevice > LN > FCDA', +]; + +// Rule: FCDA_RefersToAbstractDataAttribute +export function FCDA_RefersToAbstractDataAttribute(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selector.join(','))) + .filter((fcda) => { + return !findReferencedDataType(scl, fcda); + }) + .map((fcda) => ({ + severity: 'ERROR', + id: 'FCDA_RefersToAbstractDataAttribute', + filename: docName, + message: 'FCDA does not refer any existing DA or BDA in DataTypeTemplates section', + element: fcda, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index 4e9aa1f..8f3deae 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -18,7 +18,7 @@ export * from './DAType'; export * from './DOI'; // export * from './DOType'; // export * from './EquipmentContainer'; -// export * from './FCDA'; +export * from './FCDA'; // export * from './GSE'; // export * from './GSEControl'; // export * from './GeneralEquipment'; diff --git a/test/SemanticConstraints/FCDA.test.ts b/test/SemanticConstraints/FCDA.test.ts new file mode 100644 index 0000000..6b00be5 --- /dev/null +++ b/test/SemanticConstraints/FCDA.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; + +import { FCDA_RefersToAbstractDataAttribute } from '../../src/SemanticConstraints/FCDA'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +const DATA_TYPE_TEMPLATES = ` + + + + + + + + + + + + + + + + + + + + + + +`; + +describe('FCDA', () => { + describe('FCDA_RefersToAbstractDataAttribute', () => { + it('should return no issues when FCDA refers to existing DA in MMXU1', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + ${DATA_TYPE_TEMPLATES} + `); + expect(FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when FCDA refers to existing BDA in MMXU1', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + ${DATA_TYPE_TEMPLATES} + `); + expect(FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when FCDA refers to existing DA in PTOC1', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + ${DATA_TYPE_TEMPLATES} + `); + expect(FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when no DataTypeTemplates exists', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + `); + const issues = FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'FCDA_RefersToAbstractDataAttribute', + filename: DOC_NAME, + message: 'FCDA does not refer any existing DA or BDA in DataTypeTemplates section', + }); + expect(issues[0].element.tagName).toBe('FCDA'); + }); + + it('should report FCDA referring to missing DA', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + ${DATA_TYPE_TEMPLATES} + `); + const issues = FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'FCDA_RefersToAbstractDataAttribute', + filename: DOC_NAME, + message: 'FCDA does not refer any existing DA or BDA in DataTypeTemplates section', + }); + expect(issues[0].element.tagName).toBe('FCDA'); + }); + + it('should report FCDA referring to missing BDA', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + ${DATA_TYPE_TEMPLATES} + `); + const issues = FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('FCDA'); + }); + + it('should report FCDA referring to missing DO', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + ${DATA_TYPE_TEMPLATES} + `); + const issues = FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('FCDA'); + }); + + it('should report multiple FCDAs referring to missing DA/BDA', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + ${DATA_TYPE_TEMPLATES} + `); + const issues = FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME); + expect(issues).toHaveLength(2); + expect(issues.every((issue) => issue.element.tagName === 'FCDA')).toBe(true); + }); + + it('should return no issues for valid FCDAs in both LN0 and LN', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + ${DATA_TYPE_TEMPLATES} + `); + expect(FCDA_RefersToAbstractDataAttribute(doc, DOC_NAME)).toEqual([]); + }); + }); +}); From e88820d5d77c28f2defcb06aee1d967122c95ebe Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Thu, 15 Oct 2026 23:09:16 +0200 Subject: [PATCH 16/18] feat(SemConstr): add GSE checks --- src/SemanticConstraints/GSE.ts | 51 +++++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/GSE.test.ts | 317 +++++++++++++++++++++++++++ 3 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/GSE.ts create mode 100644 test/SemanticConstraints/GSE.test.ts diff --git a/src/SemanticConstraints/GSE.ts b/src/SemanticConstraints/GSE.ts new file mode 100644 index 0000000..ef7facd --- /dev/null +++ b/src/SemanticConstraints/GSE.ts @@ -0,0 +1,51 @@ +import { getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: GSE_for_L2_GOOSE_has_right_addresses +export function GSE_for_L2_GOOSE_has_right_addresses(scl: XMLDocument, docName: string): ValidationIssue[] { + const requiredTypes = new Set(['VLAN-ID', 'APPID', 'VLAN-PRIORITY', 'MAC-Address']); + + return Array.from(scl.querySelectorAll(':root > Communication > SubNetwork > ConnectedAP > GSE')) + .filter((gse) => { + const iedName = gse.parentElement?.getAttribute('iedName') ?? ''; + const apName = gse.parentElement?.getAttribute('apName') ?? ''; + const ldInst = getAttributeValue(gse, 'ldInst'); + const cbName = getAttributeValue(gse, 'cbName'); + + const protocol = scl + .querySelector( + `:root > IED[name="${iedName}"] > AccessPoint[name="${apName}"] > Server > LDevice[inst="${ldInst}"] > LN0 > GSEControl[name="${cbName}"]` + ) + ?.querySelector('Protocol'); + if (protocol) return false; // R-GOOSE has Protocol, no validation needed + + // L2 GOOSE - must have all required address types and unique types + const address = gse.querySelector(':scope > Address'); + if (!address) return false; + + const foundTypes = new Set(); + Array.from(address.querySelectorAll(':scope > P')).forEach((p) => { + const type = getAttributeValue(p, 'type'); + if (type) foundTypes.add(type); + }); + + // Check all required types are present + for (const requiredType of requiredTypes) { + if (!foundTypes.has(requiredType)) { + return true; + } + } + + // Check uniqueness of P types + const types = Array.from(address.querySelectorAll(':scope > P')).map((p) => getAttributeValue(p, 'type')); + const uniqueTypes = new Set(types.filter((t) => t !== '')); + return uniqueTypes.size !== types.filter((t) => t !== '').length; + }) + .map((gse) => ({ + severity: 'ERROR', + id: 'GSE_for_L2_GOOSE_has_right_addresses', + filename: docName, + message: 'GSE for L2 GOOSE should have APPID VLAN_ID VLAN-PRIORITY and MAC-Address', + element: gse, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index 8f3deae..d1d4fdc 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -19,7 +19,7 @@ export * from './DOI'; // export * from './DOType'; // export * from './EquipmentContainer'; export * from './FCDA'; -// export * from './GSE'; +export * from './GSE'; // export * from './GSEControl'; // export * from './GeneralEquipment'; // export * from './History'; diff --git a/test/SemanticConstraints/GSE.test.ts b/test/SemanticConstraints/GSE.test.ts new file mode 100644 index 0000000..c6b7989 --- /dev/null +++ b/test/SemanticConstraints/GSE.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest'; + +import { GSE_for_L2_GOOSE_has_right_addresses } from '../../src/SemanticConstraints/GSE'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +// Common IED with GSEControl and Communication setup +const IED_WITH_GSECONTROL = ` + + + + + + + + + + +`; + +const IED_WITH_GSECONTROL_AND_PROTOCOL = ` + + + + + + + +

+ + + + + + +`; + +// Valid GSE element with ldInst for L2 GOOSE +const VALID_L2_GOOSE_GSE = ` + +

+

1

+

2

+

3

+

01-02-03-04-05-06

+
+`; + +// Valid GSE element for R-GOOSE (with Protocol) +const VALID_R_GOOSE_GSE = ` + +
+

1

+
+
`; + +describe('GSE', () => { + describe('GSE_for_L2_GOOSE_has_right_addresses', () => { + it('should return no issues for L2 GOOSE with all required address types and unique types', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + ${VALID_L2_GOOSE_GSE} + + + + `); + expect(GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for R-GOOSE with Protocol element', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL_AND_PROTOCOL} + + + + ${VALID_R_GOOSE_GSE} + + + + `); + expect(GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for GSE without Address element', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + + + + + `); + expect(GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when L2 GOOSE has all required types with extra types', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + +
+

1

+

2

+

3

+

01-02-03-04-05-06

+

1000

+

2000

+
+
+
+
+
+
`); + expect(GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME)).toEqual([]); + }); + + it('should report L2 GOOSE missing VLAN-ID', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + +
+

2

+

3

+

01-02-03-04-05-06

+
+
+
+
+
+
`); + const issues = GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'GSE_for_L2_GOOSE_has_right_addresses', + filename: DOC_NAME, + message: 'GSE for L2 GOOSE should have APPID VLAN_ID VLAN-PRIORITY and MAC-Address', + }); + expect(issues[0].element.tagName).toBe('GSE'); + }); + + it('should report L2 GOOSE missing APPID', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + +
+

1

+

3

+

01-02-03-04-05-06

+
+
+
+
+
+
`); + const issues = GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('GSE'); + }); + + it('should report L2 GOOSE missing VLAN-PRIORITY', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + +
+

1

+

2

+

01-02-03-04-05-06

+
+
+
+
+
+
`); + const issues = GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('GSE'); + }); + + it('should report L2 GOOSE missing MAC-Address', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + +
+

1

+

2

+

3

+
+
+
+
+
+
`); + const issues = GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('GSE'); + }); + + it('should report L2 GOOSE with duplicate P types', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + +
+

1

+

2

+

3

+

4

+
+
+
+
+
+
`); + const issues = GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('GSE'); + }); + + it('should report multiple invalid GSE elements', () => { + const doc = parseXMLString(` + + ${IED_WITH_GSECONTROL} + + + + + + + + + + + + + + + + + +
+

1

+

2

+

3

+
+
+
+ + +
+

1

+

2

+
+
+
+
+
+
`); + const issues = GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME); + expect(issues).toHaveLength(2); + expect(issues.every((issue) => issue.element.tagName === 'GSE')).toBe(true); + }); + + it('should return no issues when R-GOOSE and L2 GOOSE are both valid', () => { + const doc = parseXMLString(` + + + + + ${VALID_L2_GOOSE_GSE} + + + ${VALID_R_GOOSE_GSE} + + + + ${IED_WITH_GSECONTROL} + ${IED_WITH_GSECONTROL_AND_PROTOCOL} + `); + expect(GSE_for_L2_GOOSE_has_right_addresses(doc, DOC_NAME)).toEqual([]); + }); + }); +}); From afed5203e0fe280f941fbed7ee8975e379c71592 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Fri, 16 Oct 2026 09:10:21 +0200 Subject: [PATCH 17/18] feat(SemConstr): add GSEControl checks --- src/SemanticConstraints/GSEControl.ts | 20 ++ src/SemanticConstraints/index.ts | 2 +- test/SemanticConstraints/GSEControl.test.ts | 321 ++++++++++++++++++++ 3 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/SemanticConstraints/GSEControl.ts create mode 100644 test/SemanticConstraints/GSEControl.test.ts diff --git a/src/SemanticConstraints/GSEControl.ts b/src/SemanticConstraints/GSEControl.ts new file mode 100644 index 0000000..9eab08b --- /dev/null +++ b/src/SemanticConstraints/GSEControl.ts @@ -0,0 +1,20 @@ +import { getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: GSEControl_datSet_valid_reference +export function GSEControl_datSet_valid_reference(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > LDevice > LN0 > GSEControl')) + .filter((gseControl) => { + const datSet = getAttributeValue(gseControl, 'datSet'); + if (!datSet) return false; + + return !gseControl.parentElement?.querySelector(`:scope > DataSet[name="${datSet}"]`); + }) + .map((gseControl) => ({ + severity: 'ERROR', + id: 'GSEControl_datSet_valid_reference', + filename: docName, + message: 'datSet attribute shall reference a valid DataSet in LLN0 in GSEControl', + element: gseControl, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index d1d4fdc..baed886 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -20,7 +20,7 @@ export * from './DOI'; // export * from './EquipmentContainer'; export * from './FCDA'; export * from './GSE'; -// export * from './GSEControl'; +export * from './GSEControl'; // export * from './GeneralEquipment'; // export * from './History'; // export * from './IED'; diff --git a/test/SemanticConstraints/GSEControl.test.ts b/test/SemanticConstraints/GSEControl.test.ts new file mode 100644 index 0000000..c2aa082 --- /dev/null +++ b/test/SemanticConstraints/GSEControl.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from 'vitest'; + +import { GSEControl_datSet_valid_reference } from '../../src/SemanticConstraints/GSEControl'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +const VALID_IED_WITH_DATASET = ` + + + + + + + + + + + + + + + +`; + +const IED_WITHOUT_DATASET = ` + + + + + + + + + + + + +`; + +const IED_WITH_WRONG_DATASET_NAME = ` + + + + + + + + + + + + + + + +`; + +const IED_WITHOUT_DATSET_ATTRIBUTE = ` + + + + + + + + + + + + + + + +`; + +const IED_WITH_MULTIPLE_GSECONTROLS = ` + + + + + + + + + + + + + + + + + + + + + +`; + +const IED_WITH_MIXED_VALID_AND_INVALID = ` + + + + + + + + + + + + + + + + + + +`; + +const IED_WITH_EMPTY_DATSET_ATTRIBUTE = ` + + + + + + + + + + + + + + + +`; + +const MULTIPLE_IEDS_WITH_GSECONTROLS = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const MULTIPLE_IEDS_WITH_INVALID_REFERENCE = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +describe('GSEControl', () => { + describe('GSEControl_datSet_valid_reference', () => { + it('should return no issues when GSEControl references existing DataSet', () => { + const doc = parseXMLString(` + + ${VALID_IED_WITH_DATASET} + `); + expect(GSEControl_datSet_valid_reference(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when GSEControl has no datSet attribute', () => { + const doc = parseXMLString(` + + ${IED_WITHOUT_DATSET_ATTRIBUTE} + `); + expect(GSEControl_datSet_valid_reference(doc, DOC_NAME)).toEqual([]); + }); + + it('should report error when GSEControl references non-existing DataSet', () => { + const doc = parseXMLString(` + + ${IED_WITH_WRONG_DATASET_NAME} + `); + const issues = GSEControl_datSet_valid_reference(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'GSEControl_datSet_valid_reference', + filename: DOC_NAME, + message: 'datSet attribute shall reference a valid DataSet in LLN0 in GSEControl', + }); + expect(issues[0].element.tagName).toBe('GSEControl'); + }); + + it('should report error when DataSet exists but GSEControl has no parent LN0', () => { + const doc = parseXMLString(` + + ${IED_WITHOUT_DATASET} + `); + const issues = GSEControl_datSet_valid_reference(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('GSEControl'); + }); + + it('should return no issues for multiple GSEControls with valid references', () => { + const doc = parseXMLString(` + + ${IED_WITH_MULTIPLE_GSECONTROLS} + `); + expect(GSEControl_datSet_valid_reference(doc, DOC_NAME)).toEqual([]); + }); + + it('should report multiple errors for multiple GSEControls with invalid references', () => { + const doc = parseXMLString(` + + ${IED_WITH_MIXED_VALID_AND_INVALID} + `); + const issues = GSEControl_datSet_valid_reference(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.getAttribute('name')).toBe('GC2'); + }); + + it('should return no issues when datSet attribute is empty', () => { + const doc = parseXMLString(` + + ${IED_WITH_EMPTY_DATSET_ATTRIBUTE} + `); + expect(GSEControl_datSet_valid_reference(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for valid GSEControls across multiple IEDs', () => { + const doc = parseXMLString(` + + ${MULTIPLE_IEDS_WITH_GSECONTROLS} + `); + expect(GSEControl_datSet_valid_reference(doc, DOC_NAME)).toEqual([]); + }); + + it('should report error when GSEControl references DataSet in different IED', () => { + const doc = parseXMLString(` + + ${MULTIPLE_IEDS_WITH_INVALID_REFERENCE} + `); + const issues = GSEControl_datSet_valid_reference(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.getAttribute('name')).toBe('GC1'); + }); + + it('should handle GSEControl in LN (not LN0)', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + `); + const issues = GSEControl_datSet_valid_reference(doc, DOC_NAME); + // GSEControl in LN (not LN0) should still be checked, but the selector only matches LN0 + expect(issues).toHaveLength(0); + }); + }); +}); From 4a3ff55b5f2a04dd5e145017db43b6ad08405abb Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Fri, 16 Oct 2026 09:16:14 +0200 Subject: [PATCH 18/18] feat(SemConstr): add IED checks --- src/SemanticConstraints/IED.ts | 33 +++ src/SemanticConstraints/index.ts | 4 +- test/SemanticConstraints/IED.test.ts | 404 +++++++++++++++++++++++++++ 3 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 src/SemanticConstraints/IED.ts create mode 100644 test/SemanticConstraints/IED.test.ts diff --git a/src/SemanticConstraints/IED.ts b/src/SemanticConstraints/IED.ts new file mode 100644 index 0000000..9eedcc3 --- /dev/null +++ b/src/SemanticConstraints/IED.ts @@ -0,0 +1,33 @@ +import { areNamesUnique, getAttributeValue } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: IED_AccessPoint_name_unique +export function IED_AccessPoint_name_unique(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED')) + .filter((ied) => !areNamesUnique(ied.querySelectorAll(':scope > AccessPoint'))) + .map((ied) => ({ + severity: 'ERROR', + id: 'IED_AccessPoint_name_unique', + filename: docName, + message: 'AccessPoint in IED shall have different name', + element: ied, + })); +} + +// Rule: IED_LDevice_inst_unique +export function IED_LDevice_inst_unique(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED')) + .filter((ied) => { + const lDevices = ied.querySelectorAll(':scope > AccessPoint > Server > LDevice'); + const insts = Array.from(lDevices).map((ld) => getAttributeValue(ld, 'inst')); + const uniqueInsts = new Set(insts); + return uniqueInsts.size !== insts.length; + }) + .map((ied) => ({ + severity: 'ERROR', + id: 'IED_LDevice_inst_unique', + filename: docName, + message: 'LDevice in IED shall have different inst', + element: ied, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts index baed886..82c8b52 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -19,11 +19,11 @@ export * from './DOI'; // export * from './DOType'; // export * from './EquipmentContainer'; export * from './FCDA'; +// export * from './GeneralEquipment'; export * from './GSE'; export * from './GSEControl'; -// export * from './GeneralEquipment'; // export * from './History'; -// export * from './IED'; +export * from './IED'; // export * from './IEDName'; // export * from './LDevice'; // export * from './LN'; diff --git a/test/SemanticConstraints/IED.test.ts b/test/SemanticConstraints/IED.test.ts new file mode 100644 index 0000000..e91d2a5 --- /dev/null +++ b/test/SemanticConstraints/IED.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, it } from 'vitest'; + +import { + IED_AccessPoint_name_unique, + IED_LDevice_inst_unique, +} from '../../src/SemanticConstraints/IED'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +// Test data for AccessPoint uniqueness +const IED_WITH_UNIQUE_ACCESSPOINTS = ` + + + + + + + + + + +`; + +const IED_WITH_DUPLICATE_ACCESSPOINTS = ` + + + + + + + + + + +`; + +const IED_WITH_MULTIPLE_DUPLICATES = ` + + + + + + + + + + +`; + +const IED_WITH_DUPLICATE_ACCESSPOINTS_DIFFERENT_CASE = ` + + + + + + + +`; + +const IED_WITH_ONE_ACCESSPOINT = ` + + + + +`; + +const IED_WITHOUT_ACCESSPOINTS = ` + + +`; + +const IED_WITH_EMPTY_ACCESSPOINT_NAMES = ` + + + + + + + +`; + +const MULTIPLE_IEDS_WITH_DUPLICATES = ` + + + + + + + + + + + + + + + +`; + +// Test data for LDevice inst uniqueness +const IED_WITH_UNIQUE_LDEVICES = ` + + + + + + + + +`; + +const IED_WITH_DUPLICATE_LDEVICE_INST = ` + + + + + + + + +`; + +const IED_WITH_MULTIPLE_DUPLICATE_LDEVICES = ` + + + + + + + + +`; + +const IED_WITH_ONE_LDEVICE = ` + + + + + + +`; + +const IED_WITHOUT_LDEVICES = ` + + + + +`; + +const IED_WITH_EMPTY_LDEVICE_INST = ` + + + + + + + +`; + +const IED_WITH_MULTIPLE_ACCESSPOINTS_AND_LDEVICES = ` + + + + + + + + + + + + + +`; + +const MULTIPLE_IEDS_WITH_LDEVICE_DUPLICATES = ` + + + + + + + + + + + + + + + +`; + +const IED_WITH_LDEVICE_INST_DIFFERENT_CASE = ` + + + + + + + +`; + +describe('IED', () => { + describe('IED_AccessPoint_name_unique', () => { + it('should return no issues when IED has unique AccessPoint names', () => { + const doc = parseXMLString(` + + ${IED_WITH_UNIQUE_ACCESSPOINTS} + `); + expect(IED_AccessPoint_name_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should report error when IED has duplicate AccessPoint names', () => { + const doc = parseXMLString(` + + ${IED_WITH_DUPLICATE_ACCESSPOINTS} + `); + const issues = IED_AccessPoint_name_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'IED_AccessPoint_name_unique', + filename: DOC_NAME, + message: 'AccessPoint in IED shall have different name', + }); + expect(issues[0].element.tagName).toBe('IED'); + expect(issues[0].element.getAttribute('name')).toBe('IED1'); + }); + + it('should report error when IED has multiple duplicate AccessPoint names', () => { + const doc = parseXMLString(` + + ${IED_WITH_MULTIPLE_DUPLICATES} + `); + const issues = IED_AccessPoint_name_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + }); + + it('should report error when AccessPoint names differ only by case', () => { + const doc = parseXMLString(` + + ${IED_WITH_DUPLICATE_ACCESSPOINTS_DIFFERENT_CASE} + `); + const issues = IED_AccessPoint_name_unique(doc, DOC_NAME); + // Case sensitivity: AP1 and ap1 are considered different + expect(issues).toHaveLength(0); + }); + + it('should return no issues when IED has only one AccessPoint', () => { + const doc = parseXMLString(` + + ${IED_WITH_ONE_ACCESSPOINT} + `); + expect(IED_AccessPoint_name_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when IED has no AccessPoints', () => { + const doc = parseXMLString(` + + ${IED_WITHOUT_ACCESSPOINTS} + `); + expect(IED_AccessPoint_name_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should report error when IED has duplicate empty AccessPoint names', () => { + const doc = parseXMLString(` + + ${IED_WITH_EMPTY_ACCESSPOINT_NAMES} + `); + const issues = IED_AccessPoint_name_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + }); + + it('should report errors for each IED with duplicate AccessPoints', () => { + const doc = parseXMLString(` + + ${MULTIPLE_IEDS_WITH_DUPLICATES} + `); + const issues = IED_AccessPoint_name_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); // Only IED1 has duplicates + expect(issues[0].element.getAttribute('name')).toBe('IED1'); + }); + }); + + describe('IED_LDevice_inst_unique', () => { + it('should return no issues when IED has unique LDevice inst values', () => { + const doc = parseXMLString(` + + ${IED_WITH_UNIQUE_LDEVICES} + `); + expect(IED_LDevice_inst_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should report error when IED has duplicate LDevice inst values', () => { + const doc = parseXMLString(` + + ${IED_WITH_DUPLICATE_LDEVICE_INST} + `); + const issues = IED_LDevice_inst_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'IED_LDevice_inst_unique', + filename: DOC_NAME, + message: 'LDevice in IED shall have different inst', + }); + expect(issues[0].element.tagName).toBe('IED'); + expect(issues[0].element.getAttribute('name')).toBe('IED1'); + }); + + it('should report error when IED has multiple duplicate LDevice inst values', () => { + const doc = parseXMLString(` + + ${IED_WITH_MULTIPLE_DUPLICATE_LDEVICES} + `); + const issues = IED_LDevice_inst_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + }); + + it('should return no issues when IED has only one LDevice', () => { + const doc = parseXMLString(` + + ${IED_WITH_ONE_LDEVICE} + `); + expect(IED_LDevice_inst_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when IED has no LDevices', () => { + const doc = parseXMLString(` + + ${IED_WITHOUT_LDEVICES} + `); + expect(IED_LDevice_inst_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should report error when IED has duplicate empty LDevice inst values', () => { + const doc = parseXMLString(` + + ${IED_WITH_EMPTY_LDEVICE_INST} + `); + const issues = IED_LDevice_inst_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + }); + + it('should return no issues when LDevice inst values are in different AccessPoints', () => { + const doc = parseXMLString(` + + ${IED_WITH_MULTIPLE_ACCESSPOINTS_AND_LDEVICES} + `); + // LD1 appears in both AP1 and AP2, but the rule checks across all LDevices in the IED + const issues = IED_LDevice_inst_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); + }); + + it('should report errors for each IED with duplicate LDevice inst values', () => { + const doc = parseXMLString(` + + ${MULTIPLE_IEDS_WITH_LDEVICE_DUPLICATES} + `); + const issues = IED_LDevice_inst_unique(doc, DOC_NAME); + expect(issues).toHaveLength(1); // Only IED1 has duplicates + expect(issues[0].element.getAttribute('name')).toBe('IED1'); + }); + + it('should treat LDevice inst values with different case as different', () => { + const doc = parseXMLString(` + + ${IED_WITH_LDEVICE_INST_DIFFERENT_CASE} + `); + const issues = IED_LDevice_inst_unique(doc, DOC_NAME); + // Case sensitivity: LD1 and ld1 are considered different + expect(issues).toHaveLength(0); + }); + + it('should return no issues for multiple IEDs each with unique LDevices', () => { + const doc = parseXMLString(` + + + + + + + + + + + + + + + + + + `); + // Each IED has unique LDevice inst values within itself + expect(IED_LDevice_inst_unique(doc, DOC_NAME)).toEqual([]); + }); + }); +});