From 1ec46b64f177618442d46e4a2accf465e9a3ccef Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Mon, 12 Oct 2026 14:52:49 +0200 Subject: [PATCH 1/2] feat: add reqired_attributes AnyLN and Association --- src/Helpers/Enums.ts | 47 +- src/RequiredAttributes/AnyLN.ts | 90 ++++ src/RequiredAttributes/Association.ts | 186 ++++++++ src/RequiredAttributes/index.ts | 76 ++++ src/validate.ts | 52 ++- test/RequiredAttributes/AnyLN.test.ts | 279 ++++++++++++ test/RequiredAttributes/Association.test.ts | 470 ++++++++++++++++++++ test/validate.test.ts | 4 + 8 files changed, 1190 insertions(+), 14 deletions(-) create mode 100644 src/RequiredAttributes/AnyLN.ts create mode 100644 src/RequiredAttributes/Association.ts create mode 100644 src/RequiredAttributes/index.ts create mode 100644 test/RequiredAttributes/AnyLN.test.ts create mode 100644 test/RequiredAttributes/Association.test.ts diff --git a/src/Helpers/Enums.ts b/src/Helpers/Enums.ts index d6b5995..a4a7d0d 100644 --- a/src/Helpers/Enums.ts +++ b/src/Helpers/Enums.ts @@ -19,15 +19,48 @@ export const CONDUCTING_EQUIPMENT_TYPES = new Set([ ]); // Valid LNClass enum values +// System LN Group + Domain LN Groups from OCL Helpers/Enums.ocl export const LN_CLASS_ENUM = new Set([ - 'LLN0', 'LPHD', 'LNOD', 'TVTR', 'TCTR', 'TCLK', 'TRES', 'TWPI', 'TWPP', - 'TPOS', 'TSPT', 'TPAS', 'TMPS', 'TAMV', 'TAMP', 'TVLV', 'TWQI', 'TWQP', + // System LN Group + 'LLN0', 'LPHD', 'LCCH', 'LGOS', 'LSVS', 'LTIM', 'LTMS', 'LTRK', + // Domain A + 'ANCR', 'ARCO', 'ARIS', 'ATCC', 'AVCO', + // Domain C + 'CALH', 'CCGR', 'CILO', 'CPOW', 'CSWI', 'CSYN', + // Domain F + 'FCNT', 'FCSD', 'FFIL', 'FLIM', 'FPID', 'FRMP', 'FSPT', 'FXOT', 'FXUT', + // Domain G + 'GAPC', 'GGIO', 'GLOG', 'GSAL', + // Domain I + 'IARC', 'IHMI', 'ISAF', 'ITCI', 'ITMI', 'ITPC', + // Domain K + 'KFAN', 'KFIL', 'KPMP', 'KTNK', 'KVLV', + // Domain M + 'MHAI', 'MHAN', 'MHYD', 'MMDC', 'MMET', 'MMTN', 'MMTR', 'MMXN', 'MMXU', 'MSQI', 'MSTA', + // Domain P + 'PDIF', 'PDIR', 'PDIS', 'PDOP', 'PDUP', 'PFRC', 'PHAR', 'PHIZ', 'PIOC', 'PMRI', 'PMSS', 'POPF', 'PPAM', 'PRTR', 'PSCH', 'PSDE', 'PTEF', 'PTHF', 'PTOC', 'PTOF', 'PTOV', 'PTRC', 'PTTR', 'PTUC', 'PTUF', 'PTUV', 'PUPF', + // Domain Q + 'QFVR', 'QITR', 'QIUB', 'QVTR', 'QVUB', 'QVVR', + // Domain R + 'RADR', 'RBDR', 'RBRF', 'RDIR', 'RDRE', 'RDRS', 'RFLO', 'RMXU', 'RPSB', 'RREC', 'RSYN', + // Domain S + 'SARC', 'SCBR', 'SIMG', 'SIML', 'SLTC', 'SOPM', 'SPDC', 'SPTR', 'SSWI', 'STMP', 'SVBR', + // Domain T + 'TANG', 'TAXD', 'TDST', 'TFLW', 'TGSN', 'THUM', 'TLVL', 'TMGF', 'TMVM', 'TPRS', 'TRTN', 'TSND', 'TTMP', 'TTNS', 'TVBR', 'TVTR', 'TWPH', + // Domain X + 'XCBR', 'XSWI', + // Domain Y + 'YEFN', 'YLTC', 'YPSH', 'YPTR', + // Domain Z + 'ZAXN', 'ZBAT', 'ZBSH', 'ZCAB', 'ZCAP', 'ZCON', 'ZGEN', 'ZGIL', 'ZLIN', 'ZMOT', 'ZREA', 'ZRES', 'ZRRC', 'ZSAR', 'ZSCR', 'ZSMC', 'ZTCF', 'ZTCR', + // Existing values from original set (not in OCL domain groups but used in practice) + 'LNOD', 'TCTR', 'TCLK', 'TRES', 'TWPI', 'TWPP', 'TPOS', 'TSPT', 'TPAS', 'TMPS', 'TAMV', 'TAMP', 'TVLV', 'TWQI', 'TWQP', 'TFLV', 'TFLP', 'TFLQ', 'TFFQ', 'TFRQ', 'TITV', 'TITQ', 'TITP', 'TITF', 'BB00', 'BBO1', 'BBO2', 'BBO3', 'BRCB', 'BBRB', 'DIR', 'DOP', 'DOI', 'DOC', - 'CSWI', 'XCBR', 'XSWI', 'CSWI2', 'XCBR2', 'XSWI2', 'CSWI3', 'XCBR3', 'XSWI3', + 'CSWI2', 'XCBR2', 'XSWI2', 'CSWI3', 'XCBR3', 'XSWI3', 'SGCB', 'XSGC', 'CSGR', 'XSGR', 'MXGR', 'XMXG', 'AGCS', 'XGCS', 'ACSE', - 'CSAC', 'XSAC', 'ATCC', 'XATC', 'TCTR', 'TVTR', 'DRCS', 'XDRC', 'RDRE', - 'XDRE', 'RDRS', 'XRDR', 'RREC', 'XRRC', 'CILO', 'XCIL', 'SPC', 'XSPC', + 'CSAC', 'XSAC', 'XATC', 'DRCS', 'XDRC', + 'RDRE', 'XDRE', 'RDRS', 'XRDR', 'RSPC', 'XRPC', 'SEL', 'XSEL', 'SRGC', 'XSGC', 'SICF', 'XSIC', 'SICC', 'XSIC2', 'SICC2', 'XSIC3', 'SICC3', 'SIMG', 'XSIM', 'SIMC', 'XSIC4', 'SICC4' ]); @@ -81,8 +114,8 @@ export function validSclLNClassEnum(lnClass: string): boolean { return LN_CLASS_ENUM.has(lnClass); } -// Valid prefix pattern: alphanumeric and special characters -export const PREFIX_PATTERN = /^[a-zA-Z0-9_\-]{0,4}$/; +// Valid prefix pattern from OCL BaseSimpleTypes.ocl: empty string OR [A-Za-z][0-9A-Za-z_]{0,10} +export const PREFIX_PATTERN = /^(|[A-Za-z][0-9A-Za-z_]{0,10})$/; // Validate prefix export function validSclPrefix(prefix: string): boolean { diff --git a/src/RequiredAttributes/AnyLN.ts b/src/RequiredAttributes/AnyLN.ts new file mode 100644 index 0000000..a41eae5 --- /dev/null +++ b/src/RequiredAttributes/AnyLN.ts @@ -0,0 +1,90 @@ +import { ValidationIssue } from '../ValidationIssue'; +import { LN_CLASS_ENUM } from '../Helpers/Enums'; + +const selectorAnyLN = [ + ':root > IED > AccessPoint > LN', + ':root > IED > AccessPoint > Server > LDevice > LN0', + ':root > IED > AccessPoint > Server > LDevice > LN', +]; + +// Rule: LN_lnClass_required +export function ln_lnClass_required(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selectorAnyLN.join(', '))) + .filter((element) => !element.getAttribute('lnClass')) + .map((element) => ({ + severity: 'ERROR', + id: 'LN_lnClass_required', + filename: docName, + message: 'lnClass attribute shall be present in AnyLN', + element: element, + })); +} + +// Rule: LN_lnClass_valid +export function ln_lnClass_valid(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selectorAnyLN.join(', '))) + .filter((element) => { + const lnClass = element.getAttribute('lnClass'); + return lnClass !== null && !isValidSclLNClass(lnClass); + }) + .map((element) => ({ + severity: 'ERROR', + id: 'LN_lnClass_valid', + filename: docName, + message: 'lnClass attribute shall be valid in AnyLN', + element: element, + })); +} + +// Rule: LN_inst_required +export function ln_inst_required(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selectorAnyLN.join(', '))) + .filter((element) => element.getAttribute('inst') === null) + .map((element) => ({ + severity: 'ERROR', + id: 'LN_inst_required', + filename: docName, + message: 'inst attribute shall be present in AnyLN', + element: element, + })); +} + +// Rule: AnyLn_lnType_required +export function anyLn_lnType_required(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selectorAnyLN.join(', '))) + .filter((element) => element.getAttribute('lnType') === null) + .map((element) => ({ + severity: 'ERROR', + id: 'AnyLN_lnType_required', + filename: docName, + message: 'lnType attribute shall be present in AnyLN', + element: element, + })); +} + +// Rule: AnyLn_lnType_valid +export function anyLn_lnType_valid(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(selectorAnyLN.join(', '))) + .filter((element) => { + const lnType = element.getAttribute('lnType'); + return lnType !== null && !isValidSclName(lnType); + }) + .map((element) => ({ + severity: 'ERROR', + id: 'AnyLN_lnType_valid', + filename: docName, + message: 'lnType attribute shall be valid in AnyLN', + element: element, + })); +} + +function isValidSclLNClass(lnClass: string): boolean { + // OCL: validSclLNClassEnum(s) = validSclPredefinedLNClassEnum(s) OR validSclExtensionLNClassEnum(s) + // Predefined: check against LN_CLASS_ENUM + // Extension: exactly 4 uppercase letters + return LN_CLASS_ENUM.has(lnClass) || /^[A-Z]{4}$/.test(lnClass); +} + +function isValidSclName(name: string): boolean { + return name.length > 0 && name.length <= 128; +} diff --git a/src/RequiredAttributes/Association.ts b/src/RequiredAttributes/Association.ts new file mode 100644 index 0000000..6678404 --- /dev/null +++ b/src/RequiredAttributes/Association.ts @@ -0,0 +1,186 @@ +import { LN_CLASS_ENUM, validSclPrefix } from '../Helpers/Enums'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: association_kind_required +export function association_kind_required(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => !association.getAttribute('kind')) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_kind_required', + filename: docName, + message: 'kind attribute shall be present in Association', + element: association, + })); +} + +// Rule: association_associationID_valid +export function association_associationID_valid(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => { + const associationID = association.getAttribute('associationID'); + return associationID !== null && !isValidSclAssociationID(associationID); + }) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_associationID_valid', + filename: docName, + message: 'associationID attribute shall be valid in Association', + element: association, + })); +} + +// Rule: association_prefix_valid +export function association_prefix_valid(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => { + const prefix = association.getAttribute('prefix'); + return prefix !== null && !validSclPrefix(prefix); + }) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_prefix_valid', + filename: docName, + message: 'prefix attribute shall be valid in Association', + element: association, + })); +} + +// Rule: association_lnClass_required +export function association_lnClass_required(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => !association.getAttribute('lnClass')) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_lnClass_required', + filename: docName, + message: 'lnClass attribute shall be present in Association', + element: association, + })); +} + +// Rule: association_lnClass_valid +export function association_lnClass_valid(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => { + const lnClass = association.getAttribute('lnClass'); + return lnClass !== null && !isValidSclLNClass(lnClass); + }) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_lnClass_valid', + filename: docName, + message: 'lnClass attribute shall be valid in Association', + element: association, + })); +} + +// Rule: association_lnInst_required +export function association_lnInst_required(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => !association.getAttribute('lnInst')) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_lnInst_required', + filename: docName, + message: 'lnInst attribute shall be present in Association', + element: association, + })); +} + +// Rule: association_lnInst_valid +export function association_lnInst_valid(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => { + const lnInst = association.getAttribute('lnInst'); + return lnInst !== null && !isValidSclLNInstOrEmpty(lnInst); + }) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_lnInst_valid', + filename: docName, + message: 'lnInst attribute shall be valid in Association', + element: association, + })); +} + +// Rule: association_iedName_required +export function association_iedName_required(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => !association.getAttribute('iedName')) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_iedName_required', + filename: docName, + message: 'iedName attribute shall be present in Association', + element: association, + })); +} + +// Rule: association_iedName_valid +export function association_iedName_valid(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => { + const iedName = association.getAttribute('iedName'); + return iedName !== null && !isValidSclIEDName(iedName); + }) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_iedName_valid', + filename: docName, + message: 'iedName attribute shall be valid in Association', + element: association, + })); +} + +// Rule: association_ldInst_required +export function association_ldInst_required(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => !association.getAttribute('ldInst')) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_ldInst_required', + filename: docName, + message: 'ldInst attribute shall be present in Association', + element: association, + })); +} + +// Rule: association_ldInst_valid +export function association_ldInst_valid(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll(':root > IED > AccessPoint > Server > Association')) + .filter((association) => { + const ldInst = association.getAttribute('ldInst'); + return ldInst !== null && !isValidSclLDInst(ldInst); + }) + .map((association) => ({ + severity: 'ERROR', + id: 'Association_ldInst_valid', + filename: docName, + message: 'ldInst attribute shall be valid in Association', + element: association, + })); +} + +function isValidSclAssociationID(id: string): boolean { + return id.length >= 1 && id.length <= 128; +} + +function isValidSclLNClass(lnClass: string): boolean { + // OCL: validSclLNClassEnum(s) = validSclPredefinedLNClassEnum(s) OR validSclExtensionLNClassEnum(s) + // Predefined: check against LN_CLASS_ENUM + // Extension: exactly 4 uppercase letters + return LN_CLASS_ENUM.has(lnClass) || /^[A-Z]{4}$/.test(lnClass); +} + +function isValidSclLNInstOrEmpty(lnInst: string): boolean { + return lnInst.length === 0 || /^[a-zA-Z0-9_]+$/.test(lnInst); +} + +function isValidSclIEDName(iedName: string): boolean { + return /^[\x20-\x7E]+$/.test(iedName) && iedName.length >= 1 && iedName.length <= 129; +} + +function isValidSclLDInst(ldInst: string): boolean { + return /^[A-Za-z0-9][0-9A-Za-z_]{0,63}$/.test(ldInst); +} diff --git a/src/RequiredAttributes/index.ts b/src/RequiredAttributes/index.ts new file mode 100644 index 0000000..c92e741 --- /dev/null +++ b/src/RequiredAttributes/index.ts @@ -0,0 +1,76 @@ +// export * from './AbstractDataAttribute'; +// export * from './AbstractDataObject'; +// export * from './AccessPoint'; +export * from './AnyLN'; +export * from './Association'; +// export * from './BitRateInMbPerSec'; +// export * from './Cert'; +// export * from './Certificate'; +// export * from './ClientLN'; +// export * from './ClientServices'; +// export * from './ConnectedAP'; +// export * from './ConductingEquipment'; +// export * from './ConnectivityNode'; +// export * from './Control'; +// export * from './ControlBlock'; +// export * from './ControlWithIEDName'; +// export * from './ControlWithTriggerOpt'; +// export * from './DA'; +// export * from './DAI'; +// export * from './DOI'; +// export * from './DOType'; +// export * from './DataSet'; +// export * from './DurationInMilliSec'; +// export * from './DurationInSec'; +// export * from './EnumVal'; +// export * from './ExtRef'; +// export * from './FCDA'; +// export * from './GSEControl'; +// export * from './GeneralEquipment'; +// export * from './Header'; +// export * from './Hitem'; +// export * from './IDNaming'; +// export * from './IED'; +// export * from './IEDName'; +// export * from './KDC'; +// export * from './LDevice'; +// export * from './LN'; +// export * from './LN0'; +// export * from './LNode'; +// export * from './LNodeType'; +// export * from './Line'; +// export * from './Log'; +// export * from './LogControl'; +// export * from './Naming'; +// export * from './P'; +// export * from './P_PhysConn'; +// export * from './PhysConn'; +// export * from './PowerTransformer'; +// export * from './Private'; +// export * from './Process'; +// export * from './ProtNs'; +// export * from './Protocol'; +// export * from './ReportControl'; +// export * from './RptEnabled'; +// export * from './SCL'; +// export * from './SDI'; +// export * from './SDO'; +// export * from './SampledValueControl'; +// export * from './Server'; +// export * from './ServerAt'; +// export * from './ServiceConfReportControl'; +// export * from './ServiceWithMax'; +// export * from './ServiceWithMaxAndMaxAttributes'; +// export * from './ServiceWithMaxNonZero'; +// export * from './ServiceWithOptionalMax'; +// export * from './Services'; +// export * from './SettingControl'; +// export * from './SubNetwork'; +// export * from './SupSubscription'; +// export * from './TapChanger'; +// export * from './Terminal'; +// export * from './TransformerWinding'; +// export * from './Val'; +// export * from './ValueWithUnit'; +// export * from './Voltage'; +// export * from './VoltageLevel'; diff --git a/src/validate.ts b/src/validate.ts index 2b5a6ef..923abcc 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,17 +1,24 @@ import { ValidationIssue } from './ValidationIssue'; import * as requiredElements from './RequiredElements'; +import * as requiredAttributes from './RequiredAttributes'; export type ValidationOptions = { filename?: string; + filter?: { + element_specific?: boolean; + attributes_specific?: boolean; + semantic_checks?: boolean; + file_specific?: boolean; + }; }; /** - * Validates an XMLDocument against RequiredElements OCL rules using parallel execution. + * Validates an XMLDocument against selected OCL rule categories using parallel execution. * Uses AsyncGenerator to stream results. * * @param scl - The XMLDocument to validate - * @param options - Validation options including filename - * @yields Arrays of ValidationIssue as rules complete + * @param options - Validation options including filename and category selectors + * @yields Arrays of ValidationIssue as rule categories complete */ export async function* validate( scl: XMLDocument, @@ -19,11 +26,23 @@ export async function* validate( ): AsyncGenerator { const filename = options.filename || 'unknown.xml'; - // Run all RequiredElements rules in parallel - const issues = await runRequiredElements(scl, filename); + // Run selected rule categories in parallel + const filter = options.filter || {}; + const categories = [ + { enabled: filter.element_specific !== false, run: () => runRequiredElements(scl, filename) }, + { enabled: filter.attributes_specific !== false, run: () => runRequiredAttributes(scl, filename) }, + ]; + + // Run all enabled categories in parallel + const results = await Promise.all( + categories.filter((c) => c.enabled).map((c) => c.run()) + ); - if (issues.length > 0) { - yield issues; + // Yield each category's results if they have issues + for (const issues of results) { + if (issues.length > 0) { + yield issues; + } } } @@ -46,6 +65,25 @@ async function runRequiredElements(scl: XMLDocument, filename: string): Promise< return results.flat(); } +/** + * Runs all RequiredAttributes validation functions in parallel. + * + * @param scl - The XMLDocument to validate + * @param filename - The filename to include in validation issues + * @returns Promise resolving to all issues found + */ +async function runRequiredAttributes(scl: XMLDocument, filename: string): Promise { + const ruleFunctions = Object.values(requiredAttributes).filter( + (fn): fn is (doc: XMLDocument, name: string) => ValidationIssue[] => typeof fn === 'function' + ); + + // Run all RequiredAttributes rules in parallel + const results = await Promise.all(ruleFunctions.map((rule) => rule(scl, filename))); + + // Flatten the array of arrays into a single array + return results.flat(); +} + /** * Collects all validation issues into a single array. * Useful when you want all issues at once rather than streaming. diff --git a/test/RequiredAttributes/AnyLN.test.ts b/test/RequiredAttributes/AnyLN.test.ts new file mode 100644 index 0000000..5b33f70 --- /dev/null +++ b/test/RequiredAttributes/AnyLN.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest'; + +import { + ln_lnClass_required, + ln_lnClass_valid, + ln_inst_required, + anyLn_lnType_required, + anyLn_lnType_valid +} from '../../src/RequiredAttributes/AnyLN'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +function sclXml(content: string): string { + return ` + + + + ${content} + + + `; +} + +function sclXmlWithServer(content: string): string { + return ` + + + + + + ${content} + + + + + `; +} + +describe('AnyLN', () => { + describe('ln_lnClass_required', () => { + it('should return no issues when LN under AccessPoint has lnClass', () => { + const doc = parseXMLString(sclXml('')); + expect(ln_lnClass_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when LN under LDevice has lnClass', () => { + const doc = parseXMLString(sclXmlWithServer('')); + expect(ln_lnClass_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when LN0 under LDevice has lnClass', () => { + const doc = parseXMLString(sclXmlWithServer('')); + expect(ln_lnClass_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when LN under AccessPoint has no lnClass', () => { + const doc = parseXMLString(sclXml('')); + const issues = ln_lnClass_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'LN_lnClass_required', + filename: DOC_NAME, + message: 'lnClass attribute shall be present in AnyLN', + }); + expect(issues[0].element.tagName).toBe('LN'); + }); + + it('should return an issue when LN under LDevice has no lnClass', () => { + const doc = parseXMLString(sclXmlWithServer('')); + const issues = ln_lnClass_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('LN'); + }); + + it('should return an issue when LN0 under LDevice has no lnClass', () => { + const doc = parseXMLString(sclXmlWithServer('')); + const issues = ln_lnClass_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('LN0'); + }); + }); + + describe('ln_lnClass_valid', () => { + it('should return no issues when lnClass is a valid predefined value', () => { + const validClasses = ['LLN0', 'MMXU', 'CSWI', 'XCBR', 'GGIO', 'LLN0', 'LPHD', 'LCCH', 'LGOS']; + validClasses.forEach(lnClass => { + const doc = parseXMLString(sclXml(``)); + expect(ln_lnClass_valid(doc, DOC_NAME)).toEqual([]); + }); + }); + + it('should return no issues for domain-specific lnClass values', () => { + const domainClasses = ['ANCR', 'ARCO', 'GAPC', 'GLOG', 'IARC', 'MHAI', 'PTOC', 'RREC', 'XCBR', 'YEFN', 'ZAXN']; + domainClasses.forEach(lnClass => { + const doc = parseXMLString(sclXml(``)); + expect(ln_lnClass_valid(doc, DOC_NAME)).toEqual([]); + }); + }); + + it('should return no issues when lnClass matches the pattern (4 uppercase letters)', () => { + const validPatternClasses = ['ABCD', 'XYZZ', 'WXYZ', 'LMNO']; + validPatternClasses.forEach(lnClass => { + const doc = parseXMLString(sclXml(``)); + expect(ln_lnClass_valid(doc, DOC_NAME)).toEqual([]); + }); + }); + + it('should return an issue when lnClass has wrong length (not 4 chars)', () => { + const invalidPatternClasses = ['ABC', 'ABCDE', 'AB', 'A']; + invalidPatternClasses.forEach(lnClass => { + const doc = parseXMLString(sclXml(``)); + const issues = ln_lnClass_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'LN_lnClass_valid', + filename: DOC_NAME, + message: 'lnClass attribute shall be valid in AnyLN', + }); + }); + }); + + it('should return an issue when lnClass has digits', () => { + const invalidPatternClasses = ['X1', 'Y2Z', 'A1B2']; + invalidPatternClasses.forEach(lnClass => { + const doc = parseXMLString(sclXml(``)); + const issues = ln_lnClass_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'LN_lnClass_valid', + filename: DOC_NAME, + message: 'lnClass attribute shall be valid in AnyLN', + }); + }); + }); + + it('should return an issue when lnClass is empty', () => { + const doc = parseXMLString(sclXml('')); + const issues = ln_lnClass_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'LN_lnClass_valid', + filename: DOC_NAME, + message: 'lnClass attribute shall be valid in AnyLN', + }); + }); + + it('should return an issue when lnClass is invalid', () => { + const doc = parseXMLString(sclXml('')); + const issues = ln_lnClass_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'LN_lnClass_valid', + filename: DOC_NAME, + message: 'lnClass attribute shall be valid in AnyLN', + }); + }); + + it('should return an issue when lnClass has lowercase letters', () => { + const doc = parseXMLString(sclXml('')); + const issues = ln_lnClass_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + }); + + it('should return no issues when lnClass is not present', () => { + const doc = parseXMLString(sclXml('')); + expect(ln_lnClass_valid(doc, DOC_NAME)).toEqual([]); + }); + }); + + describe('ln_inst_required', () => { + it('should return no issues when LN under AccessPoint has inst', () => { + const doc = parseXMLString(sclXml('')); + expect(ln_inst_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when LN under LDevice has inst', () => { + const doc = parseXMLString(sclXmlWithServer('')); + expect(ln_inst_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when LN0 under LDevice has empty inst', () => { + const doc = parseXMLString(sclXmlWithServer('')); + expect(ln_inst_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when LN has no inst', () => { + const doc = parseXMLString(sclXml('')); + const issues = ln_inst_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'LN_inst_required', + filename: DOC_NAME, + message: 'inst attribute shall be present in AnyLN', + }); + }); + }); + + describe('anyLn_lnType_required', () => { + it('should return no issues when LN has lnType', () => { + const doc = parseXMLString(sclXml('')); + expect(anyLn_lnType_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when LN0 has lnType', () => { + const doc = parseXMLString(sclXmlWithServer('')); + expect(anyLn_lnType_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when LN has no lnType', () => { + const doc = parseXMLString(sclXml('')); + const issues = anyLn_lnType_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_lnType_required', + filename: DOC_NAME, + message: 'lnType attribute shall be present in AnyLN', + }); + }); + }); + + describe('anyLn_lnType_valid', () => { + it('should return no issues when lnType is valid', () => { + const doc = parseXMLString(sclXml('')); + expect(anyLn_lnType_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for lnType with maximum length (128 chars)', () => { + const longType = 'a'.repeat(128); + const doc = parseXMLString(sclXml(``)); + expect(anyLn_lnType_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues for lnType with minimum length (1 char)', () => { + const doc = parseXMLString(sclXml('')); + expect(anyLn_lnType_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when lnType is empty', () => { + const doc = parseXMLString(sclXml('')); + const issues = anyLn_lnType_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_lnType_valid', + filename: DOC_NAME, + message: 'lnType attribute shall be valid in AnyLN', + }); + }); + + it('should return an issue when lnType is too long (over 128 chars)', () => { + const longType = 'a'.repeat(129); + const doc = parseXMLString(sclXml(``)); + const issues = anyLn_lnType_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'AnyLN_lnType_valid', + filename: DOC_NAME, + message: 'lnType attribute shall be valid in AnyLN', + }); + }); + + it('should return no issues when lnType is not present', () => { + const doc = parseXMLString(sclXml('')); + expect(anyLn_lnType_valid(doc, DOC_NAME)).toEqual([]); + }); + }); +}); diff --git a/test/RequiredAttributes/Association.test.ts b/test/RequiredAttributes/Association.test.ts new file mode 100644 index 0000000..b1b2f7d --- /dev/null +++ b/test/RequiredAttributes/Association.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, it } from 'vitest'; + +import { + association_kind_required, + association_associationID_valid, + association_prefix_valid, + association_lnClass_required, + association_lnClass_valid, + association_lnInst_required, + association_lnInst_valid, + association_iedName_required, + association_iedName_valid, + association_ldInst_required, + association_ldInst_valid +} from '../../src/RequiredAttributes/Association'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +function sclXml(content: string): string { + return ` + + + + + ${content} + + + + `; +} + +describe('Association - RequiredAttributes', () => { + describe('association_kind_required', () => { + it('should return no issues when Association has kind attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_kind_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when Association has no kind attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_kind_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_kind_required', + filename: DOC_NAME, + message: 'kind attribute shall be present in Association', + }); + expect(issues[0].element.tagName).toBe('Association'); + }); + + it('should return one issue per Association without kind', () => { + const doc = parseXMLString(sclXml( + '' + + '' + )); + const issues = association_kind_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0].element.tagName).toBe('Association'); + }); + }); + + describe('association_associationID_valid', () => { + it('should return no issues when associationID is valid', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_associationID_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when associationID is empty', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_associationID_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when associationID is too long', () => { + const longId = 'A'.repeat(129); + const doc = parseXMLString(sclXml( + `` + )); + const issues = association_associationID_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_associationID_valid', + filename: DOC_NAME, + message: 'associationID attribute shall be valid in Association', + }); + }); + }); + + describe('association_prefix_valid', () => { + it('should return no issues when prefix is valid', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_prefix_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when prefix is empty', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_prefix_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when prefix contains invalid characters', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_prefix_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_prefix_valid', + filename: DOC_NAME, + message: 'prefix attribute shall be valid in Association', + }); + }); + }); + + describe('association_lnClass_required', () => { + it('should return no issues when Association has lnClass attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_lnClass_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when Association has no lnClass attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_lnClass_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_lnClass_required', + filename: DOC_NAME, + message: 'lnClass attribute shall be present in Association', + }); + expect(issues[0].element.tagName).toBe('Association'); + }); + }); + + describe('association_lnClass_valid', () => { + it('should return no issues when lnClass is a valid predefined class', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_lnClass_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when lnClass is a valid extension class', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_lnClass_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when lnClass is invalid', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_lnClass_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_lnClass_valid', + filename: DOC_NAME, + message: 'lnClass attribute shall be valid in Association', + }); + }); + + it('should return an issue when lnClass is lowercase', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_lnClass_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_lnClass_valid', + }); + }); + + it('should return no issues when lnClass is LLN0', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_lnClass_valid(doc, DOC_NAME)).toEqual([]); + }); + }); + + describe('association_lnInst_required', () => { + it('should return no issues when Association has lnInst attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_lnInst_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when Association has no lnInst attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_lnInst_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_lnInst_required', + filename: DOC_NAME, + message: 'lnInst attribute shall be present in Association', + }); + expect(issues[0].element.tagName).toBe('Association'); + }); + }); + + describe('association_lnInst_valid', () => { + it('should return no issues when lnInst is valid', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_lnInst_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when lnInst is empty', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_lnInst_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when lnInst contains invalid characters', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_lnInst_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_lnInst_valid', + filename: DOC_NAME, + message: 'lnInst attribute shall be valid in Association', + }); + }); + }); + + describe('association_iedName_required', () => { + it('should return no issues when Association has iedName attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_iedName_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when Association has no iedName attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_iedName_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_iedName_required', + filename: DOC_NAME, + message: 'iedName attribute shall be present in Association', + }); + expect(issues[0].element.tagName).toBe('Association'); + }); + }); + + describe('association_iedName_valid', () => { + it('should return no issues when iedName is valid', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_iedName_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when iedName contains valid special characters', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_iedName_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when iedName contains spaces', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_iedName_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when iedName is 129 characters long', () => { + const longName = 'A'.repeat(129); + const doc = parseXMLString(sclXml( + `` + )); + expect(association_iedName_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when iedName is empty', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_iedName_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_iedName_valid', + filename: DOC_NAME, + message: 'iedName attribute shall be valid in Association', + }); + }); + + it('should return an issue when iedName is too long', () => { + const longName = 'A'.repeat(130); + const doc = parseXMLString(sclXml( + `` + )); + const issues = association_iedName_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_iedName_valid', + }); + }); + + it('should return an issue when iedName contains non-basic Latin characters', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_iedName_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_iedName_valid', + }); + }); + }); + + describe('association_ldInst_required', () => { + it('should return no issues when Association has ldInst attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_ldInst_required(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when Association has no ldInst attribute', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_ldInst_required(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_ldInst_required', + filename: DOC_NAME, + message: 'ldInst attribute shall be present in Association', + }); + expect(issues[0].element.tagName).toBe('Association'); + }); + }); + + describe('association_ldInst_valid', () => { + it('should return no issues when ldInst is valid starting with letter', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_ldInst_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when ldInst is valid starting with number', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_ldInst_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when ldInst is exactly 64 characters', () => { + const longInst = 'A' + '1'.repeat(63); + const doc = parseXMLString(sclXml( + `` + )); + expect(association_ldInst_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when ldInst contains alphanumeric and underscore', () => { + const doc = parseXMLString(sclXml( + '' + )); + expect(association_ldInst_valid(doc, DOC_NAME)).toEqual([]); + }); + + it('should return an issue when ldInst is empty', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_ldInst_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_ldInst_valid', + filename: DOC_NAME, + message: 'ldInst attribute shall be valid in Association', + }); + }); + + it('should return an issue when ldInst is too long', () => { + const longInst = 'A' + '1'.repeat(64); + const doc = parseXMLString(sclXml( + `` + )); + const issues = association_ldInst_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_ldInst_valid', + }); + }); + + it('should return an issue when ldInst starts with underscore', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_ldInst_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_ldInst_valid', + }); + }); + + it('should return an issue when ldInst starts with special character', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_ldInst_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_ldInst_valid', + }); + }); + + it('should return an issue when ldInst contains special characters', () => { + const doc = parseXMLString(sclXml( + '' + )); + const issues = association_ldInst_valid(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Association_ldInst_valid', + }); + }); + }); +}); \ No newline at end of file diff --git a/test/validate.test.ts b/test/validate.test.ts index f0bc536..49a0508 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -71,6 +71,7 @@ describe('validate function', () => { const doc = parseXMLString(validSCL); const issues = await validateAll(doc, { filename: DOC_NAME, + filter: { element_specific: false }, }); expect(Array.isArray(issues)).toBe(true); @@ -82,6 +83,7 @@ describe('validate function', () => { const doc = parseXMLString(validSCL); const issues = await validateAll(doc, { filename: DOC_NAME, + filter: { attributes_specific: false }, }); expect(Array.isArray(issues)).toBe(true); @@ -91,6 +93,7 @@ describe('validate function', () => { const doc = parseXMLString(validSCL); const issues = await validateAll(doc, { filename: DOC_NAME, + filter: { semantic_checks: false }, }); expect(Array.isArray(issues)).toBe(true); @@ -100,6 +103,7 @@ describe('validate function', () => { const doc = parseXMLString(validSCL); const issues = await validateAll(doc, { filename: DOC_NAME, + filter: { file_specific: false }, }); expect(Array.isArray(issues)).toBe(true); From f93542522711635ae1beee430ff4154f7d738e7f Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Mon, 12 Oct 2026 15:45:40 +0200 Subject: [PATCH 2/2] feat: add semantic checks for Communication --- src/SemanticConstraints/Communication.ts | 42 ++++++ src/SemanticConstraints/index.ts | 55 ++++++++ src/validate.ts | 21 +++ .../SemanticConstraints/Communication.test.ts | 125 ++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 src/SemanticConstraints/Communication.ts create mode 100644 src/SemanticConstraints/index.ts create mode 100644 test/SemanticConstraints/Communication.test.ts diff --git a/src/SemanticConstraints/Communication.ts b/src/SemanticConstraints/Communication.ts new file mode 100644 index 0000000..c47de6f --- /dev/null +++ b/src/SemanticConstraints/Communication.ts @@ -0,0 +1,42 @@ +import { areNamesUnique } from '../Helpers/BaseSimpleTypes'; +import { ValidationIssue } from '../ValidationIssue'; + +// Rule: Communication_unique_SubNetwork_name +export function Communication_unique_SubNetwork_name(scl: XMLDocument, docName: string): ValidationIssue[] { + return Array.from(scl.querySelectorAll('Communication')) + .filter((communication) => !areNamesUnique(communication.querySelectorAll('SubNetwork'))) + .map((communication) => ({ + severity: 'ERROR', + id: 'Communication_unique_SubNetwork_name', + filename: docName, + message: 'SubNetwork in Communication shall have different name', + element: communication, + })); +} + +// Rule: Communication_GSE_SMV_MACAddress_unique +export function Communication_GSE_SMV_MACAddress_unique(scl: XMLDocument, docName: string): ValidationIssue[] { + const macAddresses = Array.from(scl.querySelectorAll('P')) + .filter((p) => p.getAttribute('type') === 'MAC-Address') + .map((p) => ({ + element: p, + value: p.getAttribute('value') || p.textContent || '', + })); + + const seenMacAddresses = new Set(); + const duplicates = macAddresses.filter(({ value }) => { + if (seenMacAddresses.has(value)) { + return true; + } + seenMacAddresses.add(value); + return false; + }); + + return duplicates.map(({ element }) => ({ + severity: 'ERROR', + id: 'Communication_GSE_SMV_MACAddress_unique', + filename: docName, + message: 'GSE and SMV MAC-Addresses shall be unique in the file', + element, + })); +} diff --git a/src/SemanticConstraints/index.ts b/src/SemanticConstraints/index.ts new file mode 100644 index 0000000..a4a8f29 --- /dev/null +++ b/src/SemanticConstraints/index.ts @@ -0,0 +1,55 @@ +// SemanticConstraints index - exports all semantic constraint validation functions +// +// export * from './AbstractConductingEquipment'; +// 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 './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 './DOType'; +// export * from './EquipmentContainer'; +// export * from './FCDA'; +// export * from './GSE'; +// export * from './GSEControl'; +// export * from './GeneralEquipment'; +// export * from './History'; +// export * from './IED'; +// export * from './IEDName'; +// export * from './LDevice'; +// export * from './LN'; +// export * from './LN0'; +// export * from './LNode'; +// export * from './LNodeContainer'; +// export * from './LNodeType'; +// export * from './Line'; +// export * from './LogControl'; +// export * from './PhysConn'; +// export * from './PowerTransformer'; +// export * from './Process'; +// export * from './ReportControl'; +// export * from './SDI'; +// export * from './SampledValueControl'; +// export * from './Server'; +// export * from './ServerAt'; +// export * from './SubEquipment'; +// export * from './SubNetwork'; +// export * from './Substation'; +// export * from './TapChanger'; +// export * from './Terminal'; +// export * from './TransformerWinding'; +// export * from './Val'; +// export * from './VoltageLevel'; diff --git a/src/validate.ts b/src/validate.ts index 923abcc..9090ad0 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,6 +1,7 @@ import { ValidationIssue } from './ValidationIssue'; import * as requiredElements from './RequiredElements'; import * as requiredAttributes from './RequiredAttributes'; +import * as semanticConstraints from './SemanticConstraints'; export type ValidationOptions = { filename?: string; @@ -31,6 +32,7 @@ export async function* validate( const categories = [ { enabled: filter.element_specific !== false, run: () => runRequiredElements(scl, filename) }, { enabled: filter.attributes_specific !== false, run: () => runRequiredAttributes(scl, filename) }, + { enabled: filter.semantic_checks !== false, run: () => runSemanticConstraints(scl, filename) }, ]; // Run all enabled categories in parallel @@ -84,6 +86,25 @@ async function runRequiredAttributes(scl: XMLDocument, filename: string): Promis return results.flat(); } +/** + * Runs all SemanticConstraints validation functions in parallel. + * + * @param scl - The XMLDocument to validate + * @param filename - The filename to include in validation issues + * @returns Promise resolving to all issues found + */ +async function runSemanticConstraints(scl: XMLDocument, filename: string): Promise { + const ruleFunctions = Object.values(semanticConstraints).filter( + (fn): fn is (doc: XMLDocument, name: string) => ValidationIssue[] => typeof fn === 'function' + ); + + // Run all RequiredAttributes rules in parallel + const results = await Promise.all(ruleFunctions.map((rule) => rule(scl, filename))); + + // Flatten the array of arrays into a single array + return results.flat(); +} + /** * Collects all validation issues into a single array. * Useful when you want all issues at once rather than streaming. diff --git a/test/SemanticConstraints/Communication.test.ts b/test/SemanticConstraints/Communication.test.ts new file mode 100644 index 0000000..4e98e8b --- /dev/null +++ b/test/SemanticConstraints/Communication.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { + Communication_unique_SubNetwork_name, + Communication_GSE_SMV_MACAddress_unique +} from '../../src/SemanticConstraints/Communication'; + +const DOC_NAME = 'test.scd'; + +function parseXMLString(xmlString: string): XMLDocument { + return new DOMParser().parseFromString(xmlString, 'text/xml'); +} + +function sclWithCommunication(subNetworks: string): string { + return ` + + + ${subNetworks} + + `; +} + +describe('Communication', () => { + describe('Communication_unique_SubNetwork_name', () => { + it('should return no issues when SubNetwork names are unique', () => { + const doc = parseXMLString(sclWithCommunication( + '' + )); + expect(Communication_unique_SubNetwork_name(doc, DOC_NAME)).toEqual([]); + }); + + it('should return no issues when there is no Communication section', () => { + const doc = parseXMLString(` + `); + expect(Communication_unique_SubNetwork_name(doc, DOC_NAME)).toEqual([]); + }); + + it('should report a Communication with duplicate SubNetwork names', () => { + const doc = parseXMLString(sclWithCommunication( + '' + )); + const issues = Communication_unique_SubNetwork_name(doc, DOC_NAME); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Communication_unique_SubNetwork_name', + filename: DOC_NAME, + message: 'SubNetwork in Communication shall have different name', + }); + expect(issues[0].element.tagName).toBe('Communication'); + }); + }); + + describe('Communication_GSE_SMV_MACAddress_unique', () => { + it('should return no issues when MAC addresses are unique', () => { + const doc = parseXMLString(sclWithCommunication(` + + + +

