@api-client/ui 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/elements/highlight/MarkedHighlight.d.ts.map +1 -1
- package/build/src/elements/highlight/MarkedHighlight.js +2 -1
- package/build/src/elements/highlight/MarkedHighlight.js.map +1 -1
- package/build/src/md/button/internals/base.js +1 -1
- package/build/src/md/button/internals/base.js.map +1 -1
- package/build/src/md/dialog/internals/Dialog.d.ts +18 -0
- package/build/src/md/dialog/internals/Dialog.d.ts.map +1 -1
- package/build/src/md/dialog/internals/Dialog.js +60 -2
- package/build/src/md/dialog/internals/Dialog.js.map +1 -1
- package/build/src/md/input/Input.d.ts +4 -4
- package/build/src/md/input/Input.d.ts.map +1 -1
- package/build/src/md/input/Input.js +3 -11
- package/build/src/md/input/Input.js.map +1 -1
- package/build/src/md/text-area/internals/TextAreaElement.d.ts.map +1 -1
- package/build/src/md/text-area/internals/TextAreaElement.js +1 -2
- package/build/src/md/text-area/internals/TextAreaElement.js.map +1 -1
- package/build/src/md/text-field/internals/TextField.d.ts.map +1 -1
- package/build/src/md/text-field/internals/TextField.js +1 -2
- package/build/src/md/text-field/internals/TextField.js.map +1 -1
- package/demo/md/dialog/dialog.ts +135 -1
- package/package.json +2 -2
- package/src/elements/highlight/MarkedHighlight.ts +2 -1
- package/src/md/button/internals/base.ts +1 -1
- package/src/md/dialog/internals/Dialog.ts +54 -1
- package/src/md/input/Input.ts +4 -4
- package/src/md/text-area/internals/TextAreaElement.ts +1 -2
- package/src/md/text-field/internals/TextField.ts +4 -5
- package/test/README.md +372 -0
- package/test/dom-assertions.test.ts +182 -0
- package/test/helpers/TestUtils.ts +243 -0
- package/test/helpers/UiMock.ts +83 -13
- package/test/md/dialog/UiDialog.test.ts +169 -0
- package/test/setup.test.ts +217 -0
- package/test/setup.ts +117 -0
- package/.github/workflows/test.yml +0 -42
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended test utilities for the UI component library.
|
|
3
|
+
* Provides Jest-like matchers and additional testing helpers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { expect } from '@open-wc/testing'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Custom matchers for better test assertions
|
|
10
|
+
*/
|
|
11
|
+
export const customMatchers = {
|
|
12
|
+
/**
|
|
13
|
+
* Check if an element has specific CSS classes
|
|
14
|
+
*/
|
|
15
|
+
toHaveClasses(received: Element, ...classes: string[]) {
|
|
16
|
+
const classList = Array.from(received.classList)
|
|
17
|
+
const missing = classes.filter((cls) => !classList.includes(cls))
|
|
18
|
+
|
|
19
|
+
if (missing.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
message: () => `Expected element not to have classes: ${classes.join(', ')}`,
|
|
22
|
+
pass: true,
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
return {
|
|
26
|
+
message: () => `Expected element to have classes: ${missing.join(', ')}`,
|
|
27
|
+
pass: false,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if an element is visible (not hidden by CSS)
|
|
34
|
+
*/
|
|
35
|
+
toBeVisible(received: Element) {
|
|
36
|
+
const style = window.getComputedStyle(received)
|
|
37
|
+
const isVisible = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
|
|
38
|
+
|
|
39
|
+
if (isVisible) {
|
|
40
|
+
return {
|
|
41
|
+
message: () => 'Expected element to be hidden',
|
|
42
|
+
pass: true,
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
return {
|
|
46
|
+
message: () => 'Expected element to be visible',
|
|
47
|
+
pass: false,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if an element has a specific attribute value
|
|
54
|
+
*/
|
|
55
|
+
toHaveAttribute(received: Element, attribute: string, value?: string) {
|
|
56
|
+
const hasAttribute = received.hasAttribute(attribute)
|
|
57
|
+
|
|
58
|
+
if (!hasAttribute) {
|
|
59
|
+
return {
|
|
60
|
+
message: () => `Expected element to have attribute "${attribute}"`,
|
|
61
|
+
pass: false,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (value !== undefined) {
|
|
66
|
+
const actualValue = received.getAttribute(attribute)
|
|
67
|
+
if (actualValue !== value) {
|
|
68
|
+
return {
|
|
69
|
+
message: () => `Expected attribute "${attribute}" to be "${value}", but got "${actualValue}"`,
|
|
70
|
+
pass: false,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
message: () => `Expected element not to have attribute "${attribute}"${value ? ` with value "${value}"` : ''}`,
|
|
77
|
+
pass: true,
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Helper to demonstrate proper DOM element comparison
|
|
83
|
+
* Note: Always use assert.dom.equal() for DOM element comparison, not assert.equal()
|
|
84
|
+
*/
|
|
85
|
+
domElementsEqual(actual: Element, expected: Element): boolean {
|
|
86
|
+
// This is just a helper - in actual tests, use assert.dom.equal(actual, expected)
|
|
87
|
+
return actual === expected && actual.isEqualNode(expected)
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Test factory functions for common test scenarios
|
|
93
|
+
*/
|
|
94
|
+
export const testFactory = {
|
|
95
|
+
/**
|
|
96
|
+
* Create a test suite for a custom element
|
|
97
|
+
*/
|
|
98
|
+
createElementTestSuite(elementName: string, importPath: string) {
|
|
99
|
+
return {
|
|
100
|
+
async setup() {
|
|
101
|
+
await import(importPath)
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async createElement(attributes: Record<string, string> = {}, content = '') {
|
|
105
|
+
const attrs = Object.entries(attributes)
|
|
106
|
+
.map(([key, value]) => `${key}="${value}"`)
|
|
107
|
+
.join(' ')
|
|
108
|
+
|
|
109
|
+
const element = document.createElement('div')
|
|
110
|
+
element.innerHTML = `<${elementName} ${attrs}>${content}</${elementName}>`
|
|
111
|
+
document.body.appendChild(element)
|
|
112
|
+
|
|
113
|
+
return element.firstElementChild as HTMLElement
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
cleanup() {
|
|
117
|
+
document.body.innerHTML = ''
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a test data factory
|
|
124
|
+
*/
|
|
125
|
+
createDataFactory<T>(defaults: T) {
|
|
126
|
+
return (overrides: Partial<T> = {}): T => ({
|
|
127
|
+
...defaults,
|
|
128
|
+
...overrides,
|
|
129
|
+
})
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Async test helpers
|
|
135
|
+
*/
|
|
136
|
+
export const asyncHelpers = {
|
|
137
|
+
/**
|
|
138
|
+
* Wait for multiple conditions to be true
|
|
139
|
+
*/
|
|
140
|
+
async waitForAll(conditions: (() => boolean)[], timeout = 5000): Promise<void> {
|
|
141
|
+
const promises = conditions.map((condition) => window.testUtils.waitForCondition(condition, timeout))
|
|
142
|
+
await Promise.all(promises)
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Wait for any of multiple conditions to be true
|
|
147
|
+
*/
|
|
148
|
+
async waitForAny(conditions: (() => boolean)[], timeout = 5000): Promise<number> {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const startTime = Date.now()
|
|
151
|
+
let resolved = false
|
|
152
|
+
|
|
153
|
+
function checkConditions() {
|
|
154
|
+
if (resolved) return
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
157
|
+
try {
|
|
158
|
+
if (conditions[i]()) {
|
|
159
|
+
resolved = true
|
|
160
|
+
resolve(i)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Continue checking other conditions
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (Date.now() - startTime > timeout) {
|
|
169
|
+
resolved = true
|
|
170
|
+
reject(new Error(`None of the conditions met within ${timeout}ms`))
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setTimeout(checkConditions, 50)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
checkConditions()
|
|
178
|
+
})
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Retry an async operation with exponential backoff
|
|
183
|
+
*/
|
|
184
|
+
async retry<T>(operation: () => Promise<T>, maxAttempts = 3, baseDelay = 100): Promise<T> {
|
|
185
|
+
let lastError: Error
|
|
186
|
+
|
|
187
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
188
|
+
try {
|
|
189
|
+
return await operation()
|
|
190
|
+
} catch (error) {
|
|
191
|
+
lastError = error as Error
|
|
192
|
+
|
|
193
|
+
if (attempt === maxAttempts) {
|
|
194
|
+
throw lastError
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const delay = baseDelay * Math.pow(2, attempt - 1)
|
|
198
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
throw lastError!
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Performance testing utilities
|
|
208
|
+
*/
|
|
209
|
+
export const performanceHelpers = {
|
|
210
|
+
/**
|
|
211
|
+
* Measure the execution time of a function
|
|
212
|
+
*/
|
|
213
|
+
async measureTime<T>(fn: () => Promise<T> | T): Promise<{ result: T; duration: number }> {
|
|
214
|
+
const start = performance.now()
|
|
215
|
+
const result = await fn()
|
|
216
|
+
const duration = performance.now() - start
|
|
217
|
+
|
|
218
|
+
return { result, duration }
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Assert that an operation completes within a time limit
|
|
223
|
+
*/
|
|
224
|
+
async expectWithinTime<T>(operation: () => Promise<T> | T, maxDuration: number, message?: string): Promise<T> {
|
|
225
|
+
const { result, duration } = await this.measureTime(operation)
|
|
226
|
+
|
|
227
|
+
if (duration > maxDuration) {
|
|
228
|
+
const errorMessage = message || `Operation took ${duration.toFixed(2)}ms, expected < ${maxDuration}ms`
|
|
229
|
+
throw new Error(errorMessage)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return result
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Export all utilities
|
|
237
|
+
export { expect }
|
|
238
|
+
export default {
|
|
239
|
+
customMatchers,
|
|
240
|
+
testFactory,
|
|
241
|
+
asyncHelpers,
|
|
242
|
+
performanceHelpers,
|
|
243
|
+
}
|
package/test/helpers/UiMock.ts
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { nextFrame } from '@open-wc/testing'
|
|
2
2
|
import { sendMouse } from '@web/test-runner-commands'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Represents a 2D point with x and y coordinates.
|
|
6
|
+
*/
|
|
4
7
|
export interface Point {
|
|
8
|
+
/** The x-coordinate of the point. */
|
|
5
9
|
x: number
|
|
10
|
+
/** The y-coordinate of the point. */
|
|
6
11
|
y: number
|
|
7
12
|
}
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A utility class for mocking user interface interactions in tests.
|
|
16
|
+
* It provides static methods to simulate keyboard and mouse events.
|
|
17
|
+
* This class is designed as a collection of static helpers, hence it has no constructor or instances.
|
|
18
|
+
*/
|
|
9
19
|
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
|
10
20
|
export class UiMock {
|
|
21
|
+
/**
|
|
22
|
+
* Dispatches a 'keydown' event on a given element.
|
|
23
|
+
*
|
|
24
|
+
* @param element The target element for the event.
|
|
25
|
+
* @param code The `code` value for the KeyboardEvent (e.g., 'Enter', 'KeyA').
|
|
26
|
+
* @param opts Additional options to pass to the KeyboardEvent constructor.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* UiMock.keyDown(document.body, 'Enter');
|
|
30
|
+
* UiMock.keyDown(myInputElement, 'KeyA', { ctrlKey: true });
|
|
31
|
+
*/
|
|
11
32
|
static keyDown(element: EventTarget, code: string, opts: KeyboardEventInit = {}): void {
|
|
12
33
|
const defaults: KeyboardEventInit = {
|
|
13
34
|
bubbles: true,
|
|
@@ -19,6 +40,16 @@ export class UiMock {
|
|
|
19
40
|
element.dispatchEvent(down)
|
|
20
41
|
}
|
|
21
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Dispatches a 'keyup' event on a given element.
|
|
45
|
+
*
|
|
46
|
+
* @param element The target element for the event.
|
|
47
|
+
* @param code The `code` value for the KeyboardEvent (e.g., 'Escape', 'KeyB').
|
|
48
|
+
* @param opts Additional options to pass to the KeyboardEvent constructor.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* UiMock.keyUp(document.body, 'Escape');
|
|
52
|
+
*/
|
|
22
53
|
static keyUp(element: EventTarget, code: string, opts: KeyboardEventInit = {}): void {
|
|
23
54
|
const defaults: KeyboardEventInit = {
|
|
24
55
|
bubbles: true,
|
|
@@ -30,12 +61,35 @@ export class UiMock {
|
|
|
30
61
|
element.dispatchEvent(down)
|
|
31
62
|
}
|
|
32
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Simulates a full key press by dispatching 'keydown' and 'keyup' events sequentially.
|
|
66
|
+
* It waits for the next animation frame between the two events.
|
|
67
|
+
*
|
|
68
|
+
* @param element The target element for the event.
|
|
69
|
+
* @param code The `code` value for the KeyboardEvent.
|
|
70
|
+
* @param opts Additional options to pass to the KeyboardEvent constructor.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* await UiMock.keyPress(myButton, 'Space');
|
|
74
|
+
*/
|
|
33
75
|
static async keyPress(element: EventTarget, code: string, opts?: KeyboardEventInit): Promise<void> {
|
|
34
76
|
this.keyDown(element, code, opts)
|
|
35
77
|
await nextFrame()
|
|
36
78
|
this.keyUp(element, code, opts)
|
|
37
79
|
}
|
|
38
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Calculates the coordinates of the center of a given element.
|
|
83
|
+
*
|
|
84
|
+
* Note: This method mixes coordinate systems. `getBoundingClientRect` is viewport-relative,
|
|
85
|
+
* while `window.scrollY` is for document scrolling and `window.screenX` is for the
|
|
86
|
+
* screen position of the browser window. For use with `@web/test-runner-commands`'s `sendMouse`,
|
|
87
|
+
* which expects viewport-relative coordinates, the calculation should likely be:
|
|
88
|
+
* `x: Math.floor(x + width / 2)` and `y: Math.floor(y + height / 2)`.
|
|
89
|
+
*
|
|
90
|
+
* @param element The element to find the center of.
|
|
91
|
+
* @returns A `Point` object with the x and y coordinates of the element's center.
|
|
92
|
+
*/
|
|
39
93
|
static getMiddleOfElement(element: Element): Point {
|
|
40
94
|
const { x, y, width, height } = element.getBoundingClientRect()
|
|
41
95
|
|
|
@@ -46,20 +100,21 @@ export class UiMock {
|
|
|
46
100
|
}
|
|
47
101
|
|
|
48
102
|
/**
|
|
49
|
-
* Moves the mouse from
|
|
50
|
-
* This
|
|
103
|
+
* Moves the mouse from a starting point to an ending point in a series of steps.
|
|
104
|
+
* This uses the `@web/test-runner-commands` `sendMouse` API, which means
|
|
105
|
+
* the mouse will actually move in the browser running the tests.
|
|
51
106
|
*
|
|
52
|
-
* @param from The {x,y}
|
|
53
|
-
* @param to The {x,y}
|
|
54
|
-
* @param steps The number of steps to take
|
|
107
|
+
* @param from The starting {x, y} coordinates.
|
|
108
|
+
* @param to The ending {x, y} coordinates.
|
|
109
|
+
* @param steps The number of intermediate steps to take during the move. Defaults to 10.
|
|
55
110
|
*/
|
|
56
111
|
static async moveMouse(from: Point, to: Point, steps = 10): Promise<void> {
|
|
57
|
-
const dx = to.x -
|
|
58
|
-
const dy = to.y -
|
|
112
|
+
const dx = to.x - from.x
|
|
113
|
+
const dy = to.y - from.y
|
|
59
114
|
const ix = Math.round(dx / steps)
|
|
60
115
|
const iy = Math.round(dy / steps)
|
|
61
|
-
let currentX =
|
|
62
|
-
let currentY =
|
|
116
|
+
let currentX = from.x
|
|
117
|
+
let currentY = from.y
|
|
63
118
|
|
|
64
119
|
await sendMouse({ type: 'move', position: [from.x, from.y] })
|
|
65
120
|
// await executeServerCommand('take-screenshot', { name: 'move-start' });
|
|
@@ -77,11 +132,13 @@ export class UiMock {
|
|
|
77
132
|
}
|
|
78
133
|
|
|
79
134
|
/**
|
|
80
|
-
* Performs a drag
|
|
135
|
+
* Performs a drag-and-drop operation from a source element to a target element.
|
|
136
|
+
* It simulates moving to the source, pressing the mouse down, moving to the target,
|
|
137
|
+
* and releasing the mouse.
|
|
81
138
|
*
|
|
82
|
-
* @param source The
|
|
83
|
-
* @param target The
|
|
84
|
-
* @param steps The number of mouse moves
|
|
139
|
+
* @param source The HTMLElement to drag.
|
|
140
|
+
* @param target The HTMLElement to drop onto.
|
|
141
|
+
* @param steps The number of mouse moves to simulate during the drag. Defaults to 10.
|
|
85
142
|
*/
|
|
86
143
|
static async dragAndDrop(source: HTMLElement, target: HTMLElement, steps = 10): Promise<void> {
|
|
87
144
|
const from = UiMock.getMiddleOfElement(source)
|
|
@@ -96,6 +153,19 @@ export class UiMock {
|
|
|
96
153
|
// await executeServerCommand('take-screenshot', { name: `after-up` });
|
|
97
154
|
}
|
|
98
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Creates a `DragEvent` for simulating file drags (e.g., for a file drop zone).
|
|
158
|
+
*
|
|
159
|
+
* @param type The type of the drag event (e.g., 'dragenter', 'dragover', 'drop').
|
|
160
|
+
* @param opts Options, including an optional `File` object to include in the event.
|
|
161
|
+
* If no file is provided, a default 'test.txt' file is created.
|
|
162
|
+
* @returns A new `DragEvent` instance.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* const myFile = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
|
|
166
|
+
* const dropEvent = UiMock.getFileDragEvent('drop', { file: myFile });
|
|
167
|
+
* dropZone.dispatchEvent(dropEvent);
|
|
168
|
+
*/
|
|
99
169
|
static getFileDragEvent(type: string, opts: { file?: File } = {}): DragEvent {
|
|
100
170
|
let file: File
|
|
101
171
|
if (opts.file) {
|
|
@@ -230,5 +230,174 @@ describe('md', () => {
|
|
|
230
230
|
assert.isTrue(container.classList.contains('with-buttons'), 'has the with-buttons class')
|
|
231
231
|
})
|
|
232
232
|
})
|
|
233
|
+
|
|
234
|
+
describe('form handling', () => {
|
|
235
|
+
async function formWrappedDialogFixture(): Promise<{ form: HTMLFormElement; dialog: UiDialog }> {
|
|
236
|
+
const container = await fixture(html`
|
|
237
|
+
<form>
|
|
238
|
+
<ui-dialog submitClose>
|
|
239
|
+
<span slot="title">Form Dialog</span>
|
|
240
|
+
<input type="text" name="username" required />
|
|
241
|
+
<ui-button color="text" slot="button" value="dismiss">Cancel</ui-button>
|
|
242
|
+
<ui-button color="text" slot="button" value="confirm" type="submit">Submit</ui-button>
|
|
243
|
+
</ui-dialog>
|
|
244
|
+
</form>
|
|
245
|
+
`)
|
|
246
|
+
const form = container as HTMLFormElement
|
|
247
|
+
const dialog = form.querySelector('ui-dialog') as UiDialog
|
|
248
|
+
return { form, dialog }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function formWithoutSubmitCloseFixture(): Promise<{ form: HTMLFormElement; dialog: UiDialog }> {
|
|
252
|
+
const container = await fixture(html`
|
|
253
|
+
<form>
|
|
254
|
+
<ui-dialog>
|
|
255
|
+
<span slot="title">Form Dialog</span>
|
|
256
|
+
<input type="text" name="username" required />
|
|
257
|
+
<ui-button color="text" slot="button" value="dismiss">Cancel</ui-button>
|
|
258
|
+
<ui-button color="text" slot="button" value="confirm" type="submit">Submit</ui-button>
|
|
259
|
+
</ui-dialog>
|
|
260
|
+
</form>
|
|
261
|
+
`)
|
|
262
|
+
const form = container as HTMLFormElement
|
|
263
|
+
const dialog = form.querySelector('ui-dialog') as UiDialog
|
|
264
|
+
return { form, dialog }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
describe('dialog wrapped in form', () => {
|
|
268
|
+
it('should detect parent form when connected', async () => {
|
|
269
|
+
const { dialog } = await formWrappedDialogFixture()
|
|
270
|
+
await dialog.updateComplete
|
|
271
|
+
|
|
272
|
+
// The form should be detected during connectedCallback
|
|
273
|
+
// We can't directly access private fields, so we test the behavior instead
|
|
274
|
+
assert.ok(dialog.submitClose, 'dialog should be configured for form handling')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should close dialog when form is submitted and submitClose is true', async () => {
|
|
278
|
+
const { form, dialog } = await formWrappedDialogFixture()
|
|
279
|
+
dialog.open = true
|
|
280
|
+
await dialog.updateComplete
|
|
281
|
+
|
|
282
|
+
const spy = sinon.spy()
|
|
283
|
+
dialog.addEventListener('close', spy)
|
|
284
|
+
|
|
285
|
+
// Simulate form submission
|
|
286
|
+
const submitEvent = new SubmitEvent('submit', { bubbles: true, cancelable: true })
|
|
287
|
+
form.dispatchEvent(submitEvent)
|
|
288
|
+
|
|
289
|
+
assert.isFalse(dialog.open, 'dialog should be closed')
|
|
290
|
+
assert.isTrue(spy.calledOnce, 'close event should be dispatched')
|
|
291
|
+
const event = spy.args[0][0] as CustomEvent
|
|
292
|
+
assert.isFalse(event.detail.cancelled, 'dialog should be confirmed, not cancelled')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('should not close dialog when form is submitted and submitClose is false', async () => {
|
|
296
|
+
const { form, dialog } = await formWithoutSubmitCloseFixture()
|
|
297
|
+
dialog.open = true
|
|
298
|
+
await dialog.updateComplete
|
|
299
|
+
|
|
300
|
+
const spy = sinon.spy()
|
|
301
|
+
dialog.addEventListener('close', spy)
|
|
302
|
+
|
|
303
|
+
// Simulate form submission
|
|
304
|
+
const submitEvent = new SubmitEvent('submit', { bubbles: true, cancelable: true })
|
|
305
|
+
form.dispatchEvent(submitEvent)
|
|
306
|
+
|
|
307
|
+
assert.isTrue(dialog.open, 'dialog should remain open')
|
|
308
|
+
assert.isFalse(spy.called, 'close event should not be dispatched')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('should not handle form submit when submit button is clicked directly', async () => {
|
|
312
|
+
const { dialog } = await formWrappedDialogFixture()
|
|
313
|
+
dialog.open = true
|
|
314
|
+
await dialog.updateComplete
|
|
315
|
+
|
|
316
|
+
const submitButton = dialog.querySelector('ui-button[type="submit"]') as UiButton
|
|
317
|
+
const spy = sinon.spy()
|
|
318
|
+
dialog.addEventListener('close', spy)
|
|
319
|
+
|
|
320
|
+
// Click the submit button - this should not close the dialog immediately
|
|
321
|
+
// because we yield control to the form
|
|
322
|
+
submitButton.click()
|
|
323
|
+
|
|
324
|
+
// The dialog should still be open because the form hasn't been submitted yet
|
|
325
|
+
assert.isTrue(dialog.open, 'dialog should remain open when submit button is clicked')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('should remove form event listener when disconnected', async () => {
|
|
329
|
+
const { form, dialog } = await formWrappedDialogFixture()
|
|
330
|
+
const removeEventListenerSpy = sinon.spy(form, 'removeEventListener')
|
|
331
|
+
|
|
332
|
+
dialog.remove()
|
|
333
|
+
|
|
334
|
+
// We can't test the private method directly, but we can verify the spy was called
|
|
335
|
+
// The actual method name is not accessible, so we test the behavior instead
|
|
336
|
+
assert.isTrue(removeEventListenerSpy.called, 'removeEventListener should be called on form')
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('edge cases', () => {
|
|
341
|
+
it('should handle form submission when dialog is not open', async () => {
|
|
342
|
+
const { form, dialog } = await formWrappedDialogFixture()
|
|
343
|
+
// Dialog is closed by default
|
|
344
|
+
assert.isFalse(dialog.open, 'dialog should be closed initially')
|
|
345
|
+
|
|
346
|
+
const spy = sinon.spy()
|
|
347
|
+
dialog.addEventListener('close', spy)
|
|
348
|
+
|
|
349
|
+
// Submit form when dialog is closed
|
|
350
|
+
const submitEvent = new SubmitEvent('submit', { bubbles: true, cancelable: true })
|
|
351
|
+
form.dispatchEvent(submitEvent)
|
|
352
|
+
|
|
353
|
+
// Should still close the dialog (set open to false even though it's already false)
|
|
354
|
+
assert.isFalse(dialog.open, 'dialog should remain closed')
|
|
355
|
+
assert.isTrue(spy.calledOnce, 'close event should still be dispatched')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('should handle multiple form submissions', async () => {
|
|
359
|
+
const { form, dialog } = await formWrappedDialogFixture()
|
|
360
|
+
dialog.open = true
|
|
361
|
+
await dialog.updateComplete
|
|
362
|
+
|
|
363
|
+
const spy = sinon.spy()
|
|
364
|
+
dialog.addEventListener('close', spy)
|
|
365
|
+
|
|
366
|
+
// Submit form multiple times
|
|
367
|
+
const submitEvent1 = new SubmitEvent('submit', { bubbles: true, cancelable: true })
|
|
368
|
+
const submitEvent2 = new SubmitEvent('submit', { bubbles: true, cancelable: true })
|
|
369
|
+
|
|
370
|
+
form.dispatchEvent(submitEvent1)
|
|
371
|
+
form.dispatchEvent(submitEvent2)
|
|
372
|
+
|
|
373
|
+
assert.equal(spy.callCount, 2, 'close event should be dispatched for each submission')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('should maintain form reference across re-connections', async () => {
|
|
377
|
+
const { form, dialog } = await formWrappedDialogFixture()
|
|
378
|
+
|
|
379
|
+
// Remove and re-add dialog
|
|
380
|
+
const parent = form
|
|
381
|
+
dialog.remove()
|
|
382
|
+
await dialog.updateComplete
|
|
383
|
+
|
|
384
|
+
parent.appendChild(dialog)
|
|
385
|
+
await dialog.updateComplete
|
|
386
|
+
|
|
387
|
+
// Test the behavior instead of accessing private fields
|
|
388
|
+
// If form handling is working, submitClose should still work
|
|
389
|
+
dialog.open = true
|
|
390
|
+
await dialog.updateComplete
|
|
391
|
+
|
|
392
|
+
const spy = sinon.spy()
|
|
393
|
+
dialog.addEventListener('close', spy)
|
|
394
|
+
|
|
395
|
+
const submitEvent = new SubmitEvent('submit', { bubbles: true, cancelable: true })
|
|
396
|
+
form.dispatchEvent(submitEvent)
|
|
397
|
+
|
|
398
|
+
assert.isTrue(spy.called, 'form handling should work after reconnection')
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
})
|
|
233
402
|
})
|
|
234
403
|
})
|