From 6f14c5aefd225f0be69818e1ee9413250355f0d1 Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 26 Jan 2022 16:23:17 +0100 Subject: [PATCH 1/4] fix(wiazrds/address): wrong wizard input definition --- src/wizards/address.ts | 1 + test/unit/wizards/__snapshots__/address.test.snap.js | 4 ++++ test/unit/wizards/__snapshots__/gse.test.snap.js | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/src/wizards/address.ts b/src/wizards/address.ts index addee466d4..d264e07cf2 100644 --- a/src/wizards/address.ts +++ b/src/wizards/address.ts @@ -39,6 +39,7 @@ export function renderGseSmvAddress(parent: Element): TemplateResult[] { ?.innerHTML.trim() ?? null} ?nullable=${typeNullable[ptype]} pattern="${ifDefined(typePattern[ptype])}" + required >` )}`, ]; diff --git a/test/unit/wizards/__snapshots__/address.test.snap.js b/test/unit/wizards/__snapshots__/address.test.snap.js index 1fdcbe3242..20279af7a8 100644 --- a/test/unit/wizards/__snapshots__/address.test.snap.js +++ b/test/unit/wizards/__snapshots__/address.test.snap.js @@ -15,23 +15,27 @@ snapshots["address renderGseSmvAddress looks like the latest snapshot"] = diff --git a/test/unit/wizards/__snapshots__/gse.test.snap.js b/test/unit/wizards/__snapshots__/gse.test.snap.js index b6b9a23437..5868f57be7 100644 --- a/test/unit/wizards/__snapshots__/gse.test.snap.js +++ b/test/unit/wizards/__snapshots__/gse.test.snap.js @@ -15,23 +15,27 @@ snapshots["gse wizards editGseWizard looks like the latest snapshot"] = Date: Wed, 26 Jan 2022 16:24:17 +0100 Subject: [PATCH 2/4] fix(translation): complex action title --- src/translations/de.ts | 5 +++++ src/translations/en.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/translations/de.ts b/src/translations/de.ts index 50fff20963..4a70469a6d 100644 --- a/src/translations/de.ts +++ b/src/translations/de.ts @@ -402,6 +402,11 @@ export const de: Translations = { addaddress: 'GSE bearbeitet ({{identity}})', }, }, + smv: { + action: { + addaddress: 'SMV bearbeitet ({{identity}})', + }, + }, subscriber: { title: 'Subscriber Update', description: 'GOOSE Ziele aktualisieren: ', diff --git a/src/translations/en.ts b/src/translations/en.ts index 8c23605f83..90ac64343d 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -399,6 +399,11 @@ export const en = { addaddress: 'Edit GSE ({{identity}})', }, }, + smv: { + action: { + addaddress: 'Edit SMV ({{identity}})', + }, + }, subscriber: { title: 'Subscriber update', description: 'Subscriber update: ', From 3c1664d3a3fd22e485aace2a193932077e5acb5b Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 26 Jan 2022 16:25:14 +0100 Subject: [PATCH 3/4] feat(wizards/smv): add edit wizard --- src/wizards/smv.ts | 51 +++ test/foundation.ts | 9 + .../wizards/__snapshots__/smv.test.snap.js | 117 +++++++ test/unit/wizards/smv.test.ts | 294 ++++++++++++++++++ 4 files changed, 471 insertions(+) create mode 100644 src/wizards/smv.ts create mode 100644 test/unit/wizards/__snapshots__/smv.test.snap.js create mode 100644 test/unit/wizards/smv.test.ts diff --git a/src/wizards/smv.ts b/src/wizards/smv.ts new file mode 100644 index 0000000000..9031a2bdbb --- /dev/null +++ b/src/wizards/smv.ts @@ -0,0 +1,51 @@ +import { get } from 'lit-translate'; + +import { Checkbox } from '@material/mwc-checkbox'; + +import { + ComplexAction, + identity, + Wizard, + WizardAction, + WizardActor, + WizardInput, +} from '../foundation.js'; +import { renderGseSmvAddress, updateAddress } from './address.js'; + +export function updateSmvAction(element: Element): WizardActor { + return (inputs: WizardInput[], wizard: Element): WizardAction[] => { + const complexAction: ComplexAction = { + actions: [], + title: get('smv.action.addaddress', { + identity: identity(element), + }), + }; + + const instType: boolean = (( + wizard.shadowRoot?.querySelector('#instType') + ))?.checked; + const addressActions = updateAddress(element, inputs, instType); + if (!addressActions.length) return []; + + addressActions.forEach(action => { + complexAction.actions.push(action); + }); + + return [complexAction]; + }; +} + +export function editSMvWizard(element: Element): Wizard { + return [ + { + title: get('wizard.title.edit', { tagName: element.tagName }), + element, + primary: { + label: get('save'), + icon: 'edit', + action: updateSmvAction(element), + }, + content: [...renderGseSmvAddress(element)], + }, + ]; +} diff --git a/test/foundation.ts b/test/foundation.ts index 5a3c6bcba2..5a062dc601 100644 --- a/test/foundation.ts +++ b/test/foundation.ts @@ -31,6 +31,14 @@ export function ipV6(): Arbitrary { ); } +export function MAC(): Arbitrary { + const h16Arb = hexaString({ minLength: 2, maxLength: 2 }); + const ls32Arb = tuple(h16Arb, h16Arb).map(([a, b]) => `${a}-${b}`); + return tuple(array(h16Arb, { minLength: 4, maxLength: 4 }), ls32Arb).map( + ([eh, l]) => `${eh.join('-')}-${l}` + ); +} + export function ipV6SubNet(): Arbitrary { return integer({ min: 1, max: 127 }).map(num => `/${num}`); } @@ -47,6 +55,7 @@ export const regExp = { desc: new RegExp(`^${patterns.normalizedString}$`), IPv4: /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/, IPv6: /^([0-9A-F]{2}-){5}[0-9A-F]{2}$/, + MAC: /^([0-9A-F]{2}-){5}[0-9A-F]{2}$/, OSI: /^[0-9A-F]+$/, OSIAPi: /^[0-9\u002C]+$/, OSIid: /^[0-9]+$/, diff --git a/test/unit/wizards/__snapshots__/smv.test.snap.js b/test/unit/wizards/__snapshots__/smv.test.snap.js new file mode 100644 index 0000000000..cb6b709aba --- /dev/null +++ b/test/unit/wizards/__snapshots__/smv.test.snap.js @@ -0,0 +1,117 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["Edit wizard for SCL element SMV include an edit wizard that looks like the latest snapshot"] = +` +
+ + + + + + + + + + + + +
+ + + + +
+`; +/* end snapshot Edit wizard for SCL element SMV include an edit wizard that looks like the latest snapshot */ + +snapshots["Wizards for SCL element SMV include an edit wizard that looks like the latest snapshot"] = +` +
+ + + + + + + + + + + + +
+ + + + +
+`; +/* end snapshot Wizards for SCL element SMV include an edit wizard that looks like the latest snapshot */ + diff --git a/test/unit/wizards/smv.test.ts b/test/unit/wizards/smv.test.ts new file mode 100644 index 0000000000..14a603c917 --- /dev/null +++ b/test/unit/wizards/smv.test.ts @@ -0,0 +1,294 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { SinonSpy, spy } from 'sinon'; +import fc, { hexaString, integer } from 'fast-check'; + +import '../../mock-wizard.js'; +import { MockWizard } from '../../mock-wizard.js'; + +import { + ComplexAction, + Create, + Delete, + WizardInput, +} from '../../../src/foundation.js'; +import { editSMvWizard } from '../../../src/wizards/smv.js'; +import { invertedRegex, MAC, regExp } from '../../foundation.js'; +import { WizardTextField } from '../../../src/wizard-textfield.js'; + +describe('Wizards for SCL element SMV', () => { + let doc: XMLDocument; + let element: MockWizard; + let inputs: WizardInput[]; + let input: WizardInput | undefined; + + let primaryAction: HTMLElement; + + let actionEvent: SinonSpy; + + beforeEach(async () => { + element = await fixture(html``); + + actionEvent = spy(); + window.addEventListener('editor-action', actionEvent); + }); + + describe('include an edit wizard that', () => { + beforeEach(async () => { + doc = await fetch('/test/testfiles/wizards/sampledvaluecontrol.scd') + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); + + const wizard = editSMvWizard(doc.querySelector('SMV')!); + element.workflow.push(() => wizard); + await element.requestUpdate(); + + primaryAction = ( + element.wizardUI.dialog?.querySelector( + 'mwc-button[slot="primaryAction"]' + ) + ); + + inputs = Array.from(element.wizardUI.inputs); + }); + + it('looks like the latest snapshot', async () => { + await expect(element.wizardUI.dialog).dom.to.equalSnapshot(); + }); + + describe('contains an input to edit P element of type MAC-Address', () => { + beforeEach(() => { + input = inputs.find(input => input.label === 'MAC-Address'); + }); + + it('is always rendered', () => expect(input).to.exist); + + it('allow to edit for valid input', async () => + await fc.assert( + fc.asyncProperty(MAC(), async testValue => { + input!.value = testValue.toUpperCase(); + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.true; + }) + )); + + it('does not allow to edit for invalid input', async () => + await fc.assert( + fc.asyncProperty(invertedRegex(regExp.MAC), async testValue => { + input!.value = testValue; + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.false; + }) + )); + }); + + describe('contains an input to edit P element of type APPID', () => { + beforeEach(() => { + input = inputs.find(input => input.label === 'APPID'); + }); + + it('is always rendered', () => expect(input).to.exist); + + it('allow to edit for valid input', async () => + await fc.assert( + fc.asyncProperty( + hexaString({ minLength: 4, maxLength: 4 }), + async testValue => { + input!.value = testValue.toUpperCase(); + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.true; + } + ) + )); + + it('does not allow to edit for invalid input', async () => + await fc.assert( + fc.asyncProperty(hexaString({ minLength: 5 }), async testValue => { + input!.value = testValue.toUpperCase(); + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.false; + }) + )); + + it('does not allow to edit characters < 4', async () => + await fc.assert( + fc.asyncProperty( + hexaString({ minLength: 0, maxLength: 3 }), + async testValue => { + input!.value = testValue.toUpperCase(); + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.false; + } + ) + )); + }); + + describe('contains an input to edit P element of type VLAN-ID', () => { + beforeEach(() => { + input = inputs.find(input => input.label === 'VLAN-ID'); + }); + + it('is always rendered', () => expect(input).to.exist); + + it('allow to edit for valid input', async () => + await fc.assert( + fc.asyncProperty( + hexaString({ minLength: 3, maxLength: 3 }), + async testValue => { + input!.value = testValue.toUpperCase(); + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.true; + } + ) + )); + + it('does not allow to edit for invalid input', async () => + await fc.assert( + fc.asyncProperty(hexaString({ minLength: 4 }), async testValue => { + input!.value = testValue.toUpperCase(); + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.false; + }) + )); + + it('does not allow to edit characters < 3', async () => + await fc.assert( + fc.asyncProperty( + hexaString({ minLength: 0, maxLength: 2 }), + async testValue => { + input!.value = testValue.toUpperCase(); + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.false; + } + ) + )); + }); + + describe('contains an input to edit P element of type VLAN-PRIORITY', () => { + beforeEach(() => { + input = inputs.find(input => input.label === 'VLAN-PRIORITY'); + }); + + it('is always rendered', () => expect(input).to.exist); + + it('allow to edit for valid input', async () => + await fc.assert( + fc.asyncProperty( + integer({ min: 0, max: 7 }).map(num => `${num}`), + async testValue => { + input!.value = testValue; + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.true; + } + ) + )); + + it('does not allow to edit for invalid input', async () => + await fc.assert( + fc.asyncProperty( + integer({ min: 8 }).map(num => `${num}`), + async testValue => { + input!.value = testValue; + await element.requestUpdate(); + expect(input!.checkValidity()).to.be.false; + } + ) + )); + }); + + it('does not update SMV element when no P element has changed', async () => { + primaryAction.click(); + await element.requestUpdate(); + expect(actionEvent.notCalled).to.be.true; + }); + + it('properly updates a P element of type MAC-Address', async () => { + input = ( + inputs.find(input => input.label === 'MAC-Address') + ); + input.value = '01-0C-CD-01-01-00'; + await input.requestUpdate(); + + primaryAction.click(); + await element.requestUpdate(); + + const complexAction = actionEvent.args[0][0].detail.action; + + const oldAddress = (complexAction.actions[0]).old.element; + const newAddress = (complexAction.actions[1]).new.element; + + expect( + oldAddress.querySelector('P[type="MAC-Address"]')?.textContent + ).to.equal('01-0C-CD-04-00-20'); + expect( + newAddress.querySelector('P[type="MAC-Address"]')?.textContent + ).to.equal('01-0C-CD-01-01-00'); + }); + + it('properly updates a P element of type APPID', async () => { + input = inputs.find(input => input.label === 'APPID'); + input.value = '001A'; + await input.requestUpdate(); + + primaryAction.click(); + await element.requestUpdate(); + + const complexAction = actionEvent.args[0][0].detail.action; + + const oldAddress = (complexAction.actions[0]).old.element; + const newAddress = (complexAction.actions[1]).new.element; + + expect( + oldAddress.querySelector('P[type="APPID"]')?.textContent + ).to.equal('4002'); + expect( + newAddress.querySelector('P[type="APPID"]')?.textContent + ).to.equal('001A'); + }); + + it('properly updates a P element of type VLAN-ID', async () => { + input = inputs.find(input => input.label === 'VLAN-ID'); + input.value = '07D'; + await input.requestUpdate(); + + primaryAction.click(); + await element.requestUpdate(); + + const complexAction = actionEvent.args[0][0].detail.action; + + const oldAddress = (complexAction.actions[0]).old.element; + const newAddress = (complexAction.actions[1]).new.element; + + expect( + oldAddress.querySelector('P[type="VLAN-ID"]')?.textContent + ).to.equal('007'); + expect( + newAddress.querySelector('P[type="VLAN-ID"]')?.textContent + ).to.equal('07D'); + }); + + it('properly updates a P element of type VLAN-PRIORITY', async () => { + input = ( + inputs.find(input => input.label === 'VLAN-PRIORITY') + ); + input.value = '3'; + await input.requestUpdate(); + + primaryAction.click(); + await element.requestUpdate(); + + const complexAction = actionEvent.args[0][0].detail.action; + + const oldAddress = (complexAction.actions[0]).old.element; + const newAddress = (complexAction.actions[1]).new.element; + + expect( + oldAddress.querySelector('P[type="VLAN-PRIORITY"]') + ?.textContent + ).to.equal('4'); + expect( + newAddress.querySelector('P[type="VLAN-PRIORITY"]') + ?.textContent + ).to.equal('3'); + }); + }); +}); From baf3bfd2e704e30482bbf779386068fa8ca1bb1f Mon Sep 17 00:00:00 2001 From: Jakob Vogelsang Date: Wed, 26 Jan 2022 16:26:19 +0100 Subject: [PATCH 4/4] feat(wiazrds/sampledvaluecontrol): allow access to scm wizard --- src/wizards/sampledvaluecontrol.ts | 32 +++ ...pledvaluecontrol-wizarding-editing.test.ts | 248 +++++++++++------- 2 files changed, 191 insertions(+), 89 deletions(-) diff --git a/src/wizards/sampledvaluecontrol.ts b/src/wizards/sampledvaluecontrol.ts index 003be8e3a0..ae3cfbfa49 100644 --- a/src/wizards/sampledvaluecontrol.ts +++ b/src/wizards/sampledvaluecontrol.ts @@ -23,6 +23,24 @@ import { } from '../foundation.js'; import { securityEnableEnum, smpModEnum } from './foundation/enums.js'; import { maxLength, patterns } from './foundation/limits.js'; +import { editSMvWizard } from './smv.js'; + +function getSMV(element: Element): Element | null { + const cbName = element.getAttribute('name'); + const iedName = element.closest('IED')?.getAttribute('name'); + const apName = element.closest('AccessPoint')?.getAttribute('name'); + const ldInst = element.closest('LDevice')?.getAttribute('inst'); + + return ( + element + .closest('SCL') + ?.querySelector( + `:root > Communication > SubNetwork > ` + + `ConnectedAP[iedName="${iedName}"][apName="${apName}"] > ` + + `SMV[ldInst="${ldInst}"][cbName="${cbName}"]` + ) ?? null + ); +} interface ContentOptions { name: string | null; @@ -159,6 +177,8 @@ export function editSampledValueControlWizard(element: Element): Wizard { const nofASDU = element.getAttribute('nofASDU'); const securityEnable = element.getAttribute('securityEnabled'); + const sMV = getSMV(element); + return [ { title: get('wizard.title.edit', { tagName: element.tagName }), @@ -179,6 +199,18 @@ export function editSampledValueControlWizard(element: Element): Wizard { nofASDU, securityEnable, }), + sMV + ? html`` + : html``, ], }, ]; diff --git a/test/integration/wizards/sampledvaluecontrol-wizarding-editing.test.ts b/test/integration/wizards/sampledvaluecontrol-wizarding-editing.test.ts index 41554180f7..02f5934620 100644 --- a/test/integration/wizards/sampledvaluecontrol-wizarding-editing.test.ts +++ b/test/integration/wizards/sampledvaluecontrol-wizarding-editing.test.ts @@ -3,6 +3,7 @@ import { expect, fixture, html } from '@open-wc/testing'; import '../../mock-wizard-editor.js'; import { MockWizardEditor } from '../../mock-wizard-editor.js'; +import { Button } from '@material/mwc-button'; import { ListItemBase } from '@material/mwc-list/mwc-list-item-base'; import { FilteredList } from '../../../src/filtered-list.js'; @@ -77,97 +78,166 @@ describe('Wizards for SCL element SampledValueControl', () => { let primaryAction: HTMLElement; let parentIED: Element; - beforeEach(async () => { - element.workflow.length = 0; // remove all wizard from FIFO queue - parentIED = doc.querySelectorAll('IED')[1]; - element.workflow.push(() => selectSampledValueControlWizard(parentIED)); - await element.requestUpdate(); - await new Promise(resolve => setTimeout(resolve, 20)); // await animation - - const sampledValueControlBlock = ( - (element.wizardUI.dialog?.querySelector('filtered-list')) - .items[0] - ); - sampledValueControlBlock.click(); - await new Promise(resolve => setTimeout(resolve, 20)); // await animation - - nameField = element.wizardUI.dialog!.querySelector( - 'wizard-textfield[label="name"]' - )!; - primaryAction = ( - element.wizardUI.dialog?.querySelector( - 'mwc-button[slot="primaryAction"]' - ) - ); - secondaryAction = ( - element.wizardUI.dialog?.querySelector( - 'mwc-button[slot="secondaryAction"]' - ) - ); - await nameField.updateComplete; - }); - - it('rejects name attribute starting with decimals', async () => { - expect( - parentIED - .querySelectorAll('SampledValueControl')[0] - ?.getAttribute('name') - ).to.not.equal('4adsasd'); - - nameField.value = '4adsasd'; - await element.requestUpdate(); - primaryAction.click(); - - expect( - parentIED - .querySelectorAll('SampledValueControl')[0] - ?.getAttribute('name') - ).to.not.equal('4adsasd'); - }); - - it('edits name attribute on primary action', async () => { - expect( - parentIED - .querySelectorAll('SampledValueControl')[0] - ?.getAttribute('name') - ).to.not.equal('myNewName'); - - nameField.value = 'myNewName'; - await element.requestUpdate(); - primaryAction.click(); - - expect( - parentIED - .querySelectorAll('SampledValueControl')[0] - ?.getAttribute('name') - ).to.equal('myNewName'); + describe('loaded without SMV element reference', () => { + beforeEach(async () => { + element.workflow.length = 0; // remove all wizard from FIFO queue + parentIED = doc.querySelectorAll('IED')[1]; + element.workflow.push(() => selectSampledValueControlWizard(parentIED)); + await element.requestUpdate(); + await new Promise(resolve => setTimeout(resolve, 20)); // await animation + + const sampledValueControlBlock = ( + (( + element.wizardUI.dialog?.querySelector('filtered-list') + )).items[0] + ); + sampledValueControlBlock.click(); + await new Promise(resolve => setTimeout(resolve, 20)); // await animation + + nameField = element.wizardUI.dialog!.querySelector( + 'wizard-textfield[label="name"]' + )!; + primaryAction = ( + element.wizardUI.dialog?.querySelector( + 'mwc-button[slot="primaryAction"]' + ) + ); + secondaryAction = ( + element.wizardUI.dialog?.querySelector( + 'mwc-button[slot="secondaryAction"]' + ) + ); + await nameField.updateComplete; + }); + + it('rejects name attribute starting with decimals', async () => { + expect( + parentIED + .querySelectorAll('SampledValueControl')[0] + ?.getAttribute('name') + ).to.not.equal('4adsasd'); + + nameField.value = '4adsasd'; + await element.requestUpdate(); + primaryAction.click(); + + expect( + parentIED + .querySelectorAll('SampledValueControl')[0] + ?.getAttribute('name') + ).to.not.equal('4adsasd'); + }); + + it('edits name attribute on primary action', async () => { + expect( + parentIED + .querySelectorAll('SampledValueControl')[0] + ?.getAttribute('name') + ).to.not.equal('myNewName'); + + nameField.value = 'myNewName'; + await element.requestUpdate(); + primaryAction.click(); + + expect( + parentIED + .querySelectorAll('SampledValueControl')[0] + ?.getAttribute('name') + ).to.equal('myNewName'); + }); + + it('dynamically updates wizards after attribute change', async () => { + nameField.value = 'myNewName'; + primaryAction.click(); + + await new Promise(resolve => setTimeout(resolve, 20)); // await animation + + const sampledValueControlBlock = ( + (( + element.wizardUI.dialog?.querySelector('filtered-list') + )).items[0] + ); + + expect(sampledValueControlBlock.innerHTML).to.contain('myNewName'); + }); + + it('returns back to its starting wizard on secondary action', async () => { + secondaryAction.click(); + + await new Promise(resolve => setTimeout(resolve, 100)); // await animation + + const sampledValueControlBlock = ( + (( + element.wizardUI.dialog?.querySelector('filtered-list') + )).items[0] + ); + + expect(sampledValueControlBlock.innerHTML).to.contain('MSVCB01'); + }); + + it('does not render SMV edit button', async () => { + const editSMvButton =