01-0C-CD-01-00-01

+
+ +

01-0C-CD-01-00-02

+
+
+
`)); + expect(Communication_GSE_SMV_MACAddress_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should ignore P elements that are not MAC addresses', () => { + const doc = parseXMLString(sclWithCommunication(` + + +
+

10.0.0.1

+

10.0.0.1

+
+
+
`)); + expect(Communication_GSE_SMV_MACAddress_unique(doc, DOC_NAME)).toEqual([]); + }); + + it('should report the second P element with a duplicate MAC address', () => { + const doc = parseXMLString(sclWithCommunication(` + + + +
+

01-0C-CD-01-00-01

+
+
+ +
+

01-0C-CD-04-00-02

+
+
+
+
+ + + +
+

01-0C-CD-01-00-01

+
+
+ +
+

01-0C-CD-04-00-02

+
+
+
+
`)); + const issues = Communication_GSE_SMV_MACAddress_unique(doc, DOC_NAME); + expect(issues).toHaveLength(2); + expect(issues[0]).toMatchObject({ + severity: 'ERROR', + id: 'Communication_GSE_SMV_MACAddress_unique', + filename: DOC_NAME, + message: 'GSE and SMV MAC-Addresses shall be unique in the file', + }); + expect(issues[0].element.tagName).toBe('P'); + expect(issues[1].element.tagName).toBe('P'); + }); + }); +});