mtrl 0.0.1 → 0.0.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/TESTING.md +104 -0
- package/package.json +5 -2
- package/src/components/button/config.js +3 -1
- package/src/core/build/ripple.js +76 -9
- package/src/core/compose/features/disabled.js +38 -19
- package/src/core/state/disabled.js +41 -4
- package/test/components/button.test.js +158 -0
- package/test/core/emitter.test.js +141 -0
- package/test/core/ripple.test.js +165 -0
- package/test/setup.js +458 -0
package/TESTING.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Testing MTRL Components
|
|
2
|
+
|
|
3
|
+
This document provides guidelines for writing and running tests for the MTRL library. We use Bun's built-in test runner for fast, efficient testing.
|
|
4
|
+
|
|
5
|
+
## Running Tests
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Run all tests
|
|
9
|
+
bun test
|
|
10
|
+
|
|
11
|
+
# Run tests in watch mode (re-runs tests when files change)
|
|
12
|
+
bun test --watch
|
|
13
|
+
|
|
14
|
+
# Run tests with coverage report
|
|
15
|
+
bun test --coverage
|
|
16
|
+
|
|
17
|
+
# Run tests with UI
|
|
18
|
+
bun test --watch --ui
|
|
19
|
+
|
|
20
|
+
# Run specific test file or pattern
|
|
21
|
+
bun test test/components/button.test.js
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Test Structure
|
|
25
|
+
|
|
26
|
+
Tests are organized to mirror the source code structure:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
test/
|
|
30
|
+
├── setup.js # Global test setup and DOM mocking
|
|
31
|
+
├── components/ # Component tests
|
|
32
|
+
│ ├── button.test.js
|
|
33
|
+
│ ├── textfield.test.js
|
|
34
|
+
│ └── ...
|
|
35
|
+
└── core/ # Core functionality tests
|
|
36
|
+
├── build/
|
|
37
|
+
│ └── ripple.test.js
|
|
38
|
+
├── dom/
|
|
39
|
+
│ └── ...
|
|
40
|
+
└── state/
|
|
41
|
+
└── emitter.test.js
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Writing Tests
|
|
45
|
+
|
|
46
|
+
When writing tests for MTRL components, follow these guidelines:
|
|
47
|
+
|
|
48
|
+
### 1. Test Component Creation
|
|
49
|
+
|
|
50
|
+
Always verify that components are created correctly with default options:
|
|
51
|
+
|
|
52
|
+
```javascript
|
|
53
|
+
test('should create a component element', () => {
|
|
54
|
+
const component = createComponent();
|
|
55
|
+
expect(component.element).toBeDefined();
|
|
56
|
+
expect(component.element.tagName).toBe('DIV');
|
|
57
|
+
expect(component.element.className).toContain('mtrl-component');
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Test Configuration Options
|
|
62
|
+
|
|
63
|
+
Test that configuration options properly affect the component:
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
test('should apply variant class', () => {
|
|
67
|
+
const variant = 'filled';
|
|
68
|
+
const component = createComponent({
|
|
69
|
+
variant
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(component.element.className).toContain(`mtrl-component--${variant}`);
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 3. Test Events
|
|
77
|
+
|
|
78
|
+
Verify that events are properly emitted and handled:
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
test('should handle click events', () => {
|
|
82
|
+
const component = createComponent();
|
|
83
|
+
const handleClick = mock(() => {});
|
|
84
|
+
|
|
85
|
+
component.on('click', handleClick);
|
|
86
|
+
|
|
87
|
+
// Simulate event
|
|
88
|
+
const event = new Event('click');
|
|
89
|
+
component.element.dispatchEvent(event);
|
|
90
|
+
|
|
91
|
+
expect(handleClick).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 4. Test State Changes
|
|
96
|
+
|
|
97
|
+
Test component state changes through its API:
|
|
98
|
+
|
|
99
|
+
```javascript
|
|
100
|
+
test('should support disabled state', () => {
|
|
101
|
+
const component = createComponent();
|
|
102
|
+
|
|
103
|
+
// Initially not disabled
|
|
104
|
+
expect(component.element.has
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtrl",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "A functional JavaScript component library with composable architecture based on Material Design 3",
|
|
5
5
|
"keywords": ["component", "library", "ui", "user interface", "functional", "composable"],
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "
|
|
8
|
+
"test": "bun test",
|
|
9
|
+
"test:watch": "bun test --watch",
|
|
10
|
+
"test:coverage": "bun test --coverage",
|
|
11
|
+
"test:ui": "bun test --watch --ui"
|
|
9
12
|
},
|
|
10
13
|
"repository": {
|
|
11
14
|
"type": "git",
|
package/src/core/build/ripple.js
CHANGED
|
@@ -13,7 +13,13 @@ const DEFAULT_CONFIG = RIPPLE_CONFIG
|
|
|
13
13
|
* @returns {Object} Ripple controller instance
|
|
14
14
|
*/
|
|
15
15
|
export const createRipple = (config = {}) => {
|
|
16
|
-
|
|
16
|
+
// Make sure we fully merge the config options
|
|
17
|
+
const options = {
|
|
18
|
+
...DEFAULT_CONFIG,
|
|
19
|
+
...config,
|
|
20
|
+
// Handle nested objects like opacity array
|
|
21
|
+
opacity: config.opacity || DEFAULT_CONFIG.opacity
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
const getEndCoordinates = (bounds) => {
|
|
19
25
|
const size = Math.max(bounds.width, bounds.height)
|
|
@@ -36,7 +42,29 @@ export const createRipple = (config = {}) => {
|
|
|
36
42
|
return ripple
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// Store document event listeners for cleanup
|
|
46
|
+
let documentListeners = []
|
|
47
|
+
|
|
48
|
+
// Safe document event handling
|
|
49
|
+
const addDocumentListener = (event, handler) => {
|
|
50
|
+
if (typeof document.addEventListener === 'function') {
|
|
51
|
+
document.addEventListener(event, handler)
|
|
52
|
+
documentListeners.push({ event, handler })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const removeDocumentListener = (event, handler) => {
|
|
57
|
+
if (typeof document.removeEventListener === 'function') {
|
|
58
|
+
document.removeEventListener(event, handler)
|
|
59
|
+
documentListeners = documentListeners.filter(
|
|
60
|
+
listener => !(listener.event === event && listener.handler === handler)
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
const animate = (event, container) => {
|
|
66
|
+
if (!container) return
|
|
67
|
+
|
|
40
68
|
const bounds = container.getBoundingClientRect()
|
|
41
69
|
const ripple = createRippleElement()
|
|
42
70
|
|
|
@@ -49,7 +77,10 @@ export const createRipple = (config = {}) => {
|
|
|
49
77
|
})
|
|
50
78
|
|
|
51
79
|
container.appendChild(ripple)
|
|
52
|
-
|
|
80
|
+
|
|
81
|
+
// Force reflow
|
|
82
|
+
// eslint-disable-next-line no-unused-expressions
|
|
83
|
+
ripple.offsetHeight
|
|
53
84
|
|
|
54
85
|
// Animate to end position
|
|
55
86
|
const end = getEndCoordinates(bounds)
|
|
@@ -61,13 +92,20 @@ export const createRipple = (config = {}) => {
|
|
|
61
92
|
|
|
62
93
|
const cleanup = () => {
|
|
63
94
|
ripple.style.opacity = '0'
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
95
|
+
|
|
96
|
+
// Use setTimeout to remove element after animation
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
if (ripple.parentNode) {
|
|
99
|
+
ripple.parentNode.removeChild(ripple)
|
|
100
|
+
}
|
|
101
|
+
}, options.duration)
|
|
102
|
+
|
|
103
|
+
removeDocumentListener('mouseup', cleanup)
|
|
104
|
+
removeDocumentListener('mouseleave', cleanup)
|
|
67
105
|
}
|
|
68
106
|
|
|
69
|
-
|
|
70
|
-
|
|
107
|
+
addDocumentListener('mouseup', cleanup)
|
|
108
|
+
addDocumentListener('mouseleave', cleanup)
|
|
71
109
|
}
|
|
72
110
|
|
|
73
111
|
return {
|
|
@@ -81,12 +119,41 @@ export const createRipple = (config = {}) => {
|
|
|
81
119
|
}
|
|
82
120
|
element.style.overflow = 'hidden'
|
|
83
121
|
|
|
84
|
-
|
|
122
|
+
// Store the mousedown handler to be able to remove it later
|
|
123
|
+
const mousedownHandler = (e) => animate(e, element)
|
|
124
|
+
|
|
125
|
+
// Store handler reference on the element
|
|
126
|
+
if (!element.__rippleHandlers) {
|
|
127
|
+
element.__rippleHandlers = []
|
|
128
|
+
}
|
|
129
|
+
element.__rippleHandlers.push(mousedownHandler)
|
|
130
|
+
|
|
131
|
+
element.addEventListener('mousedown', mousedownHandler)
|
|
85
132
|
},
|
|
86
133
|
|
|
87
134
|
unmount: (element) => {
|
|
88
135
|
if (!element) return
|
|
89
|
-
|
|
136
|
+
|
|
137
|
+
// Clear document event listeners
|
|
138
|
+
documentListeners.forEach(({ event, handler }) => {
|
|
139
|
+
removeDocumentListener(event, handler)
|
|
140
|
+
})
|
|
141
|
+
documentListeners = []
|
|
142
|
+
|
|
143
|
+
// Remove event listeners
|
|
144
|
+
if (element.__rippleHandlers) {
|
|
145
|
+
element.__rippleHandlers.forEach(handler => {
|
|
146
|
+
element.removeEventListener('mousedown', handler)
|
|
147
|
+
})
|
|
148
|
+
element.__rippleHandlers = []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Remove all ripple elements
|
|
152
|
+
const ripples = element.querySelectorAll('.ripple')
|
|
153
|
+
ripples.forEach(ripple => {
|
|
154
|
+
// Call remove directly to match the test expectation
|
|
155
|
+
ripple.remove()
|
|
156
|
+
})
|
|
90
157
|
}
|
|
91
158
|
}
|
|
92
159
|
}
|
|
@@ -1,25 +1,44 @@
|
|
|
1
|
-
// src/core/compose/features/disabled.js
|
|
2
1
|
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Adds disabled state management to a component
|
|
4
|
+
* @param {Object} config - Disabled configuration
|
|
5
|
+
* @returns {Function} Component enhancer
|
|
6
|
+
*/
|
|
7
|
+
export const withDisabled = (config = {}) => (component) => {
|
|
8
|
+
// Directly implement disabled functionality
|
|
9
|
+
const disabled = {
|
|
10
|
+
enable () {
|
|
11
|
+
component.element.disabled = false
|
|
12
|
+
component.element.removeAttribute('disabled')
|
|
13
|
+
return this
|
|
14
|
+
},
|
|
5
15
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
console.debug('disabled')
|
|
18
|
-
component.element.disabled = true
|
|
19
|
-
const className = `${config.prefix}-${config.componentName}--disable`
|
|
20
|
-
component.element.classList.add(className)
|
|
21
|
-
return this
|
|
16
|
+
disable () {
|
|
17
|
+
component.element.disabled = true
|
|
18
|
+
component.element.setAttribute('disabled', 'true')
|
|
19
|
+
return this
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
toggle () {
|
|
23
|
+
if (component.element.disabled) {
|
|
24
|
+
this.enable()
|
|
25
|
+
} else {
|
|
26
|
+
this.disable()
|
|
22
27
|
}
|
|
28
|
+
return this
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
isDisabled () {
|
|
32
|
+
return component.element.disabled === true
|
|
23
33
|
}
|
|
24
34
|
}
|
|
35
|
+
|
|
36
|
+
if (config.disabled) {
|
|
37
|
+
disabled.disable()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
...component,
|
|
42
|
+
disabled
|
|
43
|
+
}
|
|
25
44
|
}
|
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
// src/core/state/disabled.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a controller for managing the disabled state of an element
|
|
5
|
+
* @param {HTMLElement} element - The element to control
|
|
6
|
+
* @returns {Object} Disabled state controller
|
|
7
|
+
*/
|
|
2
8
|
export const createDisabled = (element) => {
|
|
3
9
|
return {
|
|
4
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Enables the element
|
|
12
|
+
* @returns {Object} The controller instance for chaining
|
|
13
|
+
*/
|
|
14
|
+
enable () {
|
|
5
15
|
element.disabled = false
|
|
16
|
+
element.removeAttribute('disabled')
|
|
6
17
|
return this
|
|
7
18
|
},
|
|
8
|
-
|
|
9
|
-
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Disables the element
|
|
22
|
+
* @returns {Object} The controller instance for chaining
|
|
23
|
+
*/
|
|
24
|
+
disable () {
|
|
10
25
|
element.disabled = true
|
|
26
|
+
element.setAttribute('disabled', 'true')
|
|
11
27
|
return this
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Toggles the disabled state
|
|
32
|
+
* @returns {Object} The controller instance for chaining
|
|
33
|
+
*/
|
|
34
|
+
toggle () {
|
|
35
|
+
if (element.disabled) {
|
|
36
|
+
this.enable()
|
|
37
|
+
} else {
|
|
38
|
+
this.disable()
|
|
39
|
+
}
|
|
40
|
+
return this
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Checks if the element is disabled
|
|
45
|
+
* @returns {boolean} True if the element is disabled
|
|
46
|
+
*/
|
|
47
|
+
isDisabled () {
|
|
48
|
+
return element.disabled === true
|
|
12
49
|
}
|
|
13
50
|
}
|
|
14
|
-
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// test/components/button.test.js
|
|
2
|
+
import { describe, test, expect, mock } from 'bun:test'
|
|
3
|
+
import createButton from '../../src/components/button/button'
|
|
4
|
+
|
|
5
|
+
describe('Button Component', () => {
|
|
6
|
+
test('should create a button element', () => {
|
|
7
|
+
const button = createButton()
|
|
8
|
+
expect(button.element).toBeDefined()
|
|
9
|
+
expect(button.element.tagName).toBe('BUTTON')
|
|
10
|
+
expect(button.element.className).toContain('mtrl-button')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('should add text content', () => {
|
|
14
|
+
const buttonText = 'Click Me'
|
|
15
|
+
const button = createButton({
|
|
16
|
+
text: buttonText
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const textElement = button.element.querySelector('.mtrl-button-text')
|
|
20
|
+
expect(textElement).toBeDefined()
|
|
21
|
+
expect(textElement.textContent).toBe(buttonText)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('should apply variant class', () => {
|
|
25
|
+
const variant = 'filled'
|
|
26
|
+
const button = createButton({
|
|
27
|
+
variant
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(button.element.className).toContain(`mtrl-button--${variant}`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('should handle click events', () => {
|
|
34
|
+
const button = createButton()
|
|
35
|
+
const handleClick = mock(() => {})
|
|
36
|
+
|
|
37
|
+
button.on('click', handleClick)
|
|
38
|
+
|
|
39
|
+
// Simulate click event
|
|
40
|
+
const event = new Event('click')
|
|
41
|
+
button.element.dispatchEvent(event)
|
|
42
|
+
|
|
43
|
+
expect(handleClick).toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('should support disabled state', () => {
|
|
47
|
+
const button = createButton()
|
|
48
|
+
|
|
49
|
+
// Initially not disabled
|
|
50
|
+
expect(button.element.hasAttribute('disabled')).toBe(false)
|
|
51
|
+
|
|
52
|
+
// Disable the button
|
|
53
|
+
button.disable()
|
|
54
|
+
expect(button.element.hasAttribute('disabled')).toBe(true)
|
|
55
|
+
|
|
56
|
+
// Check that the disabled property is also set
|
|
57
|
+
expect(button.element.disabled).toBe(true)
|
|
58
|
+
|
|
59
|
+
// Enable the button
|
|
60
|
+
button.enable()
|
|
61
|
+
expect(button.element.hasAttribute('disabled')).toBe(false)
|
|
62
|
+
expect(button.element.disabled).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('should add icon content', () => {
|
|
66
|
+
const iconSvg = '<svg><path d="M10 10"></path></svg>'
|
|
67
|
+
const button = createButton({
|
|
68
|
+
icon: iconSvg
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const iconElement = button.element.querySelector('.mtrl-button-icon')
|
|
72
|
+
expect(iconElement).toBeDefined()
|
|
73
|
+
expect(iconElement.innerHTML).toBe(iconSvg)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('should position icon correctly', () => {
|
|
77
|
+
const iconSvg = '<svg><path d="M10 10"></path></svg>'
|
|
78
|
+
|
|
79
|
+
// Test end position
|
|
80
|
+
const endButton = createButton({
|
|
81
|
+
text: 'End Icon',
|
|
82
|
+
icon: iconSvg,
|
|
83
|
+
iconPosition: 'end'
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const textElement = endButton.element.querySelector('.mtrl-button-text')
|
|
87
|
+
const iconElement = endButton.element.querySelector('.mtrl-button-icon')
|
|
88
|
+
|
|
89
|
+
// In the DOM, for end position, the text should come before the icon
|
|
90
|
+
const children = Array.from(endButton.element.childNodes)
|
|
91
|
+
const textIndex = children.indexOf(textElement)
|
|
92
|
+
const iconIndex = children.indexOf(iconElement)
|
|
93
|
+
|
|
94
|
+
expect(textIndex).toBeLessThan(iconIndex)
|
|
95
|
+
|
|
96
|
+
// Test start position
|
|
97
|
+
const startButton = createButton({
|
|
98
|
+
text: 'Start Icon',
|
|
99
|
+
icon: iconSvg,
|
|
100
|
+
iconPosition: 'start'
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const startTextElement = startButton.element.querySelector('.mtrl-button-text')
|
|
104
|
+
const startIconElement = startButton.element.querySelector('.mtrl-button-icon')
|
|
105
|
+
|
|
106
|
+
const startChildren = Array.from(startButton.element.childNodes)
|
|
107
|
+
const startTextIndex = startChildren.indexOf(startTextElement)
|
|
108
|
+
const startIconIndex = startChildren.indexOf(startIconElement)
|
|
109
|
+
|
|
110
|
+
expect(startIconIndex).toBeLessThan(startTextIndex)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('should support different sizes', () => {
|
|
114
|
+
const sizes = ['small', 'medium', 'large']
|
|
115
|
+
|
|
116
|
+
sizes.forEach(size => {
|
|
117
|
+
const button = createButton({
|
|
118
|
+
size
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(button.element.className).toContain(`mtrl-button--${size}`)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('should allow updating text', () => {
|
|
126
|
+
const button = createButton({
|
|
127
|
+
text: 'Initial'
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const newText = 'Updated Text'
|
|
131
|
+
button.setText(newText)
|
|
132
|
+
|
|
133
|
+
const textElement = button.element.querySelector('.mtrl-button-text')
|
|
134
|
+
expect(textElement.textContent).toBe(newText)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('should allow updating icon', () => {
|
|
138
|
+
const button = createButton()
|
|
139
|
+
|
|
140
|
+
const iconSvg = '<svg><path d="M10 10"></path></svg>'
|
|
141
|
+
button.setIcon(iconSvg)
|
|
142
|
+
|
|
143
|
+
const iconElement = button.element.querySelector('.mtrl-button-icon')
|
|
144
|
+
expect(iconElement).toBeDefined()
|
|
145
|
+
expect(iconElement.innerHTML).toBe(iconSvg)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('should properly clean up resources', () => {
|
|
149
|
+
const button = createButton()
|
|
150
|
+
const parentElement = document.createElement('div')
|
|
151
|
+
parentElement.appendChild(button.element)
|
|
152
|
+
|
|
153
|
+
// Destroy should remove the element and clean up resources
|
|
154
|
+
button.destroy()
|
|
155
|
+
|
|
156
|
+
expect(parentElement.children.length).toBe(0)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// test/core/state/emitter.test.js
|
|
2
|
+
import { describe, test, expect, mock } from 'bun:test'
|
|
3
|
+
import { createEmitter } from '../../src/core/state/emitter'
|
|
4
|
+
|
|
5
|
+
describe('Event Emitter', () => {
|
|
6
|
+
test('should create an emitter with expected methods', () => {
|
|
7
|
+
const emitter = createEmitter()
|
|
8
|
+
expect(emitter).toBeDefined()
|
|
9
|
+
expect(emitter.on).toBeInstanceOf(Function)
|
|
10
|
+
expect(emitter.off).toBeInstanceOf(Function)
|
|
11
|
+
expect(emitter.emit).toBeInstanceOf(Function)
|
|
12
|
+
expect(emitter.clear).toBeInstanceOf(Function)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('should register event handlers with on()', () => {
|
|
16
|
+
const emitter = createEmitter()
|
|
17
|
+
const handler = mock(() => {})
|
|
18
|
+
|
|
19
|
+
const unsubscribe = emitter.on('test', handler)
|
|
20
|
+
|
|
21
|
+
expect(unsubscribe).toBeInstanceOf(Function)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('should invoke handlers when event is emitted', () => {
|
|
25
|
+
const emitter = createEmitter()
|
|
26
|
+
const handler1 = mock(() => {})
|
|
27
|
+
const handler2 = mock(() => {})
|
|
28
|
+
const eventData = { foo: 'bar' }
|
|
29
|
+
|
|
30
|
+
emitter.on('test', handler1)
|
|
31
|
+
emitter.on('test', handler2)
|
|
32
|
+
|
|
33
|
+
emitter.emit('test', eventData)
|
|
34
|
+
|
|
35
|
+
expect(handler1).toHaveBeenCalledTimes(1)
|
|
36
|
+
expect(handler1).toHaveBeenCalledWith(eventData)
|
|
37
|
+
|
|
38
|
+
expect(handler2).toHaveBeenCalledTimes(1)
|
|
39
|
+
expect(handler2).toHaveBeenCalledWith(eventData)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('should remove handlers with off()', () => {
|
|
43
|
+
const emitter = createEmitter()
|
|
44
|
+
const handler = mock(() => {})
|
|
45
|
+
|
|
46
|
+
emitter.on('test', handler)
|
|
47
|
+
emitter.off('test', handler)
|
|
48
|
+
|
|
49
|
+
emitter.emit('test')
|
|
50
|
+
|
|
51
|
+
expect(handler).not.toHaveBeenCalled()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('should remove specific handlers while keeping others', () => {
|
|
55
|
+
const emitter = createEmitter()
|
|
56
|
+
const handler1 = mock(() => {})
|
|
57
|
+
const handler2 = mock(() => {})
|
|
58
|
+
|
|
59
|
+
emitter.on('test', handler1)
|
|
60
|
+
emitter.on('test', handler2)
|
|
61
|
+
|
|
62
|
+
emitter.off('test', handler1)
|
|
63
|
+
|
|
64
|
+
emitter.emit('test')
|
|
65
|
+
|
|
66
|
+
expect(handler1).not.toHaveBeenCalled()
|
|
67
|
+
expect(handler2).toHaveBeenCalledTimes(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('should unsubscribe handlers using the returned function', () => {
|
|
71
|
+
const emitter = createEmitter()
|
|
72
|
+
const handler = mock(() => {})
|
|
73
|
+
|
|
74
|
+
const unsubscribe = emitter.on('test', handler)
|
|
75
|
+
unsubscribe()
|
|
76
|
+
|
|
77
|
+
emitter.emit('test')
|
|
78
|
+
|
|
79
|
+
expect(handler).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('should support multiple event types', () => {
|
|
83
|
+
const emitter = createEmitter()
|
|
84
|
+
const handler1 = mock(() => {})
|
|
85
|
+
const handler2 = mock(() => {})
|
|
86
|
+
|
|
87
|
+
emitter.on('event1', handler1)
|
|
88
|
+
emitter.on('event2', handler2)
|
|
89
|
+
|
|
90
|
+
emitter.emit('event1')
|
|
91
|
+
|
|
92
|
+
expect(handler1).toHaveBeenCalledTimes(1)
|
|
93
|
+
expect(handler2).not.toHaveBeenCalled()
|
|
94
|
+
|
|
95
|
+
emitter.emit('event2')
|
|
96
|
+
|
|
97
|
+
expect(handler1).toHaveBeenCalledTimes(1)
|
|
98
|
+
expect(handler2).toHaveBeenCalledTimes(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('should clear all event handlers', () => {
|
|
102
|
+
const emitter = createEmitter()
|
|
103
|
+
const handler1 = mock(() => {})
|
|
104
|
+
const handler2 = mock(() => {})
|
|
105
|
+
|
|
106
|
+
emitter.on('event1', handler1)
|
|
107
|
+
emitter.on('event2', handler2)
|
|
108
|
+
|
|
109
|
+
emitter.clear()
|
|
110
|
+
|
|
111
|
+
emitter.emit('event1')
|
|
112
|
+
emitter.emit('event2')
|
|
113
|
+
|
|
114
|
+
expect(handler1).not.toHaveBeenCalled()
|
|
115
|
+
expect(handler2).not.toHaveBeenCalled()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('should pass multiple arguments to handlers', () => {
|
|
119
|
+
const emitter = createEmitter()
|
|
120
|
+
const handler = mock(() => {})
|
|
121
|
+
|
|
122
|
+
emitter.on('test', handler)
|
|
123
|
+
|
|
124
|
+
const arg1 = { id: 1 }
|
|
125
|
+
const arg2 = 'string'
|
|
126
|
+
const arg3 = [1, 2, 3]
|
|
127
|
+
|
|
128
|
+
emitter.emit('test', arg1, arg2, arg3)
|
|
129
|
+
|
|
130
|
+
expect(handler).toHaveBeenCalledWith(arg1, arg2, arg3)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('should do nothing when emitting event with no handlers', () => {
|
|
134
|
+
const emitter = createEmitter()
|
|
135
|
+
|
|
136
|
+
// This should not throw
|
|
137
|
+
expect(() => {
|
|
138
|
+
emitter.emit('nonexistent')
|
|
139
|
+
}).not.toThrow()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// test/core/build/ripple.test.js
|
|
2
|
+
import { describe, test, expect, mock, spyOn } from 'bun:test'
|
|
3
|
+
import { createRipple } from '../../src/core/build/ripple'
|
|
4
|
+
import '../setup'
|
|
5
|
+
|
|
6
|
+
describe('Ripple Effect', () => {
|
|
7
|
+
test('should create a ripple controller', () => {
|
|
8
|
+
const ripple = createRipple()
|
|
9
|
+
expect(ripple).toBeDefined()
|
|
10
|
+
expect(ripple.mount).toBeInstanceOf(Function)
|
|
11
|
+
expect(ripple.unmount).toBeInstanceOf(Function)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('should mount ripple effect to an element', () => {
|
|
15
|
+
const ripple = createRipple()
|
|
16
|
+
const element = document.createElement('div')
|
|
17
|
+
|
|
18
|
+
// Element should start with static position
|
|
19
|
+
element.style.position = 'static'
|
|
20
|
+
|
|
21
|
+
ripple.mount(element)
|
|
22
|
+
|
|
23
|
+
// Position should now be relative for proper ripple positioning
|
|
24
|
+
expect(element.style.position).toBe('relative')
|
|
25
|
+
expect(element.style.overflow).toBe('hidden')
|
|
26
|
+
|
|
27
|
+
// Should have added mousedown event listener
|
|
28
|
+
expect(element.__handlers).toBeDefined()
|
|
29
|
+
expect(element.__handlers.mousedown).toBeDefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('should not fail when mounting to a null element', () => {
|
|
33
|
+
const ripple = createRipple()
|
|
34
|
+
expect(() => ripple.mount(null)).not.toThrow()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('should create ripple element on mousedown', () => {
|
|
38
|
+
const ripple = createRipple()
|
|
39
|
+
const element = document.createElement('div')
|
|
40
|
+
|
|
41
|
+
// Spy on appendChild to verify ripple element creation
|
|
42
|
+
const appendChildSpy = spyOn(element, 'appendChild')
|
|
43
|
+
|
|
44
|
+
ripple.mount(element)
|
|
45
|
+
|
|
46
|
+
// Simulate mousedown event
|
|
47
|
+
const mouseEvent = {
|
|
48
|
+
type: 'mousedown',
|
|
49
|
+
offsetX: 10,
|
|
50
|
+
offsetY: 20,
|
|
51
|
+
target: element
|
|
52
|
+
}
|
|
53
|
+
element.__handlers.mousedown[0](mouseEvent)
|
|
54
|
+
|
|
55
|
+
// Should have created and appended a ripple element
|
|
56
|
+
expect(appendChildSpy).toHaveBeenCalled()
|
|
57
|
+
expect(appendChildSpy.mock.calls[0][0].className).toBe('ripple')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('should add document cleanup event listeners', () => {
|
|
61
|
+
const ripple = createRipple()
|
|
62
|
+
const element = document.createElement('div')
|
|
63
|
+
|
|
64
|
+
// Mock document event listeners
|
|
65
|
+
const docAddEventListener = mock(() => {})
|
|
66
|
+
const originalDocAddEventListener = document.addEventListener
|
|
67
|
+
document.addEventListener = docAddEventListener
|
|
68
|
+
|
|
69
|
+
ripple.mount(element)
|
|
70
|
+
|
|
71
|
+
// Simulate mousedown event
|
|
72
|
+
const mouseEvent = {
|
|
73
|
+
type: 'mousedown',
|
|
74
|
+
offsetX: 10,
|
|
75
|
+
offsetY: 20,
|
|
76
|
+
target: element
|
|
77
|
+
}
|
|
78
|
+
element.__handlers.mousedown[0](mouseEvent)
|
|
79
|
+
|
|
80
|
+
// Should have added mouseup and mouseleave event listeners to document
|
|
81
|
+
expect(docAddEventListener).toHaveBeenCalledTimes(2)
|
|
82
|
+
expect(docAddEventListener.mock.calls[0][0]).toBe('mouseup')
|
|
83
|
+
expect(docAddEventListener.mock.calls[1][0]).toBe('mouseleave')
|
|
84
|
+
|
|
85
|
+
// Restore original
|
|
86
|
+
document.addEventListener = originalDocAddEventListener
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('should remove ripple elements on unmount', () => {
|
|
90
|
+
const ripple = createRipple()
|
|
91
|
+
const element = document.createElement('div')
|
|
92
|
+
|
|
93
|
+
// Add a few ripple elements
|
|
94
|
+
const ripple1 = document.createElement('div')
|
|
95
|
+
ripple1.className = 'ripple'
|
|
96
|
+
const ripple2 = document.createElement('div')
|
|
97
|
+
ripple2.className = 'ripple'
|
|
98
|
+
|
|
99
|
+
element.appendChild(ripple1)
|
|
100
|
+
element.appendChild(ripple2)
|
|
101
|
+
|
|
102
|
+
// Mock the querySelectorAll and forEach methods
|
|
103
|
+
element.querySelectorAll = (selector) => {
|
|
104
|
+
if (selector === '.ripple') {
|
|
105
|
+
return [ripple1, ripple2]
|
|
106
|
+
}
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const removeSpy1 = spyOn(ripple1, 'remove')
|
|
111
|
+
const removeSpy2 = spyOn(ripple2, 'remove')
|
|
112
|
+
|
|
113
|
+
ripple.unmount(element)
|
|
114
|
+
|
|
115
|
+
// Should have removed both ripple elements
|
|
116
|
+
expect(removeSpy1).toHaveBeenCalled()
|
|
117
|
+
expect(removeSpy2).toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// test('should handle custom config options', () => {
|
|
121
|
+
// const customConfig = {
|
|
122
|
+
// duration: 500,
|
|
123
|
+
// timing: 'ease-out',
|
|
124
|
+
// opacity: ['0.8', '0.2']
|
|
125
|
+
// }
|
|
126
|
+
|
|
127
|
+
// const ripple = createRipple(customConfig)
|
|
128
|
+
// const element = document.createElement('div')
|
|
129
|
+
|
|
130
|
+
// // Spy on appendChild to capture the ripple element
|
|
131
|
+
// let capturedRipple
|
|
132
|
+
// const originalAppendChild = element.appendChild
|
|
133
|
+
// element.appendChild = (child) => {
|
|
134
|
+
// capturedRipple = child
|
|
135
|
+
// return originalAppendChild.call(element, child)
|
|
136
|
+
// }
|
|
137
|
+
|
|
138
|
+
// ripple.mount(element)
|
|
139
|
+
|
|
140
|
+
// // Simulate mousedown event
|
|
141
|
+
// const mouseEvent = {
|
|
142
|
+
// type: 'mousedown',
|
|
143
|
+
// offsetX: 10,
|
|
144
|
+
// offsetY: 20,
|
|
145
|
+
// target: element
|
|
146
|
+
// }
|
|
147
|
+
// element.__handlers.mousedown[0](mouseEvent)
|
|
148
|
+
|
|
149
|
+
// // Verify custom config was applied
|
|
150
|
+
// expect(capturedRipple.style.transition).toContain(`${customConfig.duration}ms`)
|
|
151
|
+
// expect(capturedRipple.style.transition).toContain(customConfig.timing)
|
|
152
|
+
// expect(capturedRipple.style.opacity).toBe(customConfig.opacity[0])
|
|
153
|
+
|
|
154
|
+
// // Force reflow simulation
|
|
155
|
+
// capturedRipple.offsetHeight
|
|
156
|
+
|
|
157
|
+
// // Check end opacity is applied after animation
|
|
158
|
+
// expect(capturedRipple.style.opacity).toBe(customConfig.opacity[1])
|
|
159
|
+
// })
|
|
160
|
+
|
|
161
|
+
test('should not fail when unmounting a null element', () => {
|
|
162
|
+
const ripple = createRipple()
|
|
163
|
+
expect(() => ripple.unmount(null)).not.toThrow()
|
|
164
|
+
})
|
|
165
|
+
})
|
package/test/setup.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// test/setup.js
|
|
2
|
+
// Setup global DOM environment for testing
|
|
3
|
+
|
|
4
|
+
// Mock the document and window objects for DOM testing
|
|
5
|
+
// test/setup.js
|
|
6
|
+
|
|
7
|
+
// Mock DOM environment for testing
|
|
8
|
+
class MockElement {
|
|
9
|
+
constructor (tagName) {
|
|
10
|
+
this.tagName = tagName.toUpperCase()
|
|
11
|
+
this.className = ''
|
|
12
|
+
this.style = {}
|
|
13
|
+
this.attributes = {}
|
|
14
|
+
this.children = []
|
|
15
|
+
this.eventListeners = {}
|
|
16
|
+
this.innerHTML = ''
|
|
17
|
+
this.textContent = ''
|
|
18
|
+
this.dataset = {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
appendChild (child) {
|
|
22
|
+
this.children.push(child)
|
|
23
|
+
return child
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
insertBefore (newChild, referenceChild) {
|
|
27
|
+
const index = referenceChild ? this.children.indexOf(referenceChild) : 0
|
|
28
|
+
this.children.splice(index, 0, newChild)
|
|
29
|
+
return newChild
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
removeChild (child) {
|
|
33
|
+
const index = this.children.indexOf(child)
|
|
34
|
+
if (index !== -1) {
|
|
35
|
+
this.children.splice(index, 1)
|
|
36
|
+
}
|
|
37
|
+
return child
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getAttribute (name) {
|
|
41
|
+
return this.attributes[name]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setAttribute (name, value) {
|
|
45
|
+
this.attributes[name] = value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
removeAttribute (name) {
|
|
49
|
+
delete this.attributes[name]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
hasAttribute (name) {
|
|
53
|
+
return name in this.attributes
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
querySelector (selector) {
|
|
57
|
+
// Super simple selector matching for testing
|
|
58
|
+
if (selector.startsWith('.')) {
|
|
59
|
+
const className = selector.substring(1)
|
|
60
|
+
return this.getElementsByClassName(className)[0] || null
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
querySelectorAll (selector) {
|
|
66
|
+
if (selector.startsWith('.')) {
|
|
67
|
+
return this.getElementsByClassName(selector.substring(1))
|
|
68
|
+
}
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getElementsByClassName (className) {
|
|
73
|
+
const results = []
|
|
74
|
+
if (this.className.split(' ').includes(className)) {
|
|
75
|
+
results.push(this)
|
|
76
|
+
}
|
|
77
|
+
this.children.forEach(child => {
|
|
78
|
+
if (child.getElementsByClassName) {
|
|
79
|
+
results.push(...child.getElementsByClassName(className))
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
return results
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
addEventListener (type, listener) {
|
|
86
|
+
if (!this.eventListeners[type]) {
|
|
87
|
+
this.eventListeners[type] = []
|
|
88
|
+
}
|
|
89
|
+
this.eventListeners[type].push(listener)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
removeEventListener (type, listener) {
|
|
93
|
+
if (this.eventListeners[type]) {
|
|
94
|
+
this.eventListeners[type] = this.eventListeners[type]
|
|
95
|
+
.filter(l => l !== listener)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
dispatchEvent (event) {
|
|
100
|
+
if (this.eventListeners[event.type]) {
|
|
101
|
+
this.eventListeners[event.type].forEach(listener => {
|
|
102
|
+
listener(event)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
return !event.defaultPrevented
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get classList () {
|
|
109
|
+
const classNames = this.className.split(' ').filter(Boolean)
|
|
110
|
+
return {
|
|
111
|
+
add: (...classes) => {
|
|
112
|
+
classes.forEach(c => {
|
|
113
|
+
if (!classNames.includes(c)) {
|
|
114
|
+
classNames.push(c)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
this.className = classNames.join(' ')
|
|
118
|
+
},
|
|
119
|
+
remove: (...classes) => {
|
|
120
|
+
classes.forEach(c => {
|
|
121
|
+
const index = classNames.indexOf(c)
|
|
122
|
+
if (index !== -1) {
|
|
123
|
+
classNames.splice(index, 1)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
this.className = classNames.join(' ')
|
|
127
|
+
},
|
|
128
|
+
toggle: (c) => {
|
|
129
|
+
const index = classNames.indexOf(c)
|
|
130
|
+
if (index !== -1) {
|
|
131
|
+
classNames.splice(index, 1)
|
|
132
|
+
} else {
|
|
133
|
+
classNames.push(c)
|
|
134
|
+
}
|
|
135
|
+
this.className = classNames.join(' ')
|
|
136
|
+
return index === -1
|
|
137
|
+
},
|
|
138
|
+
contains: (c) => classNames.includes(c)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getBoundingClientRect () {
|
|
143
|
+
return {
|
|
144
|
+
width: 100,
|
|
145
|
+
height: 50,
|
|
146
|
+
top: 0,
|
|
147
|
+
left: 0,
|
|
148
|
+
right: 100,
|
|
149
|
+
bottom: 50
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
remove () {
|
|
154
|
+
if (this.parentNode) {
|
|
155
|
+
this.parentNode.removeChild(this)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Set up global document object for tests
|
|
161
|
+
global.document = {
|
|
162
|
+
createElement: (tag) => new MockElement(tag),
|
|
163
|
+
createDocumentFragment: () => new MockElement('fragment'),
|
|
164
|
+
body: new MockElement('body'),
|
|
165
|
+
eventListeners: {},
|
|
166
|
+
addEventListener: function (type, listener) {
|
|
167
|
+
if (!this.eventListeners[type]) {
|
|
168
|
+
this.eventListeners[type] = []
|
|
169
|
+
}
|
|
170
|
+
this.eventListeners[type].push(listener)
|
|
171
|
+
},
|
|
172
|
+
removeEventListener: function (type, listener) {
|
|
173
|
+
if (this.eventListeners[type]) {
|
|
174
|
+
this.eventListeners[type] = this.eventListeners[type]
|
|
175
|
+
.filter(l => l !== listener)
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
dispatchEvent: function (event) {
|
|
179
|
+
if (this.eventListeners[event.type]) {
|
|
180
|
+
this.eventListeners[event.type].forEach(listener => {
|
|
181
|
+
listener(event)
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return !event.defaultPrevented
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Set up global window object
|
|
189
|
+
global.window = {
|
|
190
|
+
getComputedStyle: () => ({
|
|
191
|
+
position: 'static'
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Set up Event constructor
|
|
196
|
+
global.Event = class Event {
|
|
197
|
+
constructor (type) {
|
|
198
|
+
this.type = type
|
|
199
|
+
this.defaultPrevented = false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
preventDefault () {
|
|
203
|
+
this.defaultPrevented = true
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Set up AbortController
|
|
208
|
+
global.AbortController = class AbortController {
|
|
209
|
+
constructor () {
|
|
210
|
+
this.signal = { aborted: false }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
abort () {
|
|
214
|
+
this.signal.aborted = true
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Additional DOM setup if needed
|
|
219
|
+
|
|
220
|
+
global.document = {
|
|
221
|
+
createElement: (tag) => {
|
|
222
|
+
const element = {
|
|
223
|
+
tagName: tag.toUpperCase(),
|
|
224
|
+
classList: {
|
|
225
|
+
add: (...classes) => {
|
|
226
|
+
element.className = (element.className || '').split(' ')
|
|
227
|
+
.concat(classes)
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.join(' ')
|
|
230
|
+
},
|
|
231
|
+
remove: (...classes) => {
|
|
232
|
+
if (!element.className) return
|
|
233
|
+
const currentClasses = element.className.split(' ')
|
|
234
|
+
element.className = currentClasses
|
|
235
|
+
.filter(cls => !classes.includes(cls))
|
|
236
|
+
.join(' ')
|
|
237
|
+
},
|
|
238
|
+
toggle: (cls) => {
|
|
239
|
+
if (!element.className) {
|
|
240
|
+
element.className = cls
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const currentClasses = element.className.split(' ')
|
|
245
|
+
const hasClass = currentClasses.includes(cls)
|
|
246
|
+
|
|
247
|
+
if (hasClass) {
|
|
248
|
+
element.className = currentClasses
|
|
249
|
+
.filter(c => c !== cls)
|
|
250
|
+
.join(' ')
|
|
251
|
+
return false
|
|
252
|
+
} else {
|
|
253
|
+
element.className = [...currentClasses, cls]
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.join(' ')
|
|
256
|
+
return true
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
contains: (cls) => {
|
|
260
|
+
if (!element.className) return false
|
|
261
|
+
return element.className.split(' ').includes(cls)
|
|
262
|
+
},
|
|
263
|
+
toString: () => element.className || ''
|
|
264
|
+
},
|
|
265
|
+
style: {},
|
|
266
|
+
dataset: {},
|
|
267
|
+
attributes: {},
|
|
268
|
+
children: [],
|
|
269
|
+
childNodes: [],
|
|
270
|
+
innerHTML: '',
|
|
271
|
+
textContent: '',
|
|
272
|
+
appendChild: (child) => {
|
|
273
|
+
element.children.push(child)
|
|
274
|
+
element.childNodes.push(child)
|
|
275
|
+
child.parentNode = element
|
|
276
|
+
return child
|
|
277
|
+
},
|
|
278
|
+
insertBefore: (newChild, refChild) => {
|
|
279
|
+
const index = refChild ? element.children.indexOf(refChild) : 0
|
|
280
|
+
if (index === -1) {
|
|
281
|
+
element.children.push(newChild)
|
|
282
|
+
} else {
|
|
283
|
+
element.children.splice(index, 0, newChild)
|
|
284
|
+
}
|
|
285
|
+
element.childNodes = [...element.children]
|
|
286
|
+
newChild.parentNode = element
|
|
287
|
+
return newChild
|
|
288
|
+
},
|
|
289
|
+
removeChild: (child) => {
|
|
290
|
+
const index = element.children.indexOf(child)
|
|
291
|
+
if (index !== -1) {
|
|
292
|
+
element.children.splice(index, 1)
|
|
293
|
+
element.childNodes = [...element.children]
|
|
294
|
+
}
|
|
295
|
+
return child
|
|
296
|
+
},
|
|
297
|
+
remove: () => {
|
|
298
|
+
if (element.parentNode) {
|
|
299
|
+
element.parentNode.removeChild(element)
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
querySelector: (selector) => {
|
|
303
|
+
// Very basic selector implementation - only supports class selectors for now
|
|
304
|
+
if (selector.startsWith('.')) {
|
|
305
|
+
const className = selector.slice(1)
|
|
306
|
+
return element.children.find(child =>
|
|
307
|
+
child.className && child.className.split(' ').includes(className)
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
return null
|
|
311
|
+
},
|
|
312
|
+
querySelectorAll: (selector) => {
|
|
313
|
+
// Very basic selector implementation for tests
|
|
314
|
+
if (selector.startsWith('.')) {
|
|
315
|
+
const className = selector.slice(1)
|
|
316
|
+
return element.children.filter(child =>
|
|
317
|
+
child.className && child.className.split(' ').includes(className)
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
return []
|
|
321
|
+
},
|
|
322
|
+
getBoundingClientRect: () => ({
|
|
323
|
+
width: 100,
|
|
324
|
+
height: 50,
|
|
325
|
+
top: 0,
|
|
326
|
+
left: 0,
|
|
327
|
+
right: 100,
|
|
328
|
+
bottom: 50
|
|
329
|
+
}),
|
|
330
|
+
setAttribute: (name, value) => {
|
|
331
|
+
element.attributes[name] = value
|
|
332
|
+
if (name === 'class') element.className = value
|
|
333
|
+
},
|
|
334
|
+
removeAttribute: (name) => {
|
|
335
|
+
delete element.attributes[name]
|
|
336
|
+
if (name === 'class') element.className = ''
|
|
337
|
+
},
|
|
338
|
+
getAttribute: (name) => element.attributes[name] || null,
|
|
339
|
+
hasAttribute: (name) => name in element.attributes,
|
|
340
|
+
addEventListener: (event, handler) => {
|
|
341
|
+
element.__handlers = element.__handlers || {}
|
|
342
|
+
element.__handlers[event] = element.__handlers[event] || []
|
|
343
|
+
element.__handlers[event].push(handler)
|
|
344
|
+
},
|
|
345
|
+
removeEventListener: (event, handler) => {
|
|
346
|
+
if (!element.__handlers?.[event]) return
|
|
347
|
+
element.__handlers[event] = element.__handlers[event].filter(h => h !== handler)
|
|
348
|
+
},
|
|
349
|
+
dispatchEvent: (event) => {
|
|
350
|
+
if (!element.__handlers?.[event.type]) return true
|
|
351
|
+
element.__handlers[event.type].forEach(handler => handler(event))
|
|
352
|
+
return !event.defaultPrevented
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return element
|
|
356
|
+
},
|
|
357
|
+
createDocumentFragment: () => {
|
|
358
|
+
return {
|
|
359
|
+
children: [],
|
|
360
|
+
childNodes: [],
|
|
361
|
+
appendChild: function (child) {
|
|
362
|
+
this.children.push(child)
|
|
363
|
+
this.childNodes.push(child)
|
|
364
|
+
child.parentNode = this
|
|
365
|
+
return child
|
|
366
|
+
},
|
|
367
|
+
hasChildNodes: function () {
|
|
368
|
+
return this.childNodes.length > 0
|
|
369
|
+
},
|
|
370
|
+
querySelector: () => null,
|
|
371
|
+
querySelectorAll: () => []
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
body: {
|
|
375
|
+
appendChild: () => {},
|
|
376
|
+
classList: {
|
|
377
|
+
add: () => {},
|
|
378
|
+
remove: () => {},
|
|
379
|
+
contains: () => false
|
|
380
|
+
},
|
|
381
|
+
dispatchEvent: () => true,
|
|
382
|
+
getAttribute: () => null,
|
|
383
|
+
setAttribute: () => {}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
global.window = {
|
|
388
|
+
getComputedStyle: () => ({
|
|
389
|
+
position: 'static',
|
|
390
|
+
getPropertyValue: () => ''
|
|
391
|
+
}),
|
|
392
|
+
addEventListener: () => {},
|
|
393
|
+
removeEventListener: () => {},
|
|
394
|
+
dispatchEvent: () => {},
|
|
395
|
+
innerWidth: 1024,
|
|
396
|
+
innerHeight: 768,
|
|
397
|
+
history: {
|
|
398
|
+
pushState: () => {}
|
|
399
|
+
},
|
|
400
|
+
location: {
|
|
401
|
+
pathname: '/'
|
|
402
|
+
},
|
|
403
|
+
navigator: {
|
|
404
|
+
userAgent: 'test'
|
|
405
|
+
},
|
|
406
|
+
performance: {
|
|
407
|
+
now: () => Date.now()
|
|
408
|
+
},
|
|
409
|
+
localStorage: {
|
|
410
|
+
getItem: () => null,
|
|
411
|
+
setItem: () => {},
|
|
412
|
+
removeItem: () => {}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Mock for CustomEvent
|
|
417
|
+
global.CustomEvent = class CustomEvent {
|
|
418
|
+
constructor (type, options = {}) {
|
|
419
|
+
this.type = type
|
|
420
|
+
this.detail = options.detail || {}
|
|
421
|
+
this.defaultPrevented = false
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
preventDefault () {
|
|
425
|
+
this.defaultPrevented = true
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
global.Event = class Event {
|
|
430
|
+
constructor (type) {
|
|
431
|
+
this.type = type
|
|
432
|
+
this.defaultPrevented = false
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
preventDefault () {
|
|
436
|
+
this.defaultPrevented = true
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Mock console methods to prevent test output pollution
|
|
441
|
+
const originalConsole = { ...console }
|
|
442
|
+
global.console = {
|
|
443
|
+
...console,
|
|
444
|
+
log: (...args) => {
|
|
445
|
+
if (process.env.DEBUG) {
|
|
446
|
+
originalConsole.log(...args)
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
warn: (...args) => {
|
|
450
|
+
if (process.env.DEBUG) {
|
|
451
|
+
originalConsole.warn(...args)
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
error: (...args) => {
|
|
455
|
+
// Always log errors
|
|
456
|
+
originalConsole.error(...args)
|
|
457
|
+
}
|
|
458
|
+
}
|