Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions components/lnodetype-sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ describe('LNodeTypeSidebar filtering', () => {
expect(sidebar.filteredLNodeTypes.length).to.equal(nodes.length);
});

it('returns all nodes sorted alphabetically by id', () => {
sidebar.filter = '';
const sortedIds = [
'alpha',
'bar',
'baz',
'beta',
'delta',
'epsilon',
'eta',
'foo',
'foobar',
'gamma',
'iota',
'kappa',
'qux',
'theta',
'zeta',
];
const ids = sidebar.filteredLNodeTypes.map(n => n.getAttribute('id') || '');
expect(ids).to.deep.equal(sortedIds);
});

it('filters with OR (space)', () => {
sidebar.filter = 'foo bar';
const ids = sidebar.filteredLNodeTypes.map(n => n.getAttribute('id'));
Expand All @@ -37,6 +60,13 @@ describe('LNodeTypeSidebar filtering', () => {
expect(ids).to.include('qux');
});

it('returns filtered results sorted alphabetically by id', () => {
sidebar.filter = 'foo bar';
const sortedIds = ['bar', 'foo', 'foobar', 'qux'];
const ids = sidebar.filteredLNodeTypes.map(n => n.getAttribute('id') || '');
expect(ids).to.deep.equal(sortedIds);
});

it('filters with OR (comma)', () => {
sidebar.filter = 'foo,bar';
const ids = sidebar.filteredLNodeTypes.map(n => n.getAttribute('id'));
Expand Down
37 changes: 24 additions & 13 deletions components/lnodetype-sidebar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { LitElement, html, css } from 'lit';
import { LitElement, html, css, PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';

Expand All @@ -25,8 +25,26 @@ export class LNodeTypeSidebar extends ScopedElementsMixin(LitElement) {
@state()
filter: string = '';

private sortedLNodeTypes: Element[] = [];

private debounceTimer?: number;

private static sortLNodeTypes(nodes: Element[]): Element[] {
return [...nodes].sort((a, b) => {
const aId = a.getAttribute('id')?.toLowerCase() || '';
const bId = b.getAttribute('id')?.toLowerCase() || '';
return aId.localeCompare(bId);
});
}

protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);

if (changedProperties.has('lNodeTypes')) {
this.sortedLNodeTypes = LNodeTypeSidebar.sortLNodeTypes(this.lNodeTypes);
}
}

private handleInput(e: Event) {
const { value } = e.target as HTMLInputElement;
clearTimeout(this.debounceTimer);
Expand All @@ -50,7 +68,9 @@ export class LNodeTypeSidebar extends ScopedElementsMixin(LitElement) {
}

get filteredLNodeTypes(): Element[] {
if (!this.filter.trim()) return this.lNodeTypes;
const sortedLNTypes = this.sortedLNodeTypes;

if (!this.filter.trim()) return sortedLNTypes;
// If the filter includes words separated by &, treat as a single AND group (e.g. 'a & b', 'a&b', 'a &b', 'a& b').
// Otherwise, split on comma or space (unless adjacent to &), so 'a b' and 'a,b' are separate OR groups.
let groups: string[][] = [];
Expand All @@ -74,9 +94,9 @@ export class LNodeTypeSidebar extends ScopedElementsMixin(LitElement) {
.filter(group => group.length > 0);
}

if (groups.length === 0) return this.lNodeTypes;
if (groups.length === 0) return sortedLNTypes;

return this.lNodeTypes.filter(ln => {
return sortedLNTypes.filter(ln => {
const id = ln.getAttribute('id')?.toLowerCase() || '';
const desc = ln.getAttribute('desc')?.toLowerCase() || '';
return groups.some(group =>
Expand Down Expand Up @@ -125,15 +145,6 @@ export class LNodeTypeSidebar extends ScopedElementsMixin(LitElement) {
</div>`;
}

updated(changedProperties: Map<string, unknown>) {
super.updated?.(changedProperties);
// Scroll oscd-list to top when lNodeTypes changes
if (changedProperties.has('lNodeTypes')) {
const oscdList = this.renderRoot.querySelector('oscd-list');
if (oscdList) oscdList.scrollTop = 0;
}
}

static styles = css`
.sidebar {
display: flex;
Expand Down
252 changes: 251 additions & 1 deletion foundation/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/* eslint-disable no-unused-expressions */
import { expect } from '@open-wc/testing';
import { removeDOsNotInSelection, computeOrphanedRemoves } from './utils.js';
import {
removeDOsNotInSelection,
computeOrphanedRemoves,
getMissingMandatoryFields,
getEmptyReferencedElements,
} from './utils.js';
import { nsdSpeced } from '../scl-template-update.testfiles.js';

describe('foundation/utils', () => {
Expand Down Expand Up @@ -165,4 +170,249 @@ describe('foundation/utils', () => {
);
});
});

describe('getMissingMandatoryFields', () => {
const tree: any = {
ReqDO: {
presCond: 'M',
children: {
stVal: {
mandatory: true,
children: {
q: {
mandatory: true,
},
},
},
optionalField: {
mandatory: false,
},
},
},
OptDO: {
presCond: 'O',
children: {
requiredUnderOptionalDo: {
mandatory: true,
},
},
},
};

it('returns missing mandatory subfields for present DOs', () => {
const selection = {
ReqDO: {},
};

const missing = getMissingMandatoryFields(tree, selection);

expect(missing).to.deep.equal([
{
path: ['ReqDO', 'stVal'],
kind: 'subfield',
},
]);
});

it('returns no missing mandatory subfields for optional DOs missing in file', () => {
const selection = {
ReqDO: {
stVal: {
q: {},
},
},
};

const missing = getMissingMandatoryFields(tree, selection);

expect(missing).to.deep.equal([]);
});

it('returns deeper missing mandatory subfields for present branches', () => {
const selection = {
ReqDO: {
stVal: {},
},
};

const missing = getMissingMandatoryFields(tree, selection);

expect(missing).to.deep.equal([
{
path: ['ReqDO', 'stVal', 'q'],
kind: 'subfield',
},
]);
});

it('returns missing fields sorted alphabetically by path', () => {
const unorderedTree: any = {
ReqDO: {
children: {
zField: { mandatory: true },
aField: { mandatory: true },
},
},
};

const selection = {
ReqDO: {},
};

const missing = getMissingMandatoryFields(unorderedTree, selection);

expect(missing.map(field => field.path.join('/'))).to.deep.equal([
'ReqDO/aField',
'ReqDO/zField',
]);
});
});

describe('getEmptyReferencedElements', () => {
it('returns empty referenced DAType elements', () => {
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL"><DataTypeTemplates>
<LNodeType id="RBRF1" lnClass="RBRF">
<DO name="DetValA" type="DetValAType" />
</LNodeType>
<DOType id="DetValAType" cdc="ASG">
<DA name="setMag" bType="Struct" fc="SE" type="setMagType" />
</DOType>
<DAType id="setMagType" />
</DataTypeTemplates></SCL>`,
'application/xml'
);

const lNodeType = doc.querySelector('LNodeType')!;

expect(getEmptyReferencedElements(lNodeType)).to.deep.equal([
{
tagName: 'DAType',
id: 'setMagType',
referencePath: 'DetValA.setMag',
},
]);
});

it('does not return non-empty DAType elements', () => {
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL"><DataTypeTemplates>
<LNodeType id="RBRF1" lnClass="RBRF">
<DO name="DetValA" type="DetValAType" />
</LNodeType>
<DOType id="DetValAType" cdc="ASG">
<DA name="setMag" bType="Struct" fc="SE" type="setMagType" />
</DOType>
<DAType id="setMagType">
<BDA name="f" bType="FLOAT32" />
</DAType>
</DataTypeTemplates></SCL>`,
'application/xml'
);

const lNodeType = doc.querySelector('LNodeType')!;

expect(getEmptyReferencedElements(lNodeType)).to.deep.equal([]);
});

it('reports empty referenced DOType elements', () => {
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL"><DataTypeTemplates>
<LNodeType id="RBRF1" lnClass="RBRF">
<DO name="EmptyDo" type="EmptyDoType" />
</LNodeType>
<DOType id="EmptyDoType" cdc="ASG" />
</DataTypeTemplates></SCL>`,
'application/xml'
);

const lNodeType = doc.querySelector('LNodeType')!;

expect(getEmptyReferencedElements(lNodeType)).to.deep.equal([
{
tagName: 'DOType',
id: 'EmptyDoType',
referencePath: 'EmptyDo',
},
]);
});

it('returns empty referenced EnumType elements', () => {
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL"><DataTypeTemplates>
<LNodeType id="RBRF1" lnClass="RBRF">
<DO name="Mod" type="ModType" />
</LNodeType>
<DOType id="ModType" cdc="ENS">
<DA name="stVal" bType="Enum" fc="ST" type="EmptyEnumType" />
</DOType>
<EnumType id="EmptyEnumType" />
</DataTypeTemplates></SCL>`,
'application/xml'
);

const lNodeType = doc.querySelector('LNodeType')!;

expect(getEmptyReferencedElements(lNodeType)).to.deep.equal([
{
tagName: 'EnumType',
id: 'EmptyEnumType',
referencePath: 'Mod.stVal',
},
]);
});

it('returns one empty element warning per usage path', () => {
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL"><DataTypeTemplates>
<LNodeType id="YPTR1" lnClass="YPTR">
<DO name="HiVRtg" type="SharedDoType" />
<DO name="LoVRtg" type="SharedDoType" />
</LNodeType>
<DOType id="SharedDoType" cdc="ASG">
<DA name="setMag" bType="Struct" fc="SE" type="setMagType" />
</DOType>
<DAType id="setMagType" />
</DataTypeTemplates></SCL>`,
'application/xml'
);

const lNodeType = doc.querySelector('LNodeType')!;

expect(getEmptyReferencedElements(lNodeType)).to.deep.equal([
{
tagName: 'DAType',
id: 'setMagType',
referencePath: 'HiVRtg.setMag',
},
{
tagName: 'DAType',
id: 'setMagType',
referencePath: 'LoVRtg.setMag',
},
]);
});

it('does not return empty warnings for paths already flagged as missing mandatory fields', () => {
const doc = new DOMParser().parseFromString(
`<SCL xmlns="http://www.iec.ch/61850/2003/SCL"><DataTypeTemplates>
<LNodeType id="RBRF1" lnClass="RBRF">
<DO name="Blk" type="BlkType" />
</LNodeType>
<DOType id="BlkType" cdc="SPS" />
</DataTypeTemplates></SCL>`,
'application/xml'
);

const lNodeType = doc.querySelector('LNodeType')!;
const missingMandatoryFields: Array<{
path: string[];
kind: 'subfield';
}> = [{ path: ['Blk', 'stVal'], kind: 'subfield' }];

expect(
getEmptyReferencedElements(lNodeType, missingMandatoryFields)
).to.deep.equal([]);
});
});
});
Loading