diff --git a/packages/openscd/src/translations/de.ts b/packages/openscd/src/translations/de.ts index 35be0f927c..c6a8233c7f 100644 --- a/packages/openscd/src/translations/de.ts +++ b/packages/openscd/src/translations/de.ts @@ -230,6 +230,7 @@ export const de: Translations = { toggleChildElements: 'Kindelemente umschalten', settings: 'Services für IED or AccessPoint', createIed: 'Virtuelles IED erstellen', + addAccessPoint: 'AccessPoint hinzufügen', wizard: { daTitle: 'DA Informationen anzeigen', doTitle: 'DO Informationen anzeigen', @@ -254,6 +255,34 @@ export const de: Translations = { nameFormatError: 'IED Name darf keine Leerzeichen enthalten', nameUniqueError: 'IED Name ist bereits vergeben', }, + addAccessPointDialog: { + title: 'AccessPoint hinzufügen', + nameHelper: 'AccessPoint Name', + descHelper: 'AccessPoint Beschreibung', + apName: 'AccessPoint Name', + createServerAt: 'ServerAt hinzufügen', + selectAccessPoint: 'AccessPoint auswählen', + serverAtDesc: 'ServerAt Beschreibung', + nameFormatError: 'AccessPoint Name darf keine Leerzeichen enthalten', + nameUniqueError: 'AccessPoint Name ist bereits vergeben', + nameTooLongError: 'AccessPoint Name ist zu lang', + }, + addLDeviceDialog: { + title: 'LDevice hinzufügen', + inst: 'LDevice inst', + desc: 'LDevice Beschreibung', + instRequiredError: 'LDevice inst ist erforderlich', + instFormatError: 'LDevice inst darf keine Leerzeichen enthalten', + instUniqueError: 'LDevice inst ist bereits vergeben', + instTooLongError: 'LDevice inst ist zu lang', + }, + addLnDialog: { + title: 'LN hinzufügen', + amount: 'Menge', + prefix: 'Prefix', + filter: 'Logical Node Types filtern', + noResults: 'Keine Logical Node Types gefunden', + }, }, ied: { wizard: { diff --git a/packages/openscd/src/translations/en.ts b/packages/openscd/src/translations/en.ts index 7692f89b11..8345e33ac2 100644 --- a/packages/openscd/src/translations/en.ts +++ b/packages/openscd/src/translations/en.ts @@ -227,6 +227,7 @@ export const en = { toggleChildElements: 'Toggle child elements', settings: 'Show Services the IED/AccessPoint provides', createIed: 'Create Virtual IED', + addAccessPoint: 'Add AccessPoint', wizard: { daTitle: 'Show DA Info', doTitle: 'Show DO Info', @@ -247,10 +248,38 @@ export const en = { daValue: 'Data attribute value', }, createDialog: { - iedName: 'IED Name', + iedName: 'IED name', nameFormatError: 'IED name cannot contain spaces', nameUniqueError: 'IED name already exists', }, + addAccessPointDialog: { + title: 'Add AccessPoint', + nameHelper: 'AccessPoint name', + descHelper: 'AccessPoint description', + apName: 'AccessPoint name', + createServerAt: 'Add ServerAt', + selectAccessPoint: 'Select AccessPoint', + serverAtDesc: 'ServerAt description', + nameFormatError: 'AccessPoint name cannot contain spaces', + nameUniqueError: 'AccessPoint name already exists', + nameTooLongError: 'AccessPoint name is too long', + }, + addLDeviceDialog: { + title: 'Add LDevice', + inst: 'LDevice inst', + desc: 'LDevice description', + instRequiredError: 'LDevice inst is required', + instFormatError: 'Invalid inst', + instUniqueError: 'LDevice inst already exists', + instTooLongError: 'LDevice inst is too long', + }, + addLnDialog: { + title: 'Add LN', + amount: 'Amount', + prefix: 'Prefix', + filter: 'Filter Logical Node Types', + noResults: 'No Logical Node Types found', + }, }, ied: { wizard: { diff --git a/packages/plugins/src/components/tooltip.ts b/packages/plugins/src/components/tooltip.ts new file mode 100644 index 0000000000..81690af258 --- /dev/null +++ b/packages/plugins/src/components/tooltip.ts @@ -0,0 +1,112 @@ +import { + css, + customElement, + html, + LitElement, + property, + TemplateResult, +} from 'lit-element'; + +/** A tooltip element that follows the mouse cursor and displays a text box. */ +@customElement('oscd-tooltip-4c6027dd') +export class OscdTooltip extends LitElement { + @property({ type: String }) + text = ''; + + @property({ type: Boolean, reflect: true }) + visible = false; + + @property({ type: Number }) + x = 0; + + @property({ type: Number }) + y = 0; + + @property({ type: Number }) + offset = 12; + + private pendingFrame = 0; + + show(text: string, clientX: number, clientY: number): void { + this.text = text; + this.visible = true; + this.updatePosition(clientX, clientY); + } + + hide(): void { + this.visible = false; + this.text = ''; + if (this.pendingFrame) { + cancelAnimationFrame(this.pendingFrame); + this.pendingFrame = 0; + } + } + + updatePosition(clientX: number, clientY: number): void { + this.x = clientX + this.offset; + this.y = clientY + this.offset; + + if (this.pendingFrame) return; + + this.pendingFrame = requestAnimationFrame(() => { + this.pendingFrame = 0; + if (!this.visible) return; + + const tipRect = this.getBoundingClientRect(); + let x = this.x; + let y = this.y; + + const innerW = window.innerWidth; + const innerH = window.innerHeight; + + if (x + tipRect.width + this.offset > innerW) { + x = this.x - this.offset - tipRect.width - this.offset; + } + if (x < this.offset) x = this.offset; + + if (y + tipRect.height + this.offset > innerH) { + y = this.y - this.offset - tipRect.height - this.offset; + } + if (y < this.offset) y = this.offset; + + this.style.transform = `translate3d(${Math.round(x)}px, ${Math.round( + y + )}px, 0)`; + }); + } + + render(): TemplateResult { + return html`${this.text}`; + } + + static styles = css` + :host { + position: fixed; + pointer-events: none; + background: rgba(20, 20, 20, 0.95); + color: rgba(240, 240, 240, 0.98); + padding: 6px 8px; + border-radius: 4px; + font-size: 0.85em; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); + z-index: 6000; + max-width: 60vw; + border: 1px solid rgba(255, 255, 255, 0.04); + left: 0; + top: 0; + transform: translate3d(0, 0, 0); + will-change: transform; + opacity: 1; + transition: opacity 0.15s ease-in-out; + } + + :host(:not([visible])) { + opacity: 0; + pointer-events: none; + } + + :host([hidden]) { + display: none; + } + `; +} diff --git a/packages/plugins/src/editors/ied/add-access-point-dialog.ts b/packages/plugins/src/editors/ied/add-access-point-dialog.ts new file mode 100644 index 0000000000..d709a0fca2 --- /dev/null +++ b/packages/plugins/src/editors/ied/add-access-point-dialog.ts @@ -0,0 +1,250 @@ +import { + css, + html, + LitElement, + property, + state, + TemplateResult, + query, + customElement, +} from 'lit-element'; +import { get, translate } from 'lit-translate'; + +import { Dialog } from '@material/mwc-dialog'; +import '@material/mwc-dialog'; +import '@material/mwc-button'; +import '@material/mwc-textfield'; +import '@material/mwc-switch'; +import '@material/mwc-formfield'; +import '@material/mwc-select'; +import '@material/mwc-list/mwc-list-item'; + +import { + getExistingAccessPointNames, + getAccessPointsWithServer, +} from './foundation.js'; +import { TextField } from '@material/mwc-textfield'; + +export interface AccessPointCreationData { + name: string; + createServerAt: boolean; + serverAtApName?: string; + serverAtDesc?: string; +} + +/** A dialog component for adding new AccessPoints */ +@customElement('add-access-point-dialog') +export class AddAccessPointDialog extends LitElement { + @property() + doc!: XMLDocument; + + @property() + ied!: Element; + + @property({ type: Function }) + onConfirm!: (data: AccessPointCreationData) => void; + + @query('#createAccessPointDialog') dialog!: Dialog; + + @query('#apName') apNameField!: TextField; + + @state() + private apName = ''; + + @state() + private createServerAt = false; + + @state() + private serverAtApName = ''; + + @state() + private serverAtDesc = ''; + + get open() { + return this.dialog?.open ?? false; + } + + private isApNameUnique(name: string): boolean { + const existingNames = getExistingAccessPointNames(this.ied); + return !existingNames.includes(name); + } + + private get accessPointsWithServer(): string[] { + return getAccessPointsWithServer(this.ied); + } + + public show(): void { + this.reset(); + this.dialog.show(); + } + + private reset(): void { + this.apName = ''; + this.createServerAt = false; + this.serverAtApName = ''; + this.serverAtDesc = ''; + } + + private close(): void { + this.dialog.close(); + this.reset(); + } + + private handleCreate(): void { + if (this.apNameField.checkValidity()) { + const data: AccessPointCreationData = { + name: this.apName, + createServerAt: this.createServerAt, + serverAtApName: this.createServerAt ? this.serverAtApName : undefined, + serverAtDesc: + this.createServerAt && this.serverAtDesc + ? this.serverAtDesc + : undefined, + }; + this.onConfirm(data); + this.close(); + } + } + + private getApNameError(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + if (!/^[A-Za-z0-9][0-9A-Za-z_]*$/.test(trimmed)) + return get('iededitor.addAccessPointDialog.nameFormatError'); + if (trimmed.length > 32) + return get('iededitor.addAccessPointDialog.nameTooLongError'); + if (!this.isApNameUnique(trimmed)) + return get('iededitor.addAccessPointDialog.nameUniqueError'); + return ''; + } + + private renderServerAtSection(): TemplateResult { + return html` + + { + this.createServerAt = (e.target as HTMLInputElement).checked; + this.serverAtApName = this.createServerAt + ? this.accessPointsWithServer[0] + : ''; + }} + > + + ${this.createServerAt + ? html` + { + e.stopPropagation(); + this.serverAtApName = (e.target as HTMLSelectElement).value; + }} + @click=${(e: Event) => e.stopPropagation()} + @closed=${(e: Event) => e.stopPropagation()} + style="width: 100%; margin-bottom: 16px;" + > + ${this.accessPointsWithServer.map( + (ap: string) => + html`${ap}` + )} + + { + this.serverAtDesc = (e.target as HTMLInputElement).value; + }} + style="width: 100%; margin-bottom: 16px;" + > + ` + : ''} + `; + } + + render(): TemplateResult { + return html` + +
+ { + const error = this.getApNameError(value); + return { + valid: error === '', + customError: error !== '', + }; + }} + pattern="[A-Za-z0-9][0-9A-Za-z_]*" + maxLength="32" + required + autoValidate + helper=${translate('iededitor.addAccessPointDialog.apName')} + dialogInitialFocus + style="width: 100%; margin-bottom: 16px;" + @input=${(e: Event) => { + this.apName = (e.target as HTMLInputElement).value; + }} + > + ${this.renderServerAtSection()} +
+ + ${translate('close')} + + + ${translate('add')} + +
+ `; + } + + static styles = css` + .dialog-content { + margin-top: 16px; + width: 320px; + max-width: 100vw; + box-sizing: border-box; + } + + mwc-formfield { + display: block; + } + + mwc-select, + mwc-textfield { + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + } + + mwc-formfield { + margin-bottom: 12px; + } + `; +} diff --git a/packages/plugins/src/editors/ied/add-ldevice-dialog.ts b/packages/plugins/src/editors/ied/add-ldevice-dialog.ts new file mode 100644 index 0000000000..740f39d7e9 --- /dev/null +++ b/packages/plugins/src/editors/ied/add-ldevice-dialog.ts @@ -0,0 +1,134 @@ +import { + css, + customElement, + html, + LitElement, + property, + query, + state, + TemplateResult, +} from 'lit-element'; +import { translate, get } from 'lit-translate'; + +import { Dialog } from '@material/mwc-dialog'; +import '@material/mwc-dialog'; +import '@material/mwc-textfield'; +import '@material/mwc-button'; + +import { getLDeviceInsts } from './foundation'; + +export interface LDeviceData { + inst: string; +} + +/** Dialog for adding a new LDevice to a Server. */ +@customElement('add-ldevice-dialog') +export class AddLDeviceDialog extends LitElement { + @property() + server!: Element; + + @property({ type: Function }) + onConfirm!: (data: LDeviceData) => void; + + @query('#addLDeviceDialog') dialog!: Dialog; + + @state() + private inst: string = ''; + + connectedCallback(): void { + super.connectedCallback(); + } + + public show(): void { + this.inst = ''; + this.dialog.show(); + } + + private close(): void { + this.dialog.close(); + } + + private handleCreate(): void { + const data: LDeviceData = { + inst: this.inst, + }; + this.onConfirm(data); + this.close(); + } + + private get lDeviceInst(): string[] { + return getLDeviceInsts(this.server); + } + + private getInstError(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return get('iededitor.addLDeviceDialog.instRequiredError'); + if (!/^[A-Za-z0-9][0-9A-Za-z_]*$/.test(trimmed)) + return get('iededitor.addLDeviceDialog.instFormatError'); + if (trimmed.length > 64) + return get('iededitor.addLDeviceDialog.instTooLongError'); + if (this.lDeviceInst.includes(trimmed)) + return get('iededitor.addLDeviceDialog.instUniqueError'); + return ''; + } + + render(): TemplateResult { + const error = this.getInstError(this.inst); + return html` + +
+ { + const err = this.getInstError(value); + return { + valid: err === '', + customError: err !== '', + }; + }} + pattern="[A-Za-z0-9][0-9A-Za-z_]*" + maxLength="64" + required + autoValidate + dialogInitialFocus + @input=${(e: Event) => { + this.inst = (e.target as HTMLInputElement).value; + }} + style="width: 100%;" + > +
+ + ${translate('close')} + + + ${translate('add')} + +
+ `; + } + static styles = css` + .dialog-content { + margin-top: 16px; + width: 320px; + max-width: 100vw; + box-sizing: border-box; + } + `; +} diff --git a/packages/plugins/src/editors/ied/add-ln-dialog.ts b/packages/plugins/src/editors/ied/add-ln-dialog.ts new file mode 100644 index 0000000000..35a90466ec --- /dev/null +++ b/packages/plugins/src/editors/ied/add-ln-dialog.ts @@ -0,0 +1,306 @@ +import { + css, + customElement, + html, + LitElement, + property, + query, + state, + TemplateResult, +} from 'lit-element'; +import { translate } from 'lit-translate'; + +import { Dialog } from '@material/mwc-dialog'; +import '@material/mwc-dialog'; +import '@material/mwc-textfield'; +import '@material/mwc-button'; +import '@material/mwc-select'; +import '@material/mwc-list/mwc-list-item'; + +import '../../components/tooltip'; +import { OscdTooltip } from '../../components/tooltip'; +import { getLNodeTypes } from './foundation'; + +export interface LNData { + lnType: string; + lnClass: string; + amount: number; + prefix?: string; +} + +/** Dialog for adding a new LN to a LDevice. */ +@customElement('add-ln-dialog') +export class AddLnDialog extends LitElement { + @property({ attribute: false }) + doc!: XMLDocument; + + @property({ type: Function }) + onConfirm!: (data: LNData) => void; + + @query('#addLnDialog') + dialog!: Dialog; + + @query('oscd-tooltip-4c6027dd') + tooltip!: OscdTooltip; + + @state() + lnType: string = ''; + + @state() + amount: number = 1; + + @state() + filterText = ''; + + @state() + prefix: string = ''; + + private get lNodeTypes(): Array<{ + id: string; + lnClass: string; + desc?: string; + }> { + if (!this.doc) return []; + return getLNodeTypes(this.doc).map(lnType => ({ + id: lnType.getAttribute('id') || '', + lnClass: lnType.getAttribute('lnClass') || '', + desc: lnType.getAttribute('desc') || undefined, + })); + } + + private get filteredLNodeTypes() { + const filter = this.filterText.trim().toLowerCase(); + if (!filter) return this.lNodeTypes; + return this.lNodeTypes.filter( + t => + t.lnClass.toLowerCase().includes(filter) || + t.id.toLowerCase().includes(filter) || + (t.desc?.toLowerCase().includes(filter) ?? false) + ); + } + + public show(): void { + this.lnType = ''; + this.amount = 1; + this.filterText = ''; + this.prefix = ''; + this.dialog.show(); + } + + private close(): void { + this.dialog.close(); + } + + private isPrefixValid(prefix: string): boolean { + if (prefix === '') return true; + if (prefix.length > 8) return false; + return /^[A-Za-z][0-9A-Za-z_]*$/.test(prefix); + } + + private handleCreate(): void { + const selectedType = this.lNodeTypes.find(t => t.id === this.lnType); + if (!selectedType) return; + const data: LNData = { + lnType: selectedType.id, + lnClass: selectedType.lnClass, + amount: this.amount, + ...(this.prefix && { prefix: this.prefix }), + }; + + this.onConfirm(data); + this.close(); + } + + private onListItemEnter(e: MouseEvent, id: string): void { + const target = e.currentTarget as HTMLElement; + const idSpan = target.querySelector('[data-ln-id]') as HTMLElement; + + const isOverflowing = idSpan.scrollWidth > idSpan.clientWidth; + if (!isOverflowing || !this.tooltip) return; + + this.tooltip.show(id, e.clientX, e.clientY); + } + + private onListItemMove(e: MouseEvent): void { + if (!this.tooltip || !this.tooltip.visible) return; + this.tooltip.updatePosition(e.clientX, e.clientY); + } + + private onListItemLeave(): void { + if (this.tooltip) { + this.tooltip.hide(); + } + } + + render(): TemplateResult { + return html` + +
+
+ { + e.stopPropagation(); + this.filterText = (e.target as HTMLInputElement).value; + }} + style="margin-bottom: 8px; width: 100%;" + > +
+ e.stopPropagation()} + > + ${this.filteredLNodeTypes.length === 0 + ? html`${translate( + 'iededitor.addLnDialog.noResults' + )}` + : this.filteredLNodeTypes.map( + t => html` + { + e.stopPropagation(); + this.lnType = t.id; + }} + value=${t.id} + dialogAction="none" + style="cursor: pointer;" + @mouseenter=${(e: MouseEvent) => + this.onListItemEnter(e, t.id)} + @mousemove=${(e: MouseEvent) => + this.onListItemMove(e)} + @mouseleave=${() => this.onListItemLeave()} + > + ${t.id} + ${t.desc || ''} + + ` + )} + +
+
+ { + e.stopPropagation(); + this.prefix = (e.target as HTMLInputElement).value; + }} + pattern="[A-Za-z][0-9A-Za-z_]*" + style="width: 100%; margin-top: 12px;" + data-testid="prefix" + > + { + e.stopPropagation(); + this.amount = Number((e.target as HTMLInputElement).value); + }} + @click=${(e: Event) => e.stopPropagation()} + @mousedown=${(e: Event) => e.stopPropagation()} + @mouseup=${(e: Event) => e.stopPropagation()} + style="width: 100%; margin-top: 12px;" + > +
+ + ${translate('close')} + + + ${translate('add')} + + +
+ `; + } + + static styles = css` + .dialog-content { + margin-top: 16px; + width: 400px; + max-width: 100vw; + box-sizing: border-box; + } + + .ln-list-scroll { + width: 100%; + height: 240px; + overflow-y: auto; + border: 1px solid #ccc; + border-radius: 4px; + background: var(--mdc-theme-surface, #fff); + } + + mwc-list-item { + --mdc-list-item-graphic-size: 0; + min-height: 56px; + height: 56px; + max-height: 56px; + padding-top: 0; + padding-bottom: 0; + } + + mwc-list-item[selected] { + background: var(--mdc-theme-primary, #6200ee); + } + + mwc-list-item[selected] .ln-list-id, + mwc-list-item[selected] .ln-list-desc { + color: var(--mdc-theme-on-primary, #fff); + } + + mwc-list-item > span.ln-list-id, + mwc-list-item > span.ln-list-desc { + display: block; + width: 100%; + } + + .ln-list-id { + font-size: 1em; + line-height: 1.2; + color: var(--mdc-theme-on-surface, #222); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ln-list-desc { + font-size: 0.95em; + color: var(--mdc-theme-text-secondary-on-background, #666); + line-height: 1.1; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + `; +} diff --git a/packages/plugins/src/editors/ied/create-ied-dialog.ts b/packages/plugins/src/editors/ied/create-ied-dialog.ts index f7950607cd..e53ec724c0 100644 --- a/packages/plugins/src/editors/ied/create-ied-dialog.ts +++ b/packages/plugins/src/editors/ied/create-ied-dialog.ts @@ -98,16 +98,22 @@ export class CreateIedDialog extends LitElement { customError: error !== '', }; }} + required autoValidate + helper=${translate('iededitor.createDialog.iedName')} + dialogInitialFocus + style="width: 100%; margin-bottom: 16px;" @input=${(e: Event) => { this.newIedName = (e.target as HTMLInputElement).value; }} - required - style="width: 100%; margin-bottom: 16px;" > - - ${translate('cancel')} + + ${translate('close')} = { apName }; + + if (desc) { + attributes.desc = desc; + } + + return createElement(doc, 'ServerAt', attributes); +} + +/** + * Get all existing AccessPoint names from the current IED. + * @param ied - The IED element to search in. + * @returns Array of AccessPoint names. + */ +export function getExistingAccessPointNames(ied: Element): string[] { + return Array.from(ied.querySelectorAll(':scope > AccessPoint')) + .map(ap => ap.getAttribute('name')) + .filter((name): name is string => name !== null); +} + +/** + * Get AccessPoint names that contain a Server element (can be referenced by ServerAt). + * @param ied - The IED element to search in. + * @returns Array of AccessPoint names that have Server elements. + */ +export function getAccessPointsWithServer(ied: Element): string[] { + return Array.from(ied.querySelectorAll(':scope > AccessPoint')) + .filter(ap => ap.querySelector(':scope > Server')) + .map(ap => ap.getAttribute('name')) + .filter((name): name is string => name !== null); +} + /** * With the passed DO Element retrieve the type attribute and search for the DOType in the DataType Templates section. * @param element - The DO Element. @@ -228,6 +282,26 @@ export function newFullElementPathEvent( }); } +/** + * Get all LDevice inst values from a Server element. + * @param server - The Server element to search in. + * @returns Array of LDevice inst values. + */ +export function getLDeviceInsts(server: Element): string[] { + return Array.from(server.querySelectorAll(':scope > LDevice')).map( + ld => ld.getAttribute('inst') || '' + ); +} + +/** + * Get LNodeType elements from DataTypeTemplates in the document. + * @param doc - The XML document to search in. + * @returns Array of LNodeType elements. + */ +export function getLNodeTypes(doc: XMLDocument): Element[] { + return Array.from(doc.querySelectorAll('DataTypeTemplates > LNodeType')); +} + declare global { interface ElementEventMap { ['full-element-path']: FullElementPathEvent; diff --git a/packages/plugins/src/editors/ied/ied-container.ts b/packages/plugins/src/editors/ied/ied-container.ts index 12134a61e5..a70c3b3fdb 100644 --- a/packages/plugins/src/editors/ied/ied-container.ts +++ b/packages/plugins/src/editors/ied/ied-container.ts @@ -3,24 +3,31 @@ import { customElement, html, property, + query, TemplateResult, } from 'lit-element'; import { nothing } from 'lit-html'; -import { get } from 'lit-translate'; +import { translate } from 'lit-translate'; import '@openscd/open-scd/src/action-pane.js'; import './access-point-container.js'; +import './add-access-point-dialog.js'; import { wizards } from '../../wizards/wizard-library.js'; -import { Container } from './foundation.js'; +import { Container, createAccessPoint, createServerAt } from './foundation.js'; import { getDescriptionAttribute, getNameAttribute, newWizardEvent, } from '@openscd/open-scd/src/foundation.js'; import { newActionEvent } from '@openscd/core/foundation/deprecated/editor.js'; +import { newEditEventV2, InsertV2 } from '@openscd/core'; import { removeIEDWizard } from '../../wizards/ied.js'; import { editServicesWizard } from '../../wizards/services.js'; +import { + AddAccessPointDialog, + AccessPointCreationData, +} from './add-access-point-dialog.js'; /** [[`IED`]] plugin subeditor for editing `IED` element. */ @customElement('ied-container') @@ -28,11 +35,40 @@ export class IedContainer extends Container { @property() selectedLNClasses: string[] = []; + @query('add-access-point-dialog') + addAccessPointDialog!: AddAccessPointDialog; + private openEditWizard(): void { const wizard = wizards['IED'].edit(this.element); if (wizard) this.dispatchEvent(newWizardEvent(wizard)); } + private createAccessPoint(data: AccessPointCreationData): void { + const inserts: InsertV2[] = []; + const accessPoint = createAccessPoint(this.doc, data.name); + + inserts.push({ + parent: this.element, + node: accessPoint, + reference: null, + }); + + if (data.createServerAt && data.serverAtApName) { + const serverAt = createServerAt( + this.doc, + data.serverAtApName, + data.serverAtDesc + ); + inserts.push({ + parent: accessPoint, + node: serverAt, + reference: null, + }); + } + + this.dispatchEvent(newEditEventV2(inserts)); + } + private renderServicesIcon(): TemplateResult { const services: Element | null = this.element.querySelector('Services'); @@ -40,7 +76,7 @@ export class IedContainer extends Container { return html``; } - return html` + return html` this.openSettingsWizard(services)} @@ -77,19 +113,25 @@ export class IedContainer extends Container { render(): TemplateResult { return html` developer_board - + this.removeIED()} > - + this.openEditWizard()} > ${this.renderServicesIcon()} + + this.addAccessPointDialog.show()} + > + ${Array.from(this.element.querySelectorAll(':scope > AccessPoint')).map( ap => html`` )} + + this.createAccessPoint(data)} + > `; } diff --git a/packages/plugins/src/editors/ied/ldevice-container.ts b/packages/plugins/src/editors/ied/ldevice-container.ts index 2c9f97456d..a18ddb7747 100644 --- a/packages/plugins/src/editors/ied/ldevice-container.ts +++ b/packages/plugins/src/editors/ied/ldevice-container.ts @@ -9,10 +9,13 @@ import { TemplateResult, } from 'lit-element'; import { nothing } from 'lit-html'; -import { get } from 'lit-translate'; +import { get, translate } from 'lit-translate'; import { IconButtonToggle } from '@material/mwc-icon-button-toggle'; +import { newEditEventV2 } from '@openscd/core'; +import { createElement } from '@openscd/xml'; +import { logicalDeviceIcon } from '@openscd/open-scd/src/icons/ied-icons.js'; import { getDescriptionAttribute, getInstanceAttribute, @@ -20,13 +23,15 @@ import { getLdNameAttribute, newWizardEvent, } from '@openscd/open-scd/src/foundation.js'; -import { logicalDeviceIcon } from '@openscd/open-scd/src/icons/ied-icons.js'; - -import '@openscd/open-scd/src/action-pane.js'; -import './ln-container.js'; import { wizards } from '../../wizards/wizard-library.js'; import { Container } from './foundation.js'; +import { lnInstGenerator } from '@openenergytools/scl-lib/dist/generator/lnInstGenerator.js'; +import { AddLnDialog, LNData } from './add-ln-dialog.js'; + +import '@openscd/open-scd/src/action-pane.js'; +import './ln-container.js'; +import './add-ln-dialog.js'; /** [[`IED`]] plugin subeditor for editing `LDevice` element. */ @customElement('ldevice-container') @@ -37,6 +42,9 @@ export class LDeviceContainer extends Container { @query('#toggleButton') toggleButton!: IconButtonToggle | undefined; + @query('add-ln-dialog') + addLnDialog!: AddLnDialog; + private openEditWizard(): void { const wizard = wizards['LDevice'].edit(this.element); if (wizard) this.dispatchEvent(newWizardEvent(wizard)); @@ -76,6 +84,26 @@ export class LDeviceContainer extends Container { ); } + private handleAddLN(data: LNData) { + const getInst = lnInstGenerator(this.element, 'LN'); + const inserts = []; + + for (let i = 0; i < data.amount; i++) { + const inst = getInst(data.lnClass); + if (!inst) break; + const lnAttrs = { + lnClass: data.lnClass, + lnType: data.lnType, + inst: inst, + ...(data.prefix ? { prefix: data.prefix } : {}), + }; + const ln = createElement(this.doc, 'LN', lnAttrs); + inserts.push({ parent: this.element, node: ln, reference: null }); + } + + this.dispatchEvent(newEditEventV2(inserts)); + } + render(): TemplateResult { const lnElements = this.lnElements; @@ -87,6 +115,12 @@ export class LDeviceContainer extends Container { @click=${() => this.openEditWizard()} > + + this.addLnDialog.show()} + > + ${lnElements.length > 0 ? html` + this.handleAddLN(data)} + > `; } diff --git a/packages/plugins/src/editors/ied/server-container.ts b/packages/plugins/src/editors/ied/server-container.ts index 7568ddc545..99825a0fbb 100644 --- a/packages/plugins/src/editors/ied/server-container.ts +++ b/packages/plugins/src/editors/ied/server-container.ts @@ -3,16 +3,28 @@ import { html, property, PropertyValues, + query, state, TemplateResult, } from 'lit-element'; import { nothing } from 'lit-html'; +import { translate } from 'lit-translate'; -import '@openscd/open-scd/src/action-pane.js'; -import './ldevice-container.js'; import { serverIcon } from '@openscd/open-scd/src/icons/ied-icons.js'; import { getDescriptionAttribute } from '@openscd/open-scd/src/foundation.js'; -import { Container } from './foundation.js'; +import { createElement } from '@openscd/xml'; +import { newEditEventV2 } from '@openscd/core'; + +import { + Container, + findLLN0LNodeType, + createLLN0LNodeType, +} from './foundation.js'; +import { AddLDeviceDialog, LDeviceData } from './add-ldevice-dialog.js'; + +import '@openscd/open-scd/src/action-pane.js'; +import './ldevice-container.js'; +import './add-ldevice-dialog.js'; /** [[`IED`]] plugin subeditor for editing `Server` element. */ @customElement('server-container') @@ -20,6 +32,9 @@ export class ServerContainer extends Container { @property() selectedLNClasses: string[] = []; + @query('add-ldevice-dialog') + addAccessPointDialog!: AddLDeviceDialog; + private header(): TemplateResult { const desc = getDescriptionAttribute(this.element); @@ -51,9 +66,41 @@ export class ServerContainer extends Container { ); } + private handleAddLDevice(data: LDeviceData) { + const inserts: any[] = []; + const lln0Type = findLLN0LNodeType(this.doc); + const lnTypeId = lln0Type?.getAttribute('id') || 'PlaceholderLLN0'; + + if (!lln0Type) { + const lnodeTypeInserts = createLLN0LNodeType(this.doc, lnTypeId); + inserts.push(...lnodeTypeInserts); + } + const lDevice = createElement(this.doc, 'LDevice', { + inst: data.inst, + }); + + const ln0 = createElement(this.doc, 'LN0', { + lnClass: 'LLN0', + lnType: lnTypeId, + }); + + lDevice.appendChild(ln0); + inserts.push({ parent: this.element, node: lDevice, reference: null }); + this.dispatchEvent(newEditEventV2(inserts)); + } + render(): TemplateResult { return html` ${serverIcon} + + this.addAccessPointDialog.show()} + > + ${this.lDeviceElements.map( server => html`` )} + this.handleAddLDevice(data)} + > `; } } diff --git a/packages/plugins/test/testfiles/editors/minimalVirtualIED.scd b/packages/plugins/test/testfiles/editors/minimalVirtualIED.scd new file mode 100644 index 0000000000..821c21a4bb --- /dev/null +++ b/packages/plugins/test/testfiles/editors/minimalVirtualIED.scd @@ -0,0 +1,34 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + on + blocked + test + test/blocked + off + + + diff --git a/packages/plugins/test/unit/components/tooltip.test.ts b/packages/plugins/test/unit/components/tooltip.test.ts new file mode 100644 index 0000000000..1cd3a098c2 --- /dev/null +++ b/packages/plugins/test/unit/components/tooltip.test.ts @@ -0,0 +1,126 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { OscdTooltip } from '../../../src/components/tooltip'; +import '../../../src/components/tooltip.js'; + +describe('oscd-tooltip', () => { + let element: OscdTooltip; + + beforeEach(async () => { + element = await fixture( + html`` + ); + }); + + it('should have default properties', () => { + expect(element.text).to.equal(''); + expect(element.visible).to.be.false; + expect(element.x).to.equal(0); + expect(element.y).to.equal(0); + expect(element.offset).to.equal(12); + }); + + it('should render with slotted content', async () => { + element.text = 'Test tooltip'; + await element.updateComplete; + expect(element.shadowRoot?.textContent).to.include('Test tooltip'); + }); + + describe('show()', () => { + it('should display tooltip with text and position', async () => { + element.show('Show this text', 100, 200); + await element.updateComplete; + + expect(element.text).to.equal('Show this text'); + expect(element.visible).to.be.true; + expect(element.x).to.equal(112); + expect(element.y).to.equal(212); + }); + }); + + describe('hide()', () => { + it('should hide tooltip and clear text', async () => { + element.show('Test text', 100, 100); + await element.updateComplete; + expect(element.visible).to.be.true; + expect(element.text).to.equal('Test text'); + + element.hide(); + await element.updateComplete; + expect(element.visible).to.be.false; + expect(element.text).to.equal(''); + }); + }); + + describe('updatePosition()', () => { + beforeEach(async () => { + element.show('Test', 100, 100); + await element.updateComplete; + }); + + it('should update x and y coordinates', () => { + element.updatePosition(200, 300); + expect(element.x).to.equal(212); + expect(element.y).to.equal(312); + }); + + it('should schedule position update via requestAnimationFrame', done => { + element.updatePosition(150, 250); + + setTimeout(() => { + expect(element.style.transform).to.include('translate3d'); + done(); + }, 20); + }); + + it('should not schedule multiple frames if already pending', () => { + element.updatePosition(100, 100); + element.updatePosition(200, 200); + expect(element['pendingFrame']).to.be.greaterThan(0); + }); + }); + + describe('viewport boundary handling', () => { + it('should adjust position if tooltip would overflow right edge', done => { + const nearRightEdge = window.innerWidth - 10; + element.show('Long tooltip text that needs space', nearRightEdge, 100); + + setTimeout(() => { + expect(element.style.transform).to.include('translate3d'); + done(); + }, 20); + }); + + it('should adjust position if tooltip would overflow bottom edge', done => { + const nearBottom = window.innerHeight - 10; + element.show('Tooltip near bottom', 100, nearBottom); + + setTimeout(() => { + expect(element.style.transform).to.include('translate3d'); + done(); + }, 20); + }); + }); + + describe('custom offset', () => { + it('should respect custom offset value', async () => { + element.offset = 20; + element.show('Test', 100, 100); + await element.updateComplete; + + expect(element.x).to.equal(120); + expect(element.y).to.equal(120); + }); + }); + + describe('visibility states', () => { + it('should reflect visible attribute', async () => { + element.visible = true; + await element.updateComplete; + expect(element.hasAttribute('visible')).to.be.true; + + element.visible = false; + await element.updateComplete; + expect(element.hasAttribute('visible')).to.be.false; + }); + }); +}); diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/add-access-point-dialog.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/add-access-point-dialog.test.snap.js new file mode 100644 index 0000000000..b71c136591 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/add-access-point-dialog.test.snap.js @@ -0,0 +1,46 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["add-access-point-dialog looks like the latest snapshot"] = +` +
+ + + + + + +
+ + [close] + + + [add] + +
+`; +/* end snapshot add-access-point-dialog looks like the latest snapshot */ + diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/add-ldevice-dialog.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/add-ldevice-dialog.test.snap.js new file mode 100644 index 0000000000..6eea5e4074 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/add-ldevice-dialog.test.snap.js @@ -0,0 +1,40 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["add-ldevice-dialog looks like the latest snapshot"] = +` +
+ + +
+ + [close] + + + [add] + +
+`; +/* end snapshot add-ldevice-dialog looks like the latest snapshot */ + diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/add-ln-dialog.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/add-ln-dialog.test.snap.js new file mode 100644 index 0000000000..01b14bc9a0 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/add-ln-dialog.test.snap.js @@ -0,0 +1,76 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["add-ln-dialog looks like the latest snapshot"] = +` +
+
+ + +
+ + + + PlaceholderLLN0 + + + + + +
+
+ + + + +
+ + [close] + + + [add] + +
+`; +/* end snapshot add-ln-dialog looks like the latest snapshot */ + diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js index c3afe52b78..c2b4f7b5a0 100644 --- a/packages/plugins/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/ied-container.test.snap.js @@ -27,8 +27,17 @@ snapshots["ied-container looks like the latest snapshot"] = + + + + + + `; /* end snapshot ied-container looks like the latest snapshot */ diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js index e320ea49d7..9c399766b4 100644 --- a/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/ldevice-container.test.snap.js @@ -12,6 +12,13 @@ snapshots["ldevice-container LDevice Element with LN Elements and all LN Element + + + + + + `; /* end snapshot ldevice-container LDevice Element with LN Elements and all LN Elements displayed looks like the latest snapshot */ @@ -59,6 +68,13 @@ snapshots["ldevice-container LDevice Element with LN Elements and some LN Elemen + + + + + + `; /* end snapshot ldevice-container LDevice Element with LN Elements and some LN Elements displayed looks like the latest snapshot */ @@ -96,8 +114,17 @@ snapshots["ldevice-container LDevice Element with LN Elements and no LN Elements + + + +
+ + `; /* end snapshot ldevice-container LDevice Element with LN Elements and no LN Elements displayed looks like the latest snapshot */ @@ -113,8 +140,17 @@ snapshots["ldevice-container LDevice Element without LN Element looks like the l + + + +
+ + `; /* end snapshot ldevice-container LDevice Element without LN Element looks like the latest snapshot */ diff --git a/packages/plugins/test/unit/editors/ied/__snapshots__/server-container.test.snap.js b/packages/plugins/test/unit/editors/ied/__snapshots__/server-container.test.snap.js index b6f51f9e89..68770ecd92 100644 --- a/packages/plugins/test/unit/editors/ied/__snapshots__/server-container.test.snap.js +++ b/packages/plugins/test/unit/editors/ied/__snapshots__/server-container.test.snap.js @@ -5,10 +5,19 @@ snapshots["server-container Server Element with LDevice Elements and all LN Elem ` + + + + + + `; /* end snapshot server-container Server Element with LDevice Elements and all LN Elements of the LDevice Element displayed looks like the latest snapshot */ @@ -17,8 +26,17 @@ snapshots["server-container Server Element with LDevice Elements and some LN Ele ` + + + + + + `; /* end snapshot server-container Server Element with LDevice Elements and some LN Elements displayed looks like the latest snapshot */ @@ -27,6 +45,15 @@ snapshots["server-container Server Element with LDevice Elements and no LN Eleme ` + + + + + + `; /* end snapshot server-container Server Element with LDevice Elements and no LN Elements displayed looks like the latest snapshot */ @@ -35,6 +62,15 @@ snapshots["server-container Server Element without LDevice Element looks like th ` + + + + + + `; /* end snapshot server-container Server Element without LDevice Element looks like the latest snapshot */ diff --git a/packages/plugins/test/unit/editors/ied/add-access-point-dialog.test.ts b/packages/plugins/test/unit/editors/ied/add-access-point-dialog.test.ts new file mode 100644 index 0000000000..36431afa03 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/add-access-point-dialog.test.ts @@ -0,0 +1,115 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { SinonSpy, spy } from 'sinon'; +import { AddAccessPointDialog } from '../../../../src/editors/ied/add-access-point-dialog'; +import '../../../../src/editors/ied/add-access-point-dialog.js'; + +describe('add-access-point-dialog', () => { + let element: AddAccessPointDialog; + let doc: XMLDocument; + let ied: Element; + let onConfirmSpy: SinonSpy; + + beforeEach(async () => { + doc = new DOMParser().parseFromString( + ` + + `, + 'application/xml' + ); + ied = doc.querySelector('IED')!; + onConfirmSpy = spy(); + + element = await fixture( + html`` + ); + }); + + it('looks like the latest snapshot', async () => { + element.show(); + await element.updateComplete; + expect(element).shadowDom.to.equalSnapshot(); + }); + + it('should show and hide dialog', async () => { + element.show(); + await element.updateComplete; + expect(element.dialog.open).to.be.true; + + element['close'](); + await element.updateComplete; + expect(element.dialog.open).to.be.false; + }); + + describe('access point name validation', () => { + it('should reject names with invalid format', () => { + expect(element['getApNameError']('1 invalid')).to.equal( + '[iededitor.addAccessPointDialog.nameFormatError]' + ); + expect(element['getApNameError']('!bad')).to.equal( + '[iededitor.addAccessPointDialog.nameFormatError]' + ); + }); + + it('should reject names that are too long', () => { + const longName = 'a'.repeat(33); + expect(element['getApNameError'](longName)).to.equal( + '[iededitor.addAccessPointDialog.nameTooLongError]' + ); + }); + + it('should accept valid names', () => { + expect(element['getApNameError']('ValidName1')).to.equal(''); + }); + }); + + describe('creating access point', () => { + it('should call onConfirm with correct name', async () => { + element.show(); + await element.updateComplete; + + element.apNameField.value = 'ValidName1'; + element.apNameField.dispatchEvent(new Event('input')); + + const addButton = element.shadowRoot?.querySelector( + '[data-testid="add-access-point-button"]' + ); + (addButton as HTMLElement)?.click(); + await element.updateComplete; + + expect(onConfirmSpy.calledOnce).to.be.true; + expect(onConfirmSpy.firstCall.args[0]).to.deep.include({ + name: 'ValidName1', + createServerAt: false, + }); + }); + + it('should call onConfirm with serverAt data', async () => { + element.show(); + await element.updateComplete; + element.apNameField.value = 'ValidName2'; + element.apNameField.dispatchEvent(new Event('input')); + element['createServerAt'] = true; + element['serverAtApName'] = 'AP1'; + element['serverAtDesc'] = 'Description for AP1'; + await element.updateComplete; + + const addButton = element.shadowRoot?.querySelector( + '[data-testid="add-access-point-button"]' + ); + (addButton as HTMLElement)?.click(); + await element.updateComplete; + + expect(onConfirmSpy.calledOnce).to.be.true; + expect(onConfirmSpy.firstCall.args[0]).to.deep.include({ + name: 'ValidName2', + createServerAt: true, + serverAtApName: 'AP1', + serverAtDesc: 'Description for AP1', + }); + }); + }); +}); diff --git a/packages/plugins/test/unit/editors/ied/add-ldevice-dialog.test.ts b/packages/plugins/test/unit/editors/ied/add-ldevice-dialog.test.ts new file mode 100644 index 0000000000..ce05f8b439 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/add-ldevice-dialog.test.ts @@ -0,0 +1,64 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { SinonSpy, spy } from 'sinon'; +import { AddLDeviceDialog } from '../../../../src/editors/ied/add-ldevice-dialog'; +import '../../../../src/editors/ied/add-ldevice-dialog.js'; + +describe('add-ldevice-dialog', () => { + let element: AddLDeviceDialog; + let doc: XMLDocument; + let server: Element; + let onConfirmSpy: SinonSpy; + + beforeEach(async () => { + doc = await fetch('/test/testfiles/editors/minimalVirtualIED.scd') + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); + server = doc.querySelector('IED > AccessPoint > Server')!; + onConfirmSpy = spy(); + element = await fixture( + html`` + ); + }); + + it('looks like the latest snapshot', async () => { + element.show(); + await element.updateComplete; + expect(element).shadowDom.to.equalSnapshot(); + }); + + it('should show and hide dialog', async () => { + element.show(); + await element.updateComplete; + expect(element.dialog.open).to.be.true; + + element['close'](); + await element.updateComplete; + expect(element.dialog.open).to.be.false; + }); + + describe('LDevice inst validation', () => { + it('should reject empty names', () => { + expect(element['getInstError']('')).to.equal( + '[iededitor.addLDeviceDialog.instRequiredError]' + ); + expect(element['getInstError'](' ')).to.equal( + '[iededitor.addLDeviceDialog.instRequiredError]' + ); + }); + + it('should reject names with wrong pattern', () => { + expect(element['getInstError']('invalid name')).to.equal( + '[iededitor.addLDeviceDialog.instFormatError]' + ); + }); + + it('should reject existing names', () => { + expect(element['getInstError']('LD1')).to.equal( + '[iededitor.addLDeviceDialog.instUniqueError]' + ); + }); + }); +}); diff --git a/packages/plugins/test/unit/editors/ied/add-ln-dialog.test.ts b/packages/plugins/test/unit/editors/ied/add-ln-dialog.test.ts new file mode 100644 index 0000000000..a40ea978f8 --- /dev/null +++ b/packages/plugins/test/unit/editors/ied/add-ln-dialog.test.ts @@ -0,0 +1,94 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { SinonSpy, spy } from 'sinon'; +import { AddLnDialog } from '../../../../src/editors/ied/add-ln-dialog'; +import '../../../../src/editors/ied/add-ln-dialog.js'; + +describe('add-ln-dialog', () => { + let element: AddLnDialog; + let doc: XMLDocument; + let onConfirmSpy: SinonSpy; + + beforeEach(async () => { + doc = await fetch('/test/testfiles/editors/minimalVirtualIED.scd') + .then(response => response.text()) + .then(str => new DOMParser().parseFromString(str, 'application/xml')); + onConfirmSpy = spy(); + element = await fixture( + html`` + ); + }); + + it('looks like the latest snapshot', async () => { + element.show(); + await element.updateComplete; + expect(element).shadowDom.to.equalSnapshot(); + }); + + it('should show and hide dialog', async () => { + element.show(); + await element.updateComplete; + expect(element.dialog.open).to.be.true; + + element['close'](); + await element.updateComplete; + expect(element.dialog.open).to.be.false; + }); + + it('displays filtered LN types', async () => { + element.show(); + await element.updateComplete; + expect(element.filterText).to.equal(''); + expect(element['filteredLNodeTypes'].length).to.equal(1); + + element.filterText = 'NonExistingFilter'; + await element.updateComplete; + expect(element['filteredLNodeTypes'].length).to.equal(0); + }); + + it('should create LN data on confirm', async () => { + element.show(); + await element.updateComplete; + const listItems = element.shadowRoot?.querySelectorAll('mwc-list-item'); + const targetItem = listItems + ? Array.from(listItems).find(item => item.value === 'PlaceholderLLN0') + : undefined; + if (targetItem) { + targetItem.click(); + await element.updateComplete; + } + + const prefixInput = element.shadowRoot?.querySelector( + '[data-testid="prefix"]' + ); + (prefixInput as HTMLInputElement).value = 'MyPrefix'; + (prefixInput as HTMLInputElement).dispatchEvent( + new Event('input', { bubbles: true, composed: true }) + ); + + const amountInput = element.shadowRoot?.querySelector( + '[data-testid="amount"]' + ); + (amountInput as HTMLInputElement).value = '3'; + (amountInput as HTMLInputElement).dispatchEvent( + new Event('input', { bubbles: true, composed: true }) + ); + + await element.updateComplete; + const addButton = element.shadowRoot?.querySelector( + '[data-testid="add-ln-button"]' + ); + (addButton as HTMLElement)?.click(); + await element.updateComplete; + + expect(onConfirmSpy.calledOnce).to.be.true; + expect(onConfirmSpy.firstCall.args[0]).to.deep.include({ + lnType: 'PlaceholderLLN0', + lnClass: 'LLN0', + prefix: 'MyPrefix', + amount: 3, + }); + }); +});