+ const header = html`
${page === 'Home' ? aboutBox(edition.version) : html``}
${unsafeHTML(marked(unlinkedMd))}
`;
- const children = Array.from(
+ const entries = Array.from(
md.matchAll(/\(https:..github.com.openscd.open-scd.wiki.([^)]*)\)/g)
).map(([_, child]) => child.replace(/-/g, ' '));
- return { content, children };
+ return { path, header, entries };
}
export function aboutBoxWizard(): Wizard {
@@ -64,10 +64,10 @@ export function aboutBoxWizard(): Wizard {
{
title: 'Help',
content: [
- html`
`,
+ .read=${getLinkedPages}
+ >`,
],
},
];
diff --git a/src/open-scd.ts b/src/open-scd.ts
index c5733f5b13..e99355dfec 100644
--- a/src/open-scd.ts
+++ b/src/open-scd.ts
@@ -32,10 +32,10 @@ import '@material/mwc-textfield';
import '@material/mwc-top-app-bar-fixed';
import './filtered-list.js';
+import './finder-list.js';
import './wizard-dialog.js';
import './wizard-textfield.js';
import './wizard-select.js';
-import './finder-pane.js';
import { newOpenDocEvent, newPendingStateEvent } from './foundation.js';
import { getTheme } from './themes.js';
diff --git a/src/wizards/dataset.ts b/src/wizards/dataset.ts
index 77c04827b9..22495018ed 100644
--- a/src/wizards/dataset.ts
+++ b/src/wizards/dataset.ts
@@ -1,14 +1,17 @@
import { html } from 'lit-element';
import { get, translate } from 'lit-translate';
+
import {
cloneElement,
getValue,
identity,
+ newWizardEvent,
Wizard,
WizardAction,
WizardActor,
WizardInput,
} from '../foundation.js';
+import { wizards } from './wizard-library.js';
export function updateDataSetAction(element: Element): WizardActor {
return (inputs: WizardInput[]): WizardAction[] => {
@@ -69,6 +72,17 @@ export function editDataSetWizard(element: Element): Wizard {
required
>
`,
+ html`
{
+ const wizard = wizards['FCDA'].create(element);
+ if (wizard) {
+ e.target?.dispatchEvent(newWizardEvent(wizard));
+ e.target?.dispatchEvent(newWizardEvent());
+ }
+ }}
+ >`,
html`
${Array.from(element.querySelectorAll('FCDA')).map(
fcda =>
diff --git a/src/wizards/fcda.ts b/src/wizards/fcda.ts
new file mode 100644
index 0000000000..e143a8f006
--- /dev/null
+++ b/src/wizards/fcda.ts
@@ -0,0 +1,142 @@
+import { html } from 'lit-element';
+import { get, translate } from 'lit-translate';
+
+import {
+ createElement,
+ identity,
+ selector,
+ Wizard,
+ WizardAction,
+ WizardActor,
+ WizardInput,
+} from '../foundation.js';
+import { getChildren } from './foundation/functions.js';
+
+import { Directory, FinderList } from '../finder-list.js';
+
+function newFCDA(parent: Element, path: string[]): Element | undefined {
+ const [leafTag, leafId] = path[path.length - 1].split(': ');
+ const leaf = parent.ownerDocument.querySelector(selector(leafTag, leafId));
+ if (!leaf || getChildren(leaf).length > 0) return;
+
+ const lnSegment = path.find(segment => segment.startsWith('LN'));
+ if (!lnSegment) return;
+
+ const [lnTag, lnId] = lnSegment.split(': ');
+
+ const ln = parent.ownerDocument.querySelector(selector(lnTag, lnId));
+ if (!ln) return;
+
+ const iedName = ln.closest('IED')?.getAttribute('name');
+ const ldInst = ln.closest('LDevice')?.getAttribute('inst');
+ const prefix = ln.getAttribute('prefix') ?? '';
+ const lnClass = ln.getAttribute('lnClass');
+ const lnInst = ln.getAttribute('inst') ?? '';
+
+ let doName = '';
+ let daName = '';
+ let fc = '';
+
+ for (const segment of path) {
+ const [tagName, id] = segment.split(': ');
+ if (!['DO', 'DA', 'SDO', 'BDA'].includes(tagName)) continue;
+
+ const element = parent.ownerDocument.querySelector(selector(tagName, id));
+
+ if (!element) return;
+
+ const name = element.getAttribute('name')!;
+
+ if (tagName === 'DO') doName = name;
+ if (tagName === 'SDO') doName = doName + '.' + name;
+ if (tagName === 'DA') {
+ daName = name;
+ fc = element.getAttribute('fc') ?? '';
+ }
+ if (tagName === 'BDA') daName = daName + '.' + name;
+ }
+
+ if (!iedName || !ldInst || !lnClass || !doName || !daName || !fc) return;
+
+ return createElement(parent.ownerDocument, 'FCDA', {
+ iedName,
+ ldInst,
+ prefix,
+ lnClass,
+ lnInst,
+ doName,
+ daName,
+ fc,
+ });
+}
+
+function createFCDAsAction(parent: Element): WizardActor {
+ return (inputs: WizardInput[], wizard: Element): WizardAction[] => {
+ const finder = wizard.shadowRoot!.querySelector('finder-list');
+ const paths = finder?.paths ?? [];
+
+ const actions = [];
+ for (const path of paths) {
+ const element = newFCDA(parent, path);
+
+ if (!element) continue;
+
+ actions.push({
+ new: {
+ parent,
+ element,
+ reference: null,
+ },
+ });
+ }
+
+ return actions;
+ };
+}
+
+function getDisplayString(entry: string): string {
+ return entry.replace(/^.*>/, '').trim();
+}
+
+function getReader(server: Element): (path: string[]) => Promise {
+ return async (path: string[]) => {
+ const [tagName, id] = path[path.length - 1]?.split(': ', 2);
+ const element = server.ownerDocument.querySelector(selector(tagName, id));
+
+ if (!element)
+ return { path, header: html`${translate('error')}
`, entries: [] };
+
+ return {
+ path,
+ header: undefined,
+ entries: getChildren(element).map(
+ child => `${child.tagName}: ${identity(child)}`
+ ),
+ };
+ };
+}
+
+export function createFCDAsWizard(parent: Element): Wizard | undefined {
+ const server = parent.closest('Server');
+ if (!server) return;
+
+ return [
+ {
+ title: get('wizard.title.add', { tagName: 'FCDA' }),
+ primary: {
+ label: 'add',
+ icon: 'add',
+ action: createFCDAsAction(parent),
+ },
+ content: [
+ html` path[path.length - 1]}
+ >`,
+ ],
+ },
+ ];
+}
diff --git a/src/wizards/foundation/functions.ts b/src/wizards/foundation/functions.ts
new file mode 100644
index 0000000000..5f06f3ba04
--- /dev/null
+++ b/src/wizards/foundation/functions.ts
@@ -0,0 +1,20 @@
+export function getChildren(parent: Element): Element[] {
+ if (['LDevice', 'Server'].includes(parent.tagName))
+ return Array.from(parent.children).filter(
+ child =>
+ child.tagName === 'LDevice' ||
+ child.tagName === 'LN0' ||
+ child.tagName === 'LN'
+ );
+
+ const id =
+ parent.tagName === 'LN' || parent.tagName === 'LN0'
+ ? parent.getAttribute('lnType')
+ : parent.getAttribute('type');
+
+ return Array.from(
+ parent.ownerDocument.querySelectorAll(
+ `LNodeType[id="${id}"] > DO, DOType[id="${id}"] > SDO, DOType[id="${id}"] > DA, DAType[id="${id}"] > BDA`
+ )
+ );
+}
diff --git a/src/wizards/wizard-library.ts b/src/wizards/wizard-library.ts
index 72c6652ada..ec98dbaefa 100644
--- a/src/wizards/wizard-library.ts
+++ b/src/wizards/wizard-library.ts
@@ -5,6 +5,7 @@ import {
createConductingEquipmentWizard,
editConductingEquipmentWizard,
} from './conductingequipment.js';
+import { createFCDAsWizard } from './fcda.js';
import { lNodeWizard } from './lnode.js';
import { createSubstationWizard, substationEditWizard } from './substation.js';
import {
@@ -183,7 +184,7 @@ export const wizards: Record<
},
FCDA: {
edit: emptyWizard,
- create: emptyWizard,
+ create: createFCDAsWizard,
},
FileHandling: {
edit: emptyWizard,
diff --git a/test/integration/wizards/fcda-wizarding-editing-integration.test.ts b/test/integration/wizards/fcda-wizarding-editing-integration.test.ts
new file mode 100644
index 0000000000..7383054d8b
--- /dev/null
+++ b/test/integration/wizards/fcda-wizarding-editing-integration.test.ts
@@ -0,0 +1,96 @@
+import { expect, fixture, html } from '@open-wc/testing';
+
+import { FinderList } from '../../../src/finder-list.js';
+import { MockWizardEditor } from '../../mock-wizard-editor.js';
+
+import { createFCDAsWizard } from '../../../src/wizards/fcda.js';
+
+describe('FCDA editing wizarding integration', () => {
+ let doc: XMLDocument;
+ let element: MockWizardEditor;
+ let finder: FinderList;
+ let primaryAction: HTMLElement;
+
+ beforeEach(async () => {
+ element = await fixture(html``);
+ doc = await fetch('/base/test/testfiles/wizards/fcda.scd')
+ .then(response => response.text())
+ .then(str => new DOMParser().parseFromString(str, 'application/xml'));
+
+ const wizard = createFCDAsWizard(doc.querySelector('DataSet')!);
+ element.workflow.push(wizard!);
+ await element.requestUpdate();
+
+ finder = element.wizardUI.dialog!.querySelector('finder-list')!;
+ primaryAction = (
+ element.wizardUI.dialog?.querySelector('mwc-button[slot="primaryAction"]')
+ );
+ });
+
+ describe('with a specific path', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>CircuitBreaker_CB1',
+ 'LN0: IED1>>CircuitBreaker_CB1',
+ 'DO: #Dummy.LLN0>Beh',
+ 'DA: #Dummy.LLN0.Beh>stVal',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ });
+
+ it('adds a new FCDA on primary action', async () => {
+ expect(
+ doc.querySelector(
+ 'DataSet > FCDA[iedName="IED1"][ldInst="CircuitBreaker_CB1"]' +
+ '[prefix=""][lnClass="LLN0"][lnInst=""][doName="Beh"][daName="stVal"][fc="ST"]'
+ )
+ ).to.not.exist;
+ await primaryAction.click();
+ expect(
+ doc.querySelector(
+ 'DataSet > FCDA[iedName="IED1"][ldInst="CircuitBreaker_CB1"]' +
+ '[prefix=""][lnClass="LLN0"][lnInst=""][doName="Beh"][daName="stVal"][fc="ST"]'
+ )
+ ).to.exist;
+ });
+ });
+
+ describe('with a more complex path including SDOs and BDAs', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>Meas',
+ 'LN: IED1>>Meas>My MMXU 1',
+ 'DO: #Dummy.MMXU>A',
+ 'SDO: #OpenSCD_WYE_phases>phsA',
+ 'DA: #OpenSCD_CMV_db_i_MagAndAng>cVal',
+ 'BDA: #OpenSCD_Vector_I_w_Ang>mag',
+ 'BDA: #OpenSCD_AnalogueValue_INT32>i',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ });
+
+ it('adds a new FCDA on primary action', async () => {
+ expect(
+ doc.querySelector(
+ 'DataSet > FCDA[iedName="IED1"][ldInst="Meas"]' +
+ '[prefix="My"][lnClass="MMXU"][lnInst="1"]' +
+ '[doName="A.phsA"][daName="cVal.mag.i"][fc="MX"]'
+ )
+ ).to.not.exist;
+ await primaryAction.click();
+ expect(
+ doc.querySelector(
+ 'DataSet > FCDA[iedName="IED1"][ldInst="Meas"]' +
+ '[prefix="My"][lnClass="MMXU"][lnInst="1"]' +
+ '[doName="A.phsA"][daName="cVal.mag.i"][fc="MX"]'
+ )
+ ).to.exist;
+ });
+ });
+});
diff --git a/test/testfiles/wizards/fcda.scd b/test/testfiles/wizards/fcda.scd
new file mode 100644
index 0000000000..797b2a930d
--- /dev/null
+++ b/test/testfiles/wizards/fcda.scd
@@ -0,0 +1,70 @@
+
+
+
+ TrainingIEC61850
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/unit/finder-list.test.ts b/test/unit/finder-list.test.ts
new file mode 100644
index 0000000000..3ed0f9caf3
--- /dev/null
+++ b/test/unit/finder-list.test.ts
@@ -0,0 +1,311 @@
+import { ListItem } from '@material/mwc-list/mwc-list-item';
+import { expect, fixture, html } from '@open-wc/testing';
+import '../../src/finder-list.js';
+import { Directory, FinderList, Path } from '../../src/finder-list.js';
+import { depth } from '../../src/foundation.js';
+
+const pathA = ['e2', 'e1', 'e4'];
+const pathB = ['e1', 'e4'];
+const pathC = ['e3'];
+const selectionA = { e2: { e1: { e4: {} } } };
+
+const paths = [pathA, pathB, pathC];
+const selection = {
+ e2: { e1: { e4: {} } },
+ e1: { e4: {} },
+ e3: {},
+};
+
+const entries: Record = {
+ e1: ['e2', 'e3', 'e4'],
+ e2: ['e3', 'e1'],
+ e3: [],
+ e4: ['e2', 'e1'],
+ e5: [],
+};
+
+function getTitle(path: Path): string {
+ return `Testing ${path[path.length - 1]}`;
+}
+
+function getDisplayString(entry: string, path: string[]) {
+ return 'Testing ' + path.length + ' ' + entry;
+}
+
+async function read(path: Path): Promise {
+ const dir = path[path.length - 1];
+ return {
+ path,
+ header: dir === 'e5' ? undefined : html`${dir}
`,
+ entries: entries[dir] ?? [],
+ };
+}
+
+describe('finder-list', () => {
+ let element: FinderList;
+
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('displays nothing with default properties', () =>
+ expect(element).property('container').to.be.empty);
+
+ it('translates given .paths into a .selection tree', () => {
+ element.paths = paths;
+ expect(element.selection).to.deep.equal(selection);
+ });
+
+ it('translates a given .selection tree into .paths', () => {
+ element.selection = selection;
+ expect(element.paths).to.deep.equal(paths);
+ });
+
+ it('lets you set a singleton .path directly', () => {
+ element.path = pathA;
+ expect(element.selection).to.deep.equal(selectionA);
+ expect(element.paths).to.deep.equal([pathA]);
+ });
+
+ it('lets you access a singleton .path directly', () => {
+ element.selection = selectionA;
+ expect(element.path).to.deep.equal(pathA);
+ expect(element.paths).to.deep.equal([pathA]);
+ });
+
+ it('shows an empty .path given empty .paths', () => {
+ element.paths = [];
+ expect(element.path).to.deep.equal([]);
+ });
+
+ describe('given a one-element .path with no header or entries', () => {
+ beforeEach(async () => {
+ element = await fixture(
+ html``
+ );
+ await element.loaded;
+ });
+
+ it('displays no columns', async () =>
+ expect(element).property('container').property('children').to.be.empty);
+ });
+
+ describe('given a .path and a .read method', () => {
+ let items: ListItem[];
+
+ beforeEach(async () => {
+ element = await fixture(
+ html``
+ );
+ await element.loaded;
+ items = Array.from(
+ element.shadowRoot?.querySelectorAll('mwc-list-item') ?? []
+ );
+ });
+
+ it('displays one column per path element', () =>
+ expect(element)
+ .property('container')
+ .property('children')
+ .to.have.lengthOf(pathA.length));
+
+ describe('when provided with .getTitle and .getDisplayString methods', () => {
+ beforeEach(async () => {
+ element.getDisplayString = getDisplayString;
+ element.getTitle = getTitle;
+ element.requestUpdate();
+ await element.updateComplete;
+ await element.loaded;
+ });
+
+ it('overrides filtered-list searchFieldLabels using .getTitle', () => {
+ expect(element.container.querySelector('filtered-list'))
+ .property('searchFieldLabel')
+ .to.satisfy((l: string) => l.startsWith('Testing '));
+ });
+
+ it('overrides list-item text content using .getDisplayString', () => {
+ expect(element.container.querySelector('mwc-list-item'))
+ .property('text')
+ .to.satisfy((t: string) => t.startsWith('Testing '));
+ });
+
+ it('looks like its latest snapshot', () =>
+ expect(element).shadowDom.to.equalSnapshot());
+ });
+
+ describe('when an item in the last column is selected', () => {
+ const parent = pathA[pathA.length - 1];
+ const parentEntries = entries[parent];
+ const directory = parentEntries[parentEntries.length - 1];
+
+ beforeEach(async () => {
+ items[items.length - 1].click();
+ await element.updateComplete;
+ await element.loaded;
+ });
+
+ it('appends a new column to the container', () =>
+ expect(element)
+ .property('container')
+ .property('children')
+ .to.have.lengthOf(pathA.length + 1));
+
+ it("renders the selected directory's header at the top of the new column", () =>
+ expect(element)
+ .property('container')
+ .property('lastElementChild')
+ .property('firstElementChild')
+ .to.have.text(directory)
+ .and.to.have.property('tagName', 'H2'));
+
+ it("renders the selected directory's children into a list below the header", () =>
+ expect(element)
+ .property('container')
+ .property('lastElementChild')
+ .property('lastElementChild')
+ .property('children')
+ .to.have.lengthOf(entries[directory].length));
+
+ it('looks like its latest snapshot', () =>
+ expect(element).shadowDom.to.equalSnapshot());
+ });
+
+ describe('when an item in the first column is selected', () => {
+ const parent = pathA[0];
+ const parentEntries = entries[parent];
+ const directory = parentEntries[0];
+
+ beforeEach(async () => {
+ items[0].click();
+ await element.updateComplete;
+ await element.loaded;
+ });
+
+ it('replaces all but the first column with a new column', () =>
+ expect(element)
+ .property('container')
+ .property('children')
+ .to.have.lengthOf(2));
+
+ it("renders the selected directory's header at the top of the new column", () =>
+ expect(element)
+ .property('container')
+ .property('lastElementChild')
+ .property('firstElementChild')
+ .to.have.text(directory)
+ .and.to.have.property('tagName', 'H2'));
+
+ it("renders the selected directory's children into a list below the header", () =>
+ expect(element)
+ .property('container')
+ .property('lastElementChild')
+ .property('lastElementChild')
+ .property('children')
+ .to.have.lengthOf(entries[directory].length));
+
+ it('looks like its latest snapshot', () =>
+ expect(element).shadowDom.to.equalSnapshot());
+ });
+
+ describe('when the selected item in the first column is deselected', () => {
+ beforeEach(async () => {
+ items[1].click();
+ await element.updateComplete;
+ await element.loaded;
+ });
+
+ it('renders only the first column', () =>
+ expect(element)
+ .property('container')
+ .property('children')
+ .to.have.lengthOf(1));
+
+ it('looks like its latest snapshot', () =>
+ expect(element).shadowDom.to.equalSnapshot());
+ });
+ });
+
+ describe('given the "multi" attribute, some .paths, and a .read method', () => {
+ let items: ListItem[];
+
+ beforeEach(async () => {
+ element = await fixture(
+ html``
+ );
+ await element.loaded;
+ items = Array.from(
+ element.shadowRoot?.querySelectorAll('mwc-list-item') ?? []
+ );
+ });
+
+ it('displays one column per element of the longest path', () =>
+ expect(element)
+ .property('container')
+ .property('children')
+ .to.have.lengthOf(Math.max(...paths.map(p => p.length))));
+
+ it('displays one header and one list of entries per maximum length path in the last column', () =>
+ expect(element)
+ .property('container')
+ .property('lastElementChild')
+ .property('children')
+ .to.have.lengthOf(
+ 2 *
+ paths.filter(path => path.length === depth(element.selection))
+ .length
+ ));
+
+ it('looks like its latest snapshot', () =>
+ expect(element).shadowDom.to.equalSnapshot());
+
+ describe('when an item in the first column is selected', () => {
+ const parent = paths[0][0];
+ const parentEntries = entries[parent];
+ const directory = parentEntries[0];
+
+ beforeEach(async () => {
+ items[0].click();
+ await element.updateComplete;
+ await element.loaded;
+ });
+
+ it("adds the selected directory's header to the second column", () =>
+ expect(element)
+ .property('container')
+ .descendant('.column:nth-child(2)')
+ .descendant('h2:nth-child(3)')
+ .to.have.text(directory));
+
+ it("adds the selected directory's entries to the second column", () =>
+ expect(element)
+ .property('container')
+ .descendant('.column:nth-child(2)')
+ .descendant('filtered-list:nth-child(4)')
+ .property('children')
+ .to.have.lengthOf(entries[directory].length));
+
+ it('looks like its latest snapshot', () =>
+ expect(element).shadowDom.to.equalSnapshot());
+ });
+
+ describe('when a selected item in the first column is deselected', () => {
+ beforeEach(async () => {
+ items[1].click();
+ await element.updateComplete;
+ await element.loaded;
+ });
+
+ it('removes the deselected directory from the second column', () =>
+ expect(element)
+ .property('container')
+ .descendant('.column:nth-child(2)')
+ .to.not.contain.html(
+ `${entries[paths[0][0]][1]}
`
+ ));
+
+ it('looks like its latest snapshot', () =>
+ expect(element).shadowDom.to.equalSnapshot());
+ });
+ });
+});
diff --git a/test/unit/finder-pane.test.ts b/test/unit/finder-pane.test.ts
deleted file mode 100644
index 2f6d9ae7bf..0000000000
--- a/test/unit/finder-pane.test.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { ListItem } from '@material/mwc-list/mwc-list-item';
-import { expect, fixture, html } from '@open-wc/testing';
-import { Directory, FinderPane } from '../../src/finder-pane.js';
-
-const path = ['e2', 'e1', 'e4'];
-
-const entries: Record = {
- e1: ['e2', 'e3', 'e4'],
- e2: ['e3', 'e1'],
- e3: [],
- e4: ['e2', 'e3'],
-};
-
-async function getChildren(path: string[]): Promise {
- return {
- content: html`${path[path.length - 1]}
`,
- children: entries[path[path.length - 1]],
- };
-}
-
-describe('finder-pane', () => {
- let element: FinderPane;
-
- beforeEach(async () => {
- element = await fixture(html``);
- });
-
- it('displays nothing with default properties', () =>
- expect(element).shadowDom.to.equalSnapshot());
-
- describe('given a path and a getChildren method', () => {
- let items: ListItem[];
-
- beforeEach(async () => {
- element = await fixture(
- html``
- );
- items = Array.from(
- element.shadowRoot?.querySelectorAll('mwc-list-item') ?? []
- );
- });
-
- it('displays one list of children per path entry', () =>
- expect(element).shadowDom.to.equalSnapshot());
-
- describe('when an entry in the last column is selected', () => {
- beforeEach(async () => {
- items[items.length - 1].click();
- await element.updateComplete;
- });
-
- it("appends a new column with the chosen entry's children", () =>
- expect(element).shadowDom.to.equalSnapshot());
- });
-
- describe('when an entry in the first column is selected', () => {
- beforeEach(async () => {
- items[0].click();
- await element.updateComplete;
- });
-
- it('replaces all but the first column with a new column', () =>
- expect(element).shadowDom.to.equalSnapshot());
- });
- });
-});
diff --git a/test/unit/foundation.test.ts b/test/unit/foundation.test.ts
index 2ed33c8c2d..a0bbef317c 100644
--- a/test/unit/foundation.test.ts
+++ b/test/unit/foundation.test.ts
@@ -23,6 +23,7 @@ import {
SCLTag,
getChildElementsByTagName,
cloneElement,
+ depth,
} from '../../src/foundation.js';
import { MockAction } from './mock-actions.js';
@@ -545,4 +546,32 @@ describe('foundation', () => {
expect(newElement).to.not.have.attribute('attr1');
});
});
+
+ describe('depth', () => {
+ const circular = { a: { b: {} }, c: {} };
+ circular.a.b = circular;
+
+ const fiveDeep: unknown = [
+ 'first level',
+ 2,
+ {
+ a: 'second level',
+ b: 2,
+ c: [
+ 'third level',
+ { a: 'fourth level', b: 2, c: { a: 'fifth level!' } },
+ ],
+ },
+ 'test',
+ ];
+
+ it("returns the given object's or array's depth", () =>
+ expect(depth(>fiveDeep)).to.equal(5));
+
+ it('returns zero if given something other than an object or array', () =>
+ expect(depth(>('test'))).to.equal(0));
+
+ it('returns Infinity if given a circularly defined object or array', () =>
+ expect(depth(circular)).to.not.be.finite);
+ });
});
diff --git a/test/unit/wizards/dataset.test.ts b/test/unit/wizards/dataset.test.ts
index 665efaf557..abc2cbbf0e 100644
--- a/test/unit/wizards/dataset.test.ts
+++ b/test/unit/wizards/dataset.test.ts
@@ -1,4 +1,5 @@
import { expect, fixture, html } from '@open-wc/testing';
+import sinon, { SinonSpy } from 'sinon';
import {
isUpdate,
@@ -18,6 +19,7 @@ import { MockWizard } from '../../mock-wizard.js';
describe('dataset wizards', () => {
let doc: XMLDocument;
let element: MockWizard;
+ let wizardEvent: SinonSpy;
beforeEach(async () => {
element = await fixture(html``);
@@ -26,17 +28,28 @@ describe('dataset wizards', () => {
.then(str => new DOMParser().parseFromString(str, 'application/xml'));
});
- describe('editDataSetWizard', () => {
+ describe('include a dataset edit wizard', () => {
beforeEach(async () => {
const wizard = editDataSetWizard(
doc.querySelector('IED[name="IED2"] DataSet[name="GooseDataSet1"]')!
);
element.workflow.push(wizard);
await element.requestUpdate();
+
+ wizardEvent = sinon.spy();
+ window.addEventListener('wizard', wizardEvent);
+ });
+
+ it('looks like the latest snapshot', async () =>
+ expect(element.wizardUI.dialog).to.equalSnapshot()).timeout(5000);
+
+ it('allows to add a new FCDA on add FCDA button click', async () => {
+ const addButton = (
+ element.wizardUI.dialog?.querySelector('mwc-button[icon="add"]')
+ );
+ await addButton.click();
+ expect(wizardEvent).to.be.calledTwice;
});
- it('looks like the latest snapshot', async () => {
- expect(element.wizardUI.dialog).to.equalSnapshot();
- }).timeout(5000);
});
describe('updateDataSetAction', () => {
@@ -88,6 +101,7 @@ describe('dataset wizards', () => {
);
});
});
+
describe('with connected DataSet', () => {
beforeEach(async () => {
dataSet = doc.querySelector(
diff --git a/test/unit/wizards/fcda.test.ts b/test/unit/wizards/fcda.test.ts
new file mode 100644
index 0000000000..40c469e767
--- /dev/null
+++ b/test/unit/wizards/fcda.test.ts
@@ -0,0 +1,231 @@
+import { expect, fixture, html } from '@open-wc/testing';
+import sinon, { SinonSpy } from 'sinon';
+
+import { isCreate } from '../../../src/foundation.js';
+
+import { FinderList } from '../../../src/finder-list.js';
+import { createFCDAsWizard } from '../../../src/wizards/fcda.js';
+
+import { MockWizard } from '../../mock-wizard.js';
+
+describe('create wizard for FCDA element', () => {
+ let doc: XMLDocument;
+ let element: MockWizard;
+ let finder: FinderList;
+ let primaryAction: HTMLElement;
+ let actionEvent: SinonSpy;
+
+ beforeEach(async () => {
+ element = await fixture(html``);
+ doc = await fetch('/base/test/testfiles/wizards/fcda.scd')
+ .then(response => response.text())
+ .then(str => new DOMParser().parseFromString(str, 'application/xml'));
+
+ actionEvent = sinon.spy();
+ window.addEventListener('editor-action', actionEvent);
+ });
+
+ describe('with a valid SCL file', () => {
+ beforeEach(async () => {
+ const wizard = createFCDAsWizard(doc.querySelector('DataSet')!);
+ element.workflow.push(wizard!);
+ await element.requestUpdate();
+ finder =
+ element.wizardUI.dialog!.querySelector('finder-list')!;
+ primaryAction = (
+ element.wizardUI.dialog?.querySelector(
+ 'mwc-button[slot="primaryAction"]'
+ )
+ );
+ });
+
+ it('looks like the last snapshot', () => {
+ expect(element.wizardUI.dialog).to.equalSnapshot();
+ });
+
+ it('returns undefined wizard for parents without Server', () =>
+ expect(createFCDAsWizard(doc.querySelector('AccessPoint')!)).to.be
+ .undefined);
+
+ it('indicates error in case children cannot be determined', async () => {
+ finder.paths = [['some wrong path']];
+ await element.requestUpdate();
+ await finder.loaded;
+ expect(
+ element.wizardUI.dialog
+ ?.querySelector('finder-list')
+ ?.shadowRoot?.querySelector('p')?.innerText
+ ).to.equal('[error]');
+ });
+
+ describe('with a specific path', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>CircuitBreaker_CB1',
+ 'LN0: IED1>>CircuitBreaker_CB1',
+ 'DO: #Dummy.LLN0>Beh',
+ 'DA: #Dummy.LLN0.Beh>stVal',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ await primaryAction.click();
+ });
+
+ it('returns a non empty create action on primary action click', () => {
+ expect(actionEvent).to.have.been.called;
+ expect(actionEvent.args[0][0].detail.action).to.satisfy(isCreate);
+ });
+
+ it('returns a create action that follows the definition of a FCDA', () => {
+ const newElement = (
+ actionEvent.args[0][0].detail.action.new.element
+ );
+ expect(newElement).to.have.attribute('iedName', 'IED1');
+ expect(newElement).to.have.attribute('ldInst', 'CircuitBreaker_CB1');
+ expect(newElement).to.have.attribute('prefix', '');
+ expect(newElement).to.have.attribute('lnClass', 'LLN0');
+ expect(newElement).to.have.attribute('lnInst', '');
+ expect(newElement).to.have.attribute('doName', 'Beh');
+ expect(newElement).to.have.attribute('daName', 'stVal');
+ expect(newElement).to.have.attribute('fc', 'ST');
+ });
+ });
+
+ describe('with a more complex path including SDOs and BDAs', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>Meas',
+ 'LN: IED1>>Meas>My MMXU 1',
+ 'DO: #Dummy.MMXU>A',
+ 'SDO: #OpenSCD_WYE_phases>phsA',
+ 'DA: #OpenSCD_CMV_db_i_MagAndAng>cVal',
+ 'BDA: #OpenSCD_Vector_I_w_Ang>mag',
+ 'BDA: #OpenSCD_AnalogueValue_INT32>i',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ await primaryAction.click();
+ });
+
+ it('returns a non empty create action on primary action click', () => {
+ expect(actionEvent).to.have.been.called;
+ expect(actionEvent.args[0][0].detail.action).to.satisfy(isCreate);
+ });
+
+ it('returns a create action that follows the definition of a FCDA', () => {
+ const newElement = (
+ actionEvent.args[0][0].detail.action.new.element
+ );
+ expect(newElement).to.have.attribute('iedName', 'IED1');
+ expect(newElement).to.have.attribute('ldInst', 'Meas');
+ expect(newElement).to.have.attribute('prefix', 'My');
+ expect(newElement).to.have.attribute('lnClass', 'MMXU');
+ expect(newElement).to.have.attribute('lnInst', '1');
+ expect(newElement).to.have.attribute('doName', 'A.phsA');
+ expect(newElement).to.have.attribute('daName', 'cVal.mag.i');
+ expect(newElement).to.have.attribute('fc', 'MX');
+ });
+ });
+
+ describe('with path being non leaf node', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>Meas',
+ 'LN: IED1>>Meas>My MMXU 1',
+ 'DO: #Dummy.MMXU>A',
+ 'SDO: #OpenSCD_WYE_phases>phsA',
+ 'DA: #OpenSCD_CMV_db_i_MagAndAng>cVal',
+ 'BDA: #OpenSCD_Vector_I_w_Ang>mag',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ await primaryAction.click();
+ });
+
+ it('returns a non empty create action on primary action click', () =>
+ expect(actionEvent).to.not.have.been.called);
+ });
+
+ describe('with a incorrect logical node definition in the path', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>Meas',
+ 'Ln: IED1>>Meas>My MMXU 1',
+ 'DO: #Dummy.LLN0>Beh',
+ 'DA: #Dummy.LLN0.Beh>stVal',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ await primaryAction.click();
+ });
+
+ it('does not return a empty action on primary action click', () =>
+ expect(actionEvent).to.not.have.been.called);
+ });
+
+ describe('with a incorrect logical node identity in the path', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>CircuitBreaker_CB1',
+ 'LN0: some wrong identity',
+ 'DO: #Dummy.LLN0>Beh',
+ 'DA: #Dummy.LLN0.Beh>stVal',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ await primaryAction.click();
+ });
+
+ it('does not return a empty action on primary action click', () =>
+ expect(actionEvent).to.not.have.been.called);
+ });
+
+ describe('with a incorrect DO definition in the path', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>CircuitBreaker_CB1',
+ 'LN0: IED1>>CircuitBreaker_CB1',
+ 'DO: some wrong identity',
+ 'DA: #Dummy.LLN0.Beh>stVal',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ await primaryAction.click();
+ });
+
+ it('does not return a empty action on primary action click', () =>
+ expect(actionEvent).to.not.have.been.called);
+ });
+
+ describe('with a missing fc definition in the DA in the SCL file', () => {
+ const path = [
+ 'Server: IED1>P1',
+ 'LDevice: IED1>>CircuitBreaker_CB1',
+ 'LN: IED1>>CircuitBreaker_CB1> XCBR 1',
+ 'DO: #Dummy.XCBR1>Pos',
+ 'DA: #Dummy.XCBR1.Pos>stVal',
+ ];
+
+ beforeEach(async () => {
+ finder.paths = [path];
+ await element.requestUpdate();
+ await primaryAction.click();
+ });
+
+ it('does not return a empty action on primary action click', () =>
+ expect(actionEvent).to.not.have.been.called);
+ });
+ });
+});
diff --git a/test/unit/wizards/foundation/functions.test.ts b/test/unit/wizards/foundation/functions.test.ts
new file mode 100644
index 0000000000..af4835b1da
--- /dev/null
+++ b/test/unit/wizards/foundation/functions.test.ts
@@ -0,0 +1,75 @@
+import { expect } from '@open-wc/testing';
+import { getChildren } from '../../../../src/wizards/foundation/functions.js';
+
+describe('data model nodes child getter', () => {
+ let doc: XMLDocument;
+
+ beforeEach(async () => {
+ doc = await fetch('/base/test/testfiles/wizards/fcda.scd')
+ .then(response => response.text())
+ .then(str => new DOMParser().parseFromString(str, 'application/xml'));
+ });
+
+ it('returns empty array for invalid tag', () => {
+ const parent = doc.querySelector('IED')!;
+ expect(getChildren(parent)).to.be.empty;
+ });
+
+ it('returns direct children for a Server', () => {
+ const parent = doc.querySelector('Server')!;
+ expect(getChildren(parent)).to.not.be.empty;
+ expect(getChildren(parent)[0]).to.have.attribute(
+ 'inst',
+ 'CircuitBreaker_CB1'
+ );
+ expect(getChildren(parent)[1]).to.have.attribute('inst', 'Meas');
+ });
+
+ it('returns direct children for a LDevice', () => {
+ const parent = doc.querySelector('LDevice')!;
+ expect(getChildren(parent)).to.not.be.empty;
+ expect(getChildren(parent)[0]).to.have.attribute('lnClass', 'LLN0');
+ expect(getChildren(parent)[1]).to.have.attribute('lnClass', 'XCBR');
+ });
+
+ it('returns referenced children for LN/LN0', () => {
+ const parent = doc.querySelector('LN')!;
+ expect(getChildren(parent).length).to.equal(1);
+ expect(getChildren(parent)[0]).to.have.attribute('name', 'Pos');
+ });
+
+ it('returns referenced children for DO', () => {
+ const parent = doc.querySelector('DO')!;
+ expect(getChildren(parent).length).to.equal(3);
+ expect(getChildren(parent)[0]).to.have.attribute('name', 'stVal');
+ expect(getChildren(parent)[1]).to.have.attribute('name', 'q');
+ expect(getChildren(parent)[2]).to.have.attribute('name', 't');
+ });
+
+ it('returns referenced children for SDO', () => {
+ const parent = doc.querySelector('SDO')!;
+ expect(getChildren(parent).length).to.equal(1);
+ expect(getChildren(parent)[0]).to.have.attribute('name', 'cVal');
+ });
+
+ it('returns referenced children for DA', () => {
+ const parent = doc.querySelector(
+ 'DOType[id="OpenSCD_CMV_db_i_MagAndAng"]>DA'
+ )!;
+ expect(getChildren(parent).length).to.equal(1);
+ expect(getChildren(parent)[0]).to.have.attribute('name', 'mag');
+ });
+
+ it('returns referenced children for BDA', () => {
+ const parent = doc.querySelector(
+ 'DAType[id="OpenSCD_Vector_I_w_Ang"]>BDA'
+ )!;
+ expect(getChildren(parent).length).to.equal(1);
+ expect(getChildren(parent)[0]).to.have.attribute('name', 'i');
+ });
+
+ it('returns empty array for leaf node', () => {
+ const parent = doc.querySelector('DOType[id="Dummy.XCBR1.Pos"]>DA')!;
+ expect(getChildren(parent)).to.be.empty;
+ });
+});