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/Helpers/BaseSimpleTypes.ts b/src/Helpers/BaseSimpleTypes.ts index cc98c22..6104ac1 100644 --- a/src/Helpers/BaseSimpleTypes.ts +++ b/src/Helpers/BaseSimpleTypes.ts @@ -48,32 +48,105 @@ 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 Array.from(scl.querySelectorAll(selectorAnyLN)).find(ln => { + if (prefix !== getAttributeValue(ln, 'prefix')) return false; + if (lnInst !== getAttributeValue(ln, 'inst')) return false; + 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}"]`; - const lDevices = scope.querySelectorAll('LDevice'); - for (let i = 0; i < lDevices.length; i++) { - if (getAttributeValue(lDevices[i], 'inst') !== ldInst) continue; + 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; +} + +/** + * 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 > DOType > DO(SDO*) > DOType > DA (or nested in DAType > BDA) + * Returns null when the reference cannot be resolved. + */ +export function findReferencedDataType(scl: XMLDocument, doiOrSdiOrDaiOrFcda: Element): Element | null { + + const dataTypeTemplates = scl.querySelector(':root > DataTypeTemplates'); + if (!dataTypeTemplates) return null; + + 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}"]` + ]; - const lns = lDevices[i].querySelectorAll('LN0, LN'); - for (let j = 0; j < lns.length; j++) { - const ln = lns[j]; - const isLN0 = ln.tagName === 'LN0'; - const classMatches = isLN0 ? lnClass === 'LLN0' : getAttributeValue(ln, 'lnClass') === lnClass; - const instMatches = isLN0 ? lnInst === '' : getAttributeValue(ln, 'inst') === lnInst; - const prefixMatches = getAttributeValue(ln, 'prefix') === prefix; + 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') || ''; - if (classMatches && instMatches && prefixMatches) { - return ln; - } + 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') || ''; + } + + 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/AbstractConductingEquipment.ts b/src/SemanticConstraints/AbstractConductingEquipment.ts new file mode 100644 index 0000000..69d1588 --- /dev/null +++ b/src/SemanticConstraints/AbstractConductingEquipment.ts @@ -0,0 +1,26 @@ +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/AbstractDataObject.ts b/src/SemanticConstraints/AbstractDataObject.ts new file mode 100644 index 0000000..cd15004 --- /dev/null +++ b/src/SemanticConstraints/AbstractDataObject.ts @@ -0,0 +1,23 @@ +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, + }; + }); +} diff --git a/src/SemanticConstraints/AnyLN.ts b/src/SemanticConstraints/AnyLN.ts new file mode 100644 index 0000000..5614e9f --- /dev/null +++ b/src/SemanticConstraints/AnyLN.ts @@ -0,0 +1,122 @@ +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/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/BDA.ts b/src/SemanticConstraints/BDA.ts new file mode 100644 index 0000000..08b2659 --- /dev/null +++ b/src/SemanticConstraints/BDA.ts @@ -0,0 +1,49 @@ +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/ClientLN.ts b/src/SemanticConstraints/ClientLN.ts new file mode 100644 index 0000000..ed71a5b --- /dev/null +++ b/src/SemanticConstraints/ClientLN.ts @@ -0,0 +1,58 @@ +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/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/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/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/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/DAI.ts b/src/SemanticConstraints/DAI.ts new file mode 100644 index 0000000..57c81fc --- /dev/null +++ b/src/SemanticConstraints/DAI.ts @@ -0,0 +1,18 @@ +import { findReferencedDataType, 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 && !findReferencedDataType(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/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/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/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/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/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/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 a4a8f29..82c8b52 100644 --- a/src/SemanticConstraints/index.ts +++ b/src/SemanticConstraints/index.ts @@ -1,33 +1,29 @@ // SemanticConstraints index - exports all semantic constraint validation functions // -// export * from './AbstractConductingEquipment'; +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 './AbstractDataObject'; -// export * from './AnyLN'; -// export * from './Association'; -// export * from './BDA'; -// export * from './ClientLN'; +export * from './BDA'; +export * from './ClientLN'; export * from './Communication'; -// export * from './ConnectedAP'; -// export * from './ConnectivityNode'; -// export * from './Control'; -// export * from './ControlBlock'; -// export * from './DA'; -// export * from './DAI'; -// export * from './DAType'; -// export * from './DOI'; +// export * from './ConductingEquipment'; +export * from './Control'; +export * from './ConnectedAP'; +export * from './ControlBlock'; +export * from './DA'; +export * from './DAI'; +export * from './DAType'; +export * from './DOI'; // export * from './DOType'; // export * from './EquipmentContainer'; -// export * from './FCDA'; -// export * from './GSE'; -// export * from './GSEControl'; +export * from './FCDA'; // export * from './GeneralEquipment'; +export * from './GSE'; +export * from './GSEControl'; // export * from './History'; -// export * from './IED'; +export * from './IED'; // export * from './IEDName'; // export * from './LDevice'; // export * from './LN'; @@ -43,6 +39,7 @@ export * from './Communication'; // export * from './ReportControl'; // export * from './SDI'; // export * from './SampledValueControl'; +// export * from './SCL'; // export * from './Server'; // export * from './ServerAt'; // export * from './SubEquipment'; 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/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); + }); + }); +}); 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([]); + }); + }); +}); 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'); + }); + }); +}); 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'); + }); + }); +}); diff --git a/test/SemanticConstraints/BDA.test.ts b/test/SemanticConstraints/BDA.test.ts new file mode 100644 index 0000000..606dbfb --- /dev/null +++ b/test/SemanticConstraints/BDA.test.ts @@ -0,0 +1,208 @@ +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'); +} + +describe('BDA', () => { + describe('BDA_BDAType_allowedWhen_Enum', () => { + 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('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 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({ + 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'); + }); + + 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 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 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(1); + expect(issues[0].id).toBe('BDA_RefersToDATypeorEnumType'); + }); + }); +}); 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'); + }); + }); +}); 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'); + }); + }); +}); 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)$/); + }); + }); +}); 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'); + }); + }); +}); 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'); + }); + }); +}); 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([]); + }); + }); +}); 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([]); + }); + }); +}); 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); + }); + }); +}); 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([]); + }); + }); +}); 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([]); + }); + }); +}); 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); + }); + }); +}); 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([]); + }); + }); +});