@mdxui/terminal 2.0.0
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/README.md +571 -0
- package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
- package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
- package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
- package/dist/chunk-3EFDH7PK.js +5235 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-3X5IR6WE.js +884 -0
- package/dist/chunk-4FV5ZDCE.js +5236 -0
- package/dist/chunk-4OVMSF2J.js +243 -0
- package/dist/chunk-63FEETIS.js +4048 -0
- package/dist/chunk-B43KP7XJ.js +884 -0
- package/dist/chunk-BMTJXWUV.js +655 -0
- package/dist/chunk-C3SVH4N7.js +882 -0
- package/dist/chunk-EVWR7Y47.js +874 -0
- package/dist/chunk-F6A5VWUC.js +1285 -0
- package/dist/chunk-FD7KW7GE.js +882 -0
- package/dist/chunk-GBQ6UD6I.js +655 -0
- package/dist/chunk-GMDD3M6U.js +5227 -0
- package/dist/chunk-JBHRXOXM.js +1058 -0
- package/dist/chunk-JFOO3EYO.js +1182 -0
- package/dist/chunk-JQ5H3WXL.js +1291 -0
- package/dist/chunk-JQD5NASE.js +234 -0
- package/dist/chunk-KRHJP5R7.js +592 -0
- package/dist/chunk-KWF6WVJE.js +962 -0
- package/dist/chunk-LHYQVN3H.js +1038 -0
- package/dist/chunk-M3TLQLGC.js +1032 -0
- package/dist/chunk-MVW4Q5OP.js +240 -0
- package/dist/chunk-NXCZSWLU.js +1294 -0
- package/dist/chunk-O25TNRO6.js +607 -0
- package/dist/chunk-PNECDA2I.js +884 -0
- package/dist/chunk-QIHWRLJR.js +962 -0
- package/dist/chunk-QW5YMQ7K.js +882 -0
- package/dist/chunk-R5U7XKVJ.js +16 -0
- package/dist/chunk-RP2MVQLR.js +962 -0
- package/dist/chunk-TP6RXGXA.js +1087 -0
- package/dist/chunk-TQQSTITZ.js +655 -0
- package/dist/chunk-X24GWXQV.js +1281 -0
- package/dist/components/index.d.ts +802 -0
- package/dist/components/index.js +149 -0
- package/dist/data/index.d.ts +2554 -0
- package/dist/data/index.js +51 -0
- package/dist/forms/index.d.ts +1596 -0
- package/dist/forms/index.js +464 -0
- package/dist/index-CQRFZntR.d.ts +867 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +786 -0
- package/dist/interactive-D0JkWosD.d.ts +217 -0
- package/dist/keyboard/index.d.ts +2 -0
- package/dist/keyboard/index.js +43 -0
- package/dist/renderers/index.d.ts +546 -0
- package/dist/renderers/index.js +2157 -0
- package/dist/storybook/index.d.ts +396 -0
- package/dist/storybook/index.js +641 -0
- package/dist/theme/index.d.ts +1339 -0
- package/dist/theme/index.js +123 -0
- package/dist/types-Bxu5PAgA.d.ts +710 -0
- package/dist/types-CIlop5Ji.d.ts +701 -0
- package/dist/types-Ca8p_p5X.d.ts +710 -0
- package/package.json +90 -0
- package/src/__tests__/components/data/card.test.ts +458 -0
- package/src/__tests__/components/data/list.test.ts +473 -0
- package/src/__tests__/components/data/metrics.test.ts +541 -0
- package/src/__tests__/components/data/table.test.ts +448 -0
- package/src/__tests__/components/input/field.test.ts +555 -0
- package/src/__tests__/components/input/form.test.ts +870 -0
- package/src/__tests__/components/input/search.test.ts +1238 -0
- package/src/__tests__/components/input/select.test.ts +658 -0
- package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
- package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
- package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
- package/src/__tests__/components/navigation/tabs.test.ts +995 -0
- package/src/__tests__/components.test.tsx +1197 -0
- package/src/__tests__/core/compiler.test.ts +986 -0
- package/src/__tests__/core/parser.test.ts +785 -0
- package/src/__tests__/core/tier-switcher.test.ts +1103 -0
- package/src/__tests__/core/types.test.ts +1398 -0
- package/src/__tests__/data/collections.test.ts +1337 -0
- package/src/__tests__/data/db.test.ts +1265 -0
- package/src/__tests__/data/reactive.test.ts +1010 -0
- package/src/__tests__/data/sync.test.ts +1614 -0
- package/src/__tests__/errors.test.ts +660 -0
- package/src/__tests__/forms/integration.test.ts +444 -0
- package/src/__tests__/integration.test.ts +905 -0
- package/src/__tests__/keyboard.test.ts +1791 -0
- package/src/__tests__/renderer.test.ts +489 -0
- package/src/__tests__/renderers/ansi-css.test.ts +948 -0
- package/src/__tests__/renderers/ansi.test.ts +1366 -0
- package/src/__tests__/renderers/ascii.test.ts +1360 -0
- package/src/__tests__/renderers/interactive.test.ts +2353 -0
- package/src/__tests__/renderers/markdown.test.ts +1483 -0
- package/src/__tests__/renderers/text.test.ts +1369 -0
- package/src/__tests__/renderers/unicode.test.ts +1307 -0
- package/src/__tests__/theme.test.ts +639 -0
- package/src/__tests__/utils/assertions.ts +685 -0
- package/src/__tests__/utils/index.ts +115 -0
- package/src/__tests__/utils/test-renderer.ts +381 -0
- package/src/__tests__/utils/utils.test.ts +560 -0
- package/src/components/containers/card.ts +56 -0
- package/src/components/containers/dialog.ts +53 -0
- package/src/components/containers/index.ts +9 -0
- package/src/components/containers/panel.ts +59 -0
- package/src/components/feedback/badge.ts +40 -0
- package/src/components/feedback/index.ts +8 -0
- package/src/components/feedback/spinner.ts +23 -0
- package/src/components/helpers.ts +81 -0
- package/src/components/index.ts +153 -0
- package/src/components/layout/breadcrumb.ts +31 -0
- package/src/components/layout/index.ts +10 -0
- package/src/components/layout/list.ts +29 -0
- package/src/components/layout/sidebar.ts +79 -0
- package/src/components/layout/table.ts +62 -0
- package/src/components/primitives/box.ts +95 -0
- package/src/components/primitives/button.ts +54 -0
- package/src/components/primitives/index.ts +11 -0
- package/src/components/primitives/input.ts +88 -0
- package/src/components/primitives/select.ts +97 -0
- package/src/components/primitives/text.ts +60 -0
- package/src/components/render.ts +155 -0
- package/src/components/templates/app.ts +43 -0
- package/src/components/templates/index.ts +8 -0
- package/src/components/templates/site.ts +54 -0
- package/src/components/types.ts +777 -0
- package/src/core/compiler.ts +718 -0
- package/src/core/parser.ts +127 -0
- package/src/core/tier-switcher.ts +607 -0
- package/src/core/types.ts +672 -0
- package/src/data/collection.ts +316 -0
- package/src/data/collections.ts +50 -0
- package/src/data/context.tsx +174 -0
- package/src/data/db.ts +127 -0
- package/src/data/hooks.ts +532 -0
- package/src/data/index.ts +138 -0
- package/src/data/reactive.ts +1225 -0
- package/src/data/saas-collections.ts +375 -0
- package/src/data/sync.ts +1213 -0
- package/src/data/types.ts +660 -0
- package/src/forms/converters.ts +512 -0
- package/src/forms/index.ts +133 -0
- package/src/forms/schemas.ts +403 -0
- package/src/forms/types.ts +476 -0
- package/src/index.ts +542 -0
- package/src/keyboard/focus.ts +748 -0
- package/src/keyboard/index.ts +96 -0
- package/src/keyboard/integration.ts +371 -0
- package/src/keyboard/manager.ts +377 -0
- package/src/keyboard/presets.ts +90 -0
- package/src/renderers/ansi-css.ts +576 -0
- package/src/renderers/ansi.ts +802 -0
- package/src/renderers/ascii.ts +680 -0
- package/src/renderers/breadcrumb.ts +480 -0
- package/src/renderers/command-palette.ts +802 -0
- package/src/renderers/components/field.ts +210 -0
- package/src/renderers/components/form.ts +327 -0
- package/src/renderers/components/index.ts +21 -0
- package/src/renderers/components/search.ts +449 -0
- package/src/renderers/components/select.ts +222 -0
- package/src/renderers/index.ts +101 -0
- package/src/renderers/interactive/component-handlers.ts +622 -0
- package/src/renderers/interactive/cursor-manager.ts +147 -0
- package/src/renderers/interactive/focus-manager.ts +279 -0
- package/src/renderers/interactive/index.ts +661 -0
- package/src/renderers/interactive/input-handler.ts +164 -0
- package/src/renderers/interactive/keyboard-handler.ts +212 -0
- package/src/renderers/interactive/mouse-handler.ts +167 -0
- package/src/renderers/interactive/state-manager.ts +109 -0
- package/src/renderers/interactive/types.ts +338 -0
- package/src/renderers/interactive-string.ts +299 -0
- package/src/renderers/interactive.ts +59 -0
- package/src/renderers/markdown.ts +950 -0
- package/src/renderers/sidebar.ts +549 -0
- package/src/renderers/tabs.ts +682 -0
- package/src/renderers/text.ts +791 -0
- package/src/renderers/unicode.ts +917 -0
- package/src/renderers/utils.ts +942 -0
- package/src/router/adapters.ts +383 -0
- package/src/router/types.ts +140 -0
- package/src/router/utils.ts +452 -0
- package/src/schemas.ts +205 -0
- package/src/storybook/index.ts +91 -0
- package/src/storybook/interactive-decorator.tsx +659 -0
- package/src/storybook/keyboard-simulator.ts +501 -0
- package/src/theme/ansi-codes.ts +80 -0
- package/src/theme/box-drawing.ts +132 -0
- package/src/theme/color-convert.ts +254 -0
- package/src/theme/color-support.ts +321 -0
- package/src/theme/index.ts +134 -0
- package/src/theme/strip-ansi.ts +50 -0
- package/src/theme/tailwind-map.ts +469 -0
- package/src/theme/text-styles.ts +206 -0
- package/src/theme/theme-system.ts +568 -0
- package/src/types.ts +103 -0
|
@@ -0,0 +1,2353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mdxui/terminal Interactive Renderer Tests
|
|
3
|
+
*
|
|
4
|
+
* TDD RED Phase: These tests define the contract for the Interactive tier renderer.
|
|
5
|
+
* All tests should FAIL initially because the implementation doesn't exist yet.
|
|
6
|
+
*
|
|
7
|
+
* The Interactive renderer is the highest-capability tier providing:
|
|
8
|
+
* - Full TUI with keyboard navigation
|
|
9
|
+
* - Focus management and Tab cycling
|
|
10
|
+
* - Mouse click support
|
|
11
|
+
* - Cursor positioning
|
|
12
|
+
* - Real-time updates
|
|
13
|
+
* - Input field handling
|
|
14
|
+
* - Component state management
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
19
|
+
import React from 'react'
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Mock Setup
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mock @opentui/react for testing Interactive renderer functionality
|
|
27
|
+
*/
|
|
28
|
+
vi.mock('@opentui/react', () => ({
|
|
29
|
+
createRoot: vi.fn(() => ({
|
|
30
|
+
render: vi.fn(),
|
|
31
|
+
unmount: vi.fn(),
|
|
32
|
+
})),
|
|
33
|
+
useKeyboard: vi.fn(),
|
|
34
|
+
useFocus: vi.fn(() => ({
|
|
35
|
+
isFocused: false,
|
|
36
|
+
focus: vi.fn(),
|
|
37
|
+
blur: vi.fn(),
|
|
38
|
+
})),
|
|
39
|
+
useMouse: vi.fn(() => ({
|
|
40
|
+
onClick: vi.fn(),
|
|
41
|
+
onMouseMove: vi.fn(),
|
|
42
|
+
})),
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mock @opentui/core for renderer creation
|
|
47
|
+
*/
|
|
48
|
+
vi.mock('@opentui/core', () => ({
|
|
49
|
+
createCliRenderer: vi.fn(async () => ({
|
|
50
|
+
width: 80,
|
|
51
|
+
height: 24,
|
|
52
|
+
start: vi.fn(),
|
|
53
|
+
stop: vi.fn(),
|
|
54
|
+
destroy: vi.fn(),
|
|
55
|
+
requestRender: vi.fn(),
|
|
56
|
+
})),
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// renderInteractive Function Tests
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
describe('renderInteractive', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.resetModules()
|
|
66
|
+
vi.clearAllMocks()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.restoreAllMocks()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('basic rendering', () => {
|
|
74
|
+
it('exports renderInteractive function', async () => {
|
|
75
|
+
const { renderInteractive } = await import('../../renderers/interactive')
|
|
76
|
+
|
|
77
|
+
expect(typeof renderInteractive).toBe('function')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('renders a simple UINode to the terminal', async () => {
|
|
81
|
+
const { renderInteractive, createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
82
|
+
|
|
83
|
+
const renderer = await createInteractiveRenderer()
|
|
84
|
+
const node = { type: 'text', props: { children: 'Hello Interactive' } }
|
|
85
|
+
|
|
86
|
+
expect(() => renderInteractive(node, renderer)).not.toThrow()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns string when rendering', async () => {
|
|
90
|
+
const { renderInteractive, createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
91
|
+
|
|
92
|
+
const renderer = await createInteractiveRenderer()
|
|
93
|
+
const node = { type: 'box', props: { children: 'Content' } }
|
|
94
|
+
|
|
95
|
+
const result = renderInteractive(node, renderer)
|
|
96
|
+
expect(typeof result).toBe('string')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('accepts complex nested UINode trees', async () => {
|
|
100
|
+
const { renderInteractive, createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
101
|
+
|
|
102
|
+
const renderer = await createInteractiveRenderer()
|
|
103
|
+
const node = {
|
|
104
|
+
type: 'box',
|
|
105
|
+
props: {
|
|
106
|
+
border: 'single',
|
|
107
|
+
children: [
|
|
108
|
+
{ type: 'text', props: { children: 'Header' } },
|
|
109
|
+
{ type: 'input', props: { placeholder: 'Enter text' } },
|
|
110
|
+
{ type: 'button', props: { children: 'Submit' } },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
expect(() => renderInteractive(node, renderer)).not.toThrow()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('createInteractiveRenderer', () => {
|
|
120
|
+
it('creates an interactive renderer instance', async () => {
|
|
121
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
122
|
+
|
|
123
|
+
const renderer = await createInteractiveRenderer()
|
|
124
|
+
|
|
125
|
+
expect(renderer).toBeDefined()
|
|
126
|
+
expect(typeof renderer.width).toBe('number')
|
|
127
|
+
expect(typeof renderer.height).toBe('number')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('accepts configuration options', async () => {
|
|
131
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
132
|
+
|
|
133
|
+
const renderer = await createInteractiveRenderer({
|
|
134
|
+
width: 120,
|
|
135
|
+
height: 40,
|
|
136
|
+
useMouse: true,
|
|
137
|
+
useAlternateScreen: true,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
expect(renderer).toBeDefined()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('has lifecycle methods', async () => {
|
|
144
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
145
|
+
|
|
146
|
+
const renderer = await createInteractiveRenderer()
|
|
147
|
+
|
|
148
|
+
expect(typeof renderer.start).toBe('function')
|
|
149
|
+
expect(typeof renderer.stop).toBe('function')
|
|
150
|
+
expect(typeof renderer.destroy).toBe('function')
|
|
151
|
+
expect(typeof renderer.requestRender).toBe('function')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('has focus management methods', async () => {
|
|
155
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
156
|
+
|
|
157
|
+
const renderer = await createInteractiveRenderer()
|
|
158
|
+
|
|
159
|
+
expect(typeof renderer.focusNext).toBe('function')
|
|
160
|
+
expect(typeof renderer.focusPrev).toBe('function')
|
|
161
|
+
expect(typeof renderer.focusById).toBe('function')
|
|
162
|
+
expect(typeof renderer.getFocusedId).toBe('function')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('has keyboard management methods', async () => {
|
|
166
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
167
|
+
|
|
168
|
+
const renderer = await createInteractiveRenderer()
|
|
169
|
+
|
|
170
|
+
expect(typeof renderer.onKeyPress).toBe('function')
|
|
171
|
+
expect(typeof renderer.offKeyPress).toBe('function')
|
|
172
|
+
expect(typeof renderer.emitKey).toBe('function')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('has mouse handling methods', async () => {
|
|
176
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
177
|
+
|
|
178
|
+
const renderer = await createInteractiveRenderer()
|
|
179
|
+
|
|
180
|
+
expect(typeof renderer.onClick).toBe('function')
|
|
181
|
+
expect(typeof renderer.offClick).toBe('function')
|
|
182
|
+
expect(typeof renderer.emitClick).toBe('function')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Focus Management Tests
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
describe('Focus Management', () => {
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
vi.resetModules()
|
|
194
|
+
vi.clearAllMocks()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('focus chains', () => {
|
|
198
|
+
it('tracks focusable elements in registration order', async () => {
|
|
199
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
200
|
+
|
|
201
|
+
const renderer = await createInteractiveRenderer()
|
|
202
|
+
|
|
203
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
204
|
+
renderer.registerFocusable('button-1', { tabIndex: 0 })
|
|
205
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
206
|
+
|
|
207
|
+
const focusableIds = renderer.getFocusableIds()
|
|
208
|
+
expect(focusableIds).toEqual(['input-1', 'button-1', 'input-2'])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('respects tabIndex ordering', async () => {
|
|
212
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
213
|
+
|
|
214
|
+
const renderer = await createInteractiveRenderer()
|
|
215
|
+
|
|
216
|
+
renderer.registerFocusable('input-1', { tabIndex: 2 })
|
|
217
|
+
renderer.registerFocusable('button-1', { tabIndex: 0 })
|
|
218
|
+
renderer.registerFocusable('input-2', { tabIndex: 1 })
|
|
219
|
+
|
|
220
|
+
const focusableIds = renderer.getFocusableIds()
|
|
221
|
+
expect(focusableIds).toEqual(['button-1', 'input-2', 'input-1'])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('excludes elements with negative tabIndex', async () => {
|
|
225
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
226
|
+
|
|
227
|
+
const renderer = await createInteractiveRenderer()
|
|
228
|
+
|
|
229
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
230
|
+
renderer.registerFocusable('hidden-1', { tabIndex: -1 })
|
|
231
|
+
renderer.registerFocusable('button-1', { tabIndex: 0 })
|
|
232
|
+
|
|
233
|
+
const focusableIds = renderer.getFocusableIds()
|
|
234
|
+
expect(focusableIds).toEqual(['input-1', 'button-1'])
|
|
235
|
+
expect(focusableIds).not.toContain('hidden-1')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('allows unregistering focusable elements', async () => {
|
|
239
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
240
|
+
|
|
241
|
+
const renderer = await createInteractiveRenderer()
|
|
242
|
+
|
|
243
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
244
|
+
renderer.registerFocusable('button-1', { tabIndex: 0 })
|
|
245
|
+
|
|
246
|
+
renderer.unregisterFocusable('input-1')
|
|
247
|
+
|
|
248
|
+
const focusableIds = renderer.getFocusableIds()
|
|
249
|
+
expect(focusableIds).toEqual(['button-1'])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('handles focus group isolation', async () => {
|
|
253
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
254
|
+
|
|
255
|
+
const renderer = await createInteractiveRenderer()
|
|
256
|
+
|
|
257
|
+
renderer.registerFocusable('main-input', { tabIndex: 0, group: 'main' })
|
|
258
|
+
renderer.registerFocusable('modal-input', { tabIndex: 0, group: 'modal' })
|
|
259
|
+
renderer.registerFocusable('modal-button', { tabIndex: 0, group: 'modal' })
|
|
260
|
+
|
|
261
|
+
renderer.setActiveGroup('modal')
|
|
262
|
+
|
|
263
|
+
const focusableIds = renderer.getFocusableIds()
|
|
264
|
+
expect(focusableIds).toEqual(['modal-input', 'modal-button'])
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('Tab navigation', () => {
|
|
269
|
+
it('Tab moves focus to next element', async () => {
|
|
270
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
271
|
+
|
|
272
|
+
const renderer = await createInteractiveRenderer()
|
|
273
|
+
|
|
274
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
275
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
276
|
+
renderer.registerFocusable('button-1', { tabIndex: 0 })
|
|
277
|
+
|
|
278
|
+
renderer.focusById('input-1')
|
|
279
|
+
expect(renderer.getFocusedId()).toBe('input-1')
|
|
280
|
+
|
|
281
|
+
renderer.focusNext()
|
|
282
|
+
expect(renderer.getFocusedId()).toBe('input-2')
|
|
283
|
+
|
|
284
|
+
renderer.focusNext()
|
|
285
|
+
expect(renderer.getFocusedId()).toBe('button-1')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('Shift+Tab moves focus to previous element', async () => {
|
|
289
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
290
|
+
|
|
291
|
+
const renderer = await createInteractiveRenderer()
|
|
292
|
+
|
|
293
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
294
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
295
|
+
renderer.registerFocusable('button-1', { tabIndex: 0 })
|
|
296
|
+
|
|
297
|
+
renderer.focusById('button-1')
|
|
298
|
+
expect(renderer.getFocusedId()).toBe('button-1')
|
|
299
|
+
|
|
300
|
+
renderer.focusPrev()
|
|
301
|
+
expect(renderer.getFocusedId()).toBe('input-2')
|
|
302
|
+
|
|
303
|
+
renderer.focusPrev()
|
|
304
|
+
expect(renderer.getFocusedId()).toBe('input-1')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('wraps focus from last to first element', async () => {
|
|
308
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
309
|
+
|
|
310
|
+
const renderer = await createInteractiveRenderer({ wrapFocus: true })
|
|
311
|
+
|
|
312
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
313
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
314
|
+
|
|
315
|
+
renderer.focusById('input-2')
|
|
316
|
+
renderer.focusNext()
|
|
317
|
+
|
|
318
|
+
expect(renderer.getFocusedId()).toBe('input-1')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('wraps focus from first to last element', async () => {
|
|
322
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
323
|
+
|
|
324
|
+
const renderer = await createInteractiveRenderer({ wrapFocus: true })
|
|
325
|
+
|
|
326
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
327
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
328
|
+
|
|
329
|
+
renderer.focusById('input-1')
|
|
330
|
+
renderer.focusPrev()
|
|
331
|
+
|
|
332
|
+
expect(renderer.getFocusedId()).toBe('input-2')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('does not wrap focus when wrapFocus is false', async () => {
|
|
336
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
337
|
+
|
|
338
|
+
const renderer = await createInteractiveRenderer({ wrapFocus: false })
|
|
339
|
+
|
|
340
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
341
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
342
|
+
|
|
343
|
+
renderer.focusById('input-2')
|
|
344
|
+
renderer.focusNext()
|
|
345
|
+
|
|
346
|
+
// Should stay on last element
|
|
347
|
+
expect(renderer.getFocusedId()).toBe('input-2')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('focuses first element on initial Tab when nothing focused', async () => {
|
|
351
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
352
|
+
|
|
353
|
+
const renderer = await createInteractiveRenderer()
|
|
354
|
+
|
|
355
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
356
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
357
|
+
|
|
358
|
+
expect(renderer.getFocusedId()).toBeNull()
|
|
359
|
+
|
|
360
|
+
renderer.focusNext()
|
|
361
|
+
expect(renderer.getFocusedId()).toBe('input-1')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('integrates with keyboard manager for Tab handling', async () => {
|
|
365
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
366
|
+
|
|
367
|
+
const renderer = await createInteractiveRenderer()
|
|
368
|
+
|
|
369
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
370
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
371
|
+
|
|
372
|
+
renderer.focusById('input-1')
|
|
373
|
+
|
|
374
|
+
// Simulate Tab keypress
|
|
375
|
+
renderer.emitKey('tab')
|
|
376
|
+
|
|
377
|
+
expect(renderer.getFocusedId()).toBe('input-2')
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('integrates with keyboard manager for Shift+Tab handling', async () => {
|
|
381
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
382
|
+
|
|
383
|
+
const renderer = await createInteractiveRenderer()
|
|
384
|
+
|
|
385
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
386
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
387
|
+
|
|
388
|
+
renderer.focusById('input-2')
|
|
389
|
+
|
|
390
|
+
// Simulate Shift+Tab keypress
|
|
391
|
+
renderer.emitKey('shift+tab')
|
|
392
|
+
|
|
393
|
+
expect(renderer.getFocusedId()).toBe('input-1')
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
describe('focus trapping', () => {
|
|
398
|
+
it('traps focus within a container', async () => {
|
|
399
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
400
|
+
|
|
401
|
+
const renderer = await createInteractiveRenderer()
|
|
402
|
+
|
|
403
|
+
// Register elements outside trap
|
|
404
|
+
renderer.registerFocusable('outside-1', { tabIndex: 0 })
|
|
405
|
+
|
|
406
|
+
// Create a focus trap
|
|
407
|
+
renderer.pushFocusTrap('modal')
|
|
408
|
+
|
|
409
|
+
// Register elements inside trap
|
|
410
|
+
renderer.registerFocusable('modal-input', { tabIndex: 0, group: 'modal' })
|
|
411
|
+
renderer.registerFocusable('modal-button', { tabIndex: 0, group: 'modal' })
|
|
412
|
+
|
|
413
|
+
renderer.focusById('modal-button')
|
|
414
|
+
renderer.focusNext()
|
|
415
|
+
|
|
416
|
+
// Should wrap within the trap, not escape to outside-1
|
|
417
|
+
expect(renderer.getFocusedId()).toBe('modal-input')
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('restores focus when trap is removed', async () => {
|
|
421
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
422
|
+
|
|
423
|
+
const renderer = await createInteractiveRenderer()
|
|
424
|
+
|
|
425
|
+
renderer.registerFocusable('main-input', { tabIndex: 0 })
|
|
426
|
+
renderer.focusById('main-input')
|
|
427
|
+
|
|
428
|
+
// Push a modal trap
|
|
429
|
+
renderer.pushFocusTrap('modal')
|
|
430
|
+
renderer.registerFocusable('modal-input', { tabIndex: 0, group: 'modal' })
|
|
431
|
+
renderer.focusById('modal-input')
|
|
432
|
+
|
|
433
|
+
// Pop the trap
|
|
434
|
+
renderer.popFocusTrap()
|
|
435
|
+
|
|
436
|
+
// Focus should restore to main-input
|
|
437
|
+
expect(renderer.getFocusedId()).toBe('main-input')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('supports nested focus traps', async () => {
|
|
441
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
442
|
+
|
|
443
|
+
const renderer = await createInteractiveRenderer()
|
|
444
|
+
|
|
445
|
+
renderer.registerFocusable('main-input', { tabIndex: 0 })
|
|
446
|
+
renderer.focusById('main-input')
|
|
447
|
+
|
|
448
|
+
// First modal
|
|
449
|
+
renderer.pushFocusTrap('modal-1')
|
|
450
|
+
renderer.registerFocusable('modal1-input', { tabIndex: 0, group: 'modal-1' })
|
|
451
|
+
renderer.focusById('modal1-input')
|
|
452
|
+
|
|
453
|
+
// Nested modal
|
|
454
|
+
renderer.pushFocusTrap('modal-2')
|
|
455
|
+
renderer.registerFocusable('modal2-input', { tabIndex: 0, group: 'modal-2' })
|
|
456
|
+
renderer.focusById('modal2-input')
|
|
457
|
+
|
|
458
|
+
// Pop inner trap
|
|
459
|
+
renderer.popFocusTrap()
|
|
460
|
+
expect(renderer.getFocusedId()).toBe('modal1-input')
|
|
461
|
+
|
|
462
|
+
// Pop outer trap
|
|
463
|
+
renderer.popFocusTrap()
|
|
464
|
+
expect(renderer.getFocusedId()).toBe('main-input')
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
describe('focus events', () => {
|
|
469
|
+
it('emits focus event when element gains focus', async () => {
|
|
470
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
471
|
+
|
|
472
|
+
const renderer = await createInteractiveRenderer()
|
|
473
|
+
const onFocus = vi.fn()
|
|
474
|
+
|
|
475
|
+
renderer.registerFocusable('input-1', { tabIndex: 0, onFocus })
|
|
476
|
+
renderer.focusById('input-1')
|
|
477
|
+
|
|
478
|
+
expect(onFocus).toHaveBeenCalledWith({ id: 'input-1' })
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('emits blur event when element loses focus', async () => {
|
|
482
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
483
|
+
|
|
484
|
+
const renderer = await createInteractiveRenderer()
|
|
485
|
+
const onBlur = vi.fn()
|
|
486
|
+
|
|
487
|
+
renderer.registerFocusable('input-1', { tabIndex: 0, onBlur })
|
|
488
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
489
|
+
|
|
490
|
+
renderer.focusById('input-1')
|
|
491
|
+
renderer.focusById('input-2')
|
|
492
|
+
|
|
493
|
+
expect(onBlur).toHaveBeenCalledWith({ id: 'input-1' })
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('does not emit events on redundant focus calls', async () => {
|
|
497
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
498
|
+
|
|
499
|
+
const renderer = await createInteractiveRenderer()
|
|
500
|
+
const onFocus = vi.fn()
|
|
501
|
+
|
|
502
|
+
renderer.registerFocusable('input-1', { tabIndex: 0, onFocus })
|
|
503
|
+
renderer.focusById('input-1')
|
|
504
|
+
renderer.focusById('input-1')
|
|
505
|
+
|
|
506
|
+
expect(onFocus).toHaveBeenCalledTimes(1)
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// ============================================================================
|
|
512
|
+
// Keyboard Event Handling Tests
|
|
513
|
+
// ============================================================================
|
|
514
|
+
|
|
515
|
+
describe('Keyboard Event Handling', () => {
|
|
516
|
+
beforeEach(() => {
|
|
517
|
+
vi.resetModules()
|
|
518
|
+
vi.clearAllMocks()
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
describe('hotkey registration', () => {
|
|
522
|
+
it('registers global hotkeys', async () => {
|
|
523
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
524
|
+
|
|
525
|
+
const renderer = await createInteractiveRenderer()
|
|
526
|
+
const handler = vi.fn()
|
|
527
|
+
|
|
528
|
+
renderer.onKeyPress('ctrl+s', handler)
|
|
529
|
+
renderer.emitKey('ctrl+s')
|
|
530
|
+
|
|
531
|
+
expect(handler).toHaveBeenCalled()
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('unregisters hotkeys', async () => {
|
|
535
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
536
|
+
|
|
537
|
+
const renderer = await createInteractiveRenderer()
|
|
538
|
+
const handler = vi.fn()
|
|
539
|
+
|
|
540
|
+
renderer.onKeyPress('ctrl+s', handler)
|
|
541
|
+
renderer.offKeyPress('ctrl+s', handler)
|
|
542
|
+
renderer.emitKey('ctrl+s')
|
|
543
|
+
|
|
544
|
+
expect(handler).not.toHaveBeenCalled()
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('supports multiple handlers for same key', async () => {
|
|
548
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
549
|
+
|
|
550
|
+
const renderer = await createInteractiveRenderer()
|
|
551
|
+
const handler1 = vi.fn()
|
|
552
|
+
const handler2 = vi.fn()
|
|
553
|
+
|
|
554
|
+
renderer.onKeyPress('ctrl+s', handler1)
|
|
555
|
+
renderer.onKeyPress('ctrl+s', handler2)
|
|
556
|
+
renderer.emitKey('ctrl+s')
|
|
557
|
+
|
|
558
|
+
expect(handler1).toHaveBeenCalled()
|
|
559
|
+
expect(handler2).toHaveBeenCalled()
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('allows stopping propagation', async () => {
|
|
563
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
564
|
+
|
|
565
|
+
const renderer = await createInteractiveRenderer()
|
|
566
|
+
const handler1 = vi.fn(() => true) // Returns true to stop propagation
|
|
567
|
+
const handler2 = vi.fn()
|
|
568
|
+
|
|
569
|
+
renderer.onKeyPress('ctrl+s', handler1, { priority: 10 })
|
|
570
|
+
renderer.onKeyPress('ctrl+s', handler2, { priority: 0 })
|
|
571
|
+
renderer.emitKey('ctrl+s')
|
|
572
|
+
|
|
573
|
+
expect(handler1).toHaveBeenCalled()
|
|
574
|
+
expect(handler2).not.toHaveBeenCalled()
|
|
575
|
+
})
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
describe('vim bindings', () => {
|
|
579
|
+
it('supports j/k for vertical navigation', async () => {
|
|
580
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
581
|
+
|
|
582
|
+
const renderer = await createInteractiveRenderer({ vimBindings: true })
|
|
583
|
+
|
|
584
|
+
renderer.registerFocusable('item-1', { tabIndex: 0 })
|
|
585
|
+
renderer.registerFocusable('item-2', { tabIndex: 0 })
|
|
586
|
+
renderer.registerFocusable('item-3', { tabIndex: 0 })
|
|
587
|
+
|
|
588
|
+
renderer.focusById('item-1')
|
|
589
|
+
|
|
590
|
+
renderer.emitKey('j')
|
|
591
|
+
expect(renderer.getFocusedId()).toBe('item-2')
|
|
592
|
+
|
|
593
|
+
renderer.emitKey('j')
|
|
594
|
+
expect(renderer.getFocusedId()).toBe('item-3')
|
|
595
|
+
|
|
596
|
+
renderer.emitKey('k')
|
|
597
|
+
expect(renderer.getFocusedId()).toBe('item-2')
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('supports h/l for horizontal navigation', async () => {
|
|
601
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
602
|
+
|
|
603
|
+
const renderer = await createInteractiveRenderer({ vimBindings: true })
|
|
604
|
+
|
|
605
|
+
renderer.registerFocusable('col-1', { tabIndex: 0, column: 0 })
|
|
606
|
+
renderer.registerFocusable('col-2', { tabIndex: 0, column: 1 })
|
|
607
|
+
renderer.registerFocusable('col-3', { tabIndex: 0, column: 2 })
|
|
608
|
+
|
|
609
|
+
renderer.focusById('col-1')
|
|
610
|
+
|
|
611
|
+
renderer.emitKey('l')
|
|
612
|
+
expect(renderer.getFocusedId()).toBe('col-2')
|
|
613
|
+
|
|
614
|
+
renderer.emitKey('l')
|
|
615
|
+
expect(renderer.getFocusedId()).toBe('col-3')
|
|
616
|
+
|
|
617
|
+
renderer.emitKey('h')
|
|
618
|
+
expect(renderer.getFocusedId()).toBe('col-2')
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it('supports gg to jump to first item', async () => {
|
|
622
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
623
|
+
|
|
624
|
+
const renderer = await createInteractiveRenderer({ vimBindings: true })
|
|
625
|
+
|
|
626
|
+
renderer.registerFocusable('item-1', { tabIndex: 0 })
|
|
627
|
+
renderer.registerFocusable('item-2', { tabIndex: 0 })
|
|
628
|
+
renderer.registerFocusable('item-3', { tabIndex: 0 })
|
|
629
|
+
|
|
630
|
+
renderer.focusById('item-3')
|
|
631
|
+
|
|
632
|
+
renderer.emitKey('g')
|
|
633
|
+
renderer.emitKey('g')
|
|
634
|
+
|
|
635
|
+
expect(renderer.getFocusedId()).toBe('item-1')
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('supports G to jump to last item', async () => {
|
|
639
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
640
|
+
|
|
641
|
+
const renderer = await createInteractiveRenderer({ vimBindings: true })
|
|
642
|
+
|
|
643
|
+
renderer.registerFocusable('item-1', { tabIndex: 0 })
|
|
644
|
+
renderer.registerFocusable('item-2', { tabIndex: 0 })
|
|
645
|
+
renderer.registerFocusable('item-3', { tabIndex: 0 })
|
|
646
|
+
|
|
647
|
+
renderer.focusById('item-1')
|
|
648
|
+
|
|
649
|
+
renderer.emitKey('G')
|
|
650
|
+
|
|
651
|
+
expect(renderer.getFocusedId()).toBe('item-3')
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('supports / for search mode', async () => {
|
|
655
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
656
|
+
|
|
657
|
+
const renderer = await createInteractiveRenderer({ vimBindings: true })
|
|
658
|
+
const onSearchMode = vi.fn()
|
|
659
|
+
|
|
660
|
+
renderer.onSearchMode(onSearchMode)
|
|
661
|
+
renderer.emitKey('/')
|
|
662
|
+
|
|
663
|
+
expect(onSearchMode).toHaveBeenCalled()
|
|
664
|
+
expect(renderer.getMode()).toBe('search')
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('supports Escape to exit modes', async () => {
|
|
668
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
669
|
+
|
|
670
|
+
const renderer = await createInteractiveRenderer({ vimBindings: true })
|
|
671
|
+
|
|
672
|
+
renderer.emitKey('/')
|
|
673
|
+
expect(renderer.getMode()).toBe('search')
|
|
674
|
+
|
|
675
|
+
renderer.emitKey('escape')
|
|
676
|
+
expect(renderer.getMode()).toBe('normal')
|
|
677
|
+
})
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
describe('arrow keys', () => {
|
|
681
|
+
it('supports arrow keys for navigation', async () => {
|
|
682
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
683
|
+
|
|
684
|
+
const renderer = await createInteractiveRenderer()
|
|
685
|
+
|
|
686
|
+
renderer.registerFocusable('item-1', { tabIndex: 0 })
|
|
687
|
+
renderer.registerFocusable('item-2', { tabIndex: 0 })
|
|
688
|
+
|
|
689
|
+
renderer.focusById('item-1')
|
|
690
|
+
|
|
691
|
+
renderer.emitKey('down')
|
|
692
|
+
expect(renderer.getFocusedId()).toBe('item-2')
|
|
693
|
+
|
|
694
|
+
renderer.emitKey('up')
|
|
695
|
+
expect(renderer.getFocusedId()).toBe('item-1')
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('supports left/right arrow keys', async () => {
|
|
699
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
700
|
+
|
|
701
|
+
const renderer = await createInteractiveRenderer()
|
|
702
|
+
|
|
703
|
+
renderer.registerFocusable('tab-1', { tabIndex: 0 })
|
|
704
|
+
renderer.registerFocusable('tab-2', { tabIndex: 0 })
|
|
705
|
+
|
|
706
|
+
renderer.focusById('tab-1')
|
|
707
|
+
|
|
708
|
+
renderer.emitKey('right')
|
|
709
|
+
expect(renderer.getFocusedId()).toBe('tab-2')
|
|
710
|
+
|
|
711
|
+
renderer.emitKey('left')
|
|
712
|
+
expect(renderer.getFocusedId()).toBe('tab-1')
|
|
713
|
+
})
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
describe('selection and activation', () => {
|
|
717
|
+
it('Enter activates focused element', async () => {
|
|
718
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
719
|
+
|
|
720
|
+
const renderer = await createInteractiveRenderer()
|
|
721
|
+
const onActivate = vi.fn()
|
|
722
|
+
|
|
723
|
+
renderer.registerFocusable('button-1', { tabIndex: 0, onActivate })
|
|
724
|
+
renderer.focusById('button-1')
|
|
725
|
+
renderer.emitKey('enter')
|
|
726
|
+
|
|
727
|
+
expect(onActivate).toHaveBeenCalledWith({ id: 'button-1' })
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
it('Space toggles focused element', async () => {
|
|
731
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
732
|
+
|
|
733
|
+
const renderer = await createInteractiveRenderer()
|
|
734
|
+
const onToggle = vi.fn()
|
|
735
|
+
|
|
736
|
+
renderer.registerFocusable('checkbox-1', { tabIndex: 0, onToggle })
|
|
737
|
+
renderer.focusById('checkbox-1')
|
|
738
|
+
renderer.emitKey('space')
|
|
739
|
+
|
|
740
|
+
expect(onToggle).toHaveBeenCalledWith({ id: 'checkbox-1' })
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it('Escape cancels current action', async () => {
|
|
744
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
745
|
+
|
|
746
|
+
const renderer = await createInteractiveRenderer()
|
|
747
|
+
const onCancel = vi.fn()
|
|
748
|
+
|
|
749
|
+
renderer.onCancel(onCancel)
|
|
750
|
+
renderer.emitKey('escape')
|
|
751
|
+
|
|
752
|
+
expect(onCancel).toHaveBeenCalled()
|
|
753
|
+
})
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
describe('modifier key combinations', () => {
|
|
757
|
+
it('handles Ctrl+C', async () => {
|
|
758
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
759
|
+
|
|
760
|
+
const renderer = await createInteractiveRenderer()
|
|
761
|
+
const handler = vi.fn()
|
|
762
|
+
|
|
763
|
+
renderer.onKeyPress('ctrl+c', handler)
|
|
764
|
+
renderer.emitKey('ctrl+c')
|
|
765
|
+
|
|
766
|
+
expect(handler).toHaveBeenCalled()
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('handles Alt+key combinations', async () => {
|
|
770
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
771
|
+
|
|
772
|
+
const renderer = await createInteractiveRenderer()
|
|
773
|
+
const handler = vi.fn()
|
|
774
|
+
|
|
775
|
+
renderer.onKeyPress('alt+h', handler)
|
|
776
|
+
renderer.emitKey('alt+h')
|
|
777
|
+
|
|
778
|
+
expect(handler).toHaveBeenCalled()
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('handles Ctrl+Shift combinations', async () => {
|
|
782
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
783
|
+
|
|
784
|
+
const renderer = await createInteractiveRenderer()
|
|
785
|
+
const handler = vi.fn()
|
|
786
|
+
|
|
787
|
+
renderer.onKeyPress('ctrl+shift+s', handler)
|
|
788
|
+
renderer.emitKey('ctrl+shift+s')
|
|
789
|
+
|
|
790
|
+
expect(handler).toHaveBeenCalled()
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
it('handles function keys', async () => {
|
|
794
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
795
|
+
|
|
796
|
+
const renderer = await createInteractiveRenderer()
|
|
797
|
+
const handler = vi.fn()
|
|
798
|
+
|
|
799
|
+
renderer.onKeyPress('f1', handler)
|
|
800
|
+
renderer.emitKey('f1')
|
|
801
|
+
|
|
802
|
+
expect(handler).toHaveBeenCalled()
|
|
803
|
+
})
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
describe('key sequence support', () => {
|
|
807
|
+
it('handles multi-key sequences', async () => {
|
|
808
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
809
|
+
|
|
810
|
+
const renderer = await createInteractiveRenderer()
|
|
811
|
+
const handler = vi.fn()
|
|
812
|
+
|
|
813
|
+
renderer.onKeySequence('dd', handler)
|
|
814
|
+
|
|
815
|
+
renderer.emitKey('d')
|
|
816
|
+
expect(handler).not.toHaveBeenCalled()
|
|
817
|
+
|
|
818
|
+
renderer.emitKey('d')
|
|
819
|
+
expect(handler).toHaveBeenCalled()
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
it('times out pending sequences', async () => {
|
|
823
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
824
|
+
|
|
825
|
+
const renderer = await createInteractiveRenderer({ sequenceTimeout: 100 })
|
|
826
|
+
const handler = vi.fn()
|
|
827
|
+
|
|
828
|
+
renderer.onKeySequence('dd', handler)
|
|
829
|
+
|
|
830
|
+
renderer.emitKey('d')
|
|
831
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
832
|
+
renderer.emitKey('d')
|
|
833
|
+
|
|
834
|
+
// Second 'd' starts a new sequence, not completing 'dd'
|
|
835
|
+
expect(handler).not.toHaveBeenCalled()
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
it('reports pending sequence state', async () => {
|
|
839
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
840
|
+
|
|
841
|
+
const renderer = await createInteractiveRenderer()
|
|
842
|
+
|
|
843
|
+
renderer.onKeySequence('daw', vi.fn())
|
|
844
|
+
|
|
845
|
+
expect(renderer.getPendingSequence()).toBe('')
|
|
846
|
+
|
|
847
|
+
renderer.emitKey('d')
|
|
848
|
+
expect(renderer.getPendingSequence()).toBe('d')
|
|
849
|
+
|
|
850
|
+
renderer.emitKey('a')
|
|
851
|
+
expect(renderer.getPendingSequence()).toBe('da')
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
it('cancels pending sequence on Escape', async () => {
|
|
855
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
856
|
+
|
|
857
|
+
const renderer = await createInteractiveRenderer()
|
|
858
|
+
|
|
859
|
+
renderer.onKeySequence('dd', vi.fn())
|
|
860
|
+
|
|
861
|
+
renderer.emitKey('d')
|
|
862
|
+
expect(renderer.getPendingSequence()).toBe('d')
|
|
863
|
+
|
|
864
|
+
renderer.emitKey('escape')
|
|
865
|
+
expect(renderer.getPendingSequence()).toBe('')
|
|
866
|
+
})
|
|
867
|
+
})
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
// ============================================================================
|
|
871
|
+
// Mouse Click Area Tests
|
|
872
|
+
// ============================================================================
|
|
873
|
+
|
|
874
|
+
describe('Mouse Click Areas', () => {
|
|
875
|
+
beforeEach(() => {
|
|
876
|
+
vi.resetModules()
|
|
877
|
+
vi.clearAllMocks()
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
describe('click registration', () => {
|
|
881
|
+
it('registers clickable areas', async () => {
|
|
882
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
883
|
+
|
|
884
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
885
|
+
|
|
886
|
+
renderer.registerClickable('button-1', {
|
|
887
|
+
x: 10,
|
|
888
|
+
y: 5,
|
|
889
|
+
width: 20,
|
|
890
|
+
height: 3,
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
const clickables = renderer.getClickableAreas()
|
|
894
|
+
expect(clickables).toHaveLength(1)
|
|
895
|
+
expect(clickables[0].id).toBe('button-1')
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
it('handles click within registered area', async () => {
|
|
899
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
900
|
+
|
|
901
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
902
|
+
const onClick = vi.fn()
|
|
903
|
+
|
|
904
|
+
renderer.registerClickable('button-1', {
|
|
905
|
+
x: 10,
|
|
906
|
+
y: 5,
|
|
907
|
+
width: 20,
|
|
908
|
+
height: 3,
|
|
909
|
+
onClick,
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
renderer.emitClick(15, 6) // Within bounds
|
|
913
|
+
|
|
914
|
+
expect(onClick).toHaveBeenCalledWith({ x: 15, y: 6, id: 'button-1' })
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
it('ignores click outside registered areas', async () => {
|
|
918
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
919
|
+
|
|
920
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
921
|
+
const onClick = vi.fn()
|
|
922
|
+
|
|
923
|
+
renderer.registerClickable('button-1', {
|
|
924
|
+
x: 10,
|
|
925
|
+
y: 5,
|
|
926
|
+
width: 20,
|
|
927
|
+
height: 3,
|
|
928
|
+
onClick,
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
renderer.emitClick(5, 2) // Outside bounds
|
|
932
|
+
|
|
933
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('handles overlapping click areas with z-order', async () => {
|
|
937
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
938
|
+
|
|
939
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
940
|
+
const onClick1 = vi.fn()
|
|
941
|
+
const onClick2 = vi.fn()
|
|
942
|
+
|
|
943
|
+
renderer.registerClickable('back', {
|
|
944
|
+
x: 0,
|
|
945
|
+
y: 0,
|
|
946
|
+
width: 40,
|
|
947
|
+
height: 20,
|
|
948
|
+
zIndex: 0,
|
|
949
|
+
onClick: onClick1,
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
renderer.registerClickable('front', {
|
|
953
|
+
x: 10,
|
|
954
|
+
y: 5,
|
|
955
|
+
width: 20,
|
|
956
|
+
height: 10,
|
|
957
|
+
zIndex: 1,
|
|
958
|
+
onClick: onClick2,
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
renderer.emitClick(15, 8) // In overlapping area
|
|
962
|
+
|
|
963
|
+
expect(onClick2).toHaveBeenCalled()
|
|
964
|
+
expect(onClick1).not.toHaveBeenCalled()
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
it('unregisters clickable areas', async () => {
|
|
968
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
969
|
+
|
|
970
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
971
|
+
const onClick = vi.fn()
|
|
972
|
+
|
|
973
|
+
renderer.registerClickable('button-1', {
|
|
974
|
+
x: 10,
|
|
975
|
+
y: 5,
|
|
976
|
+
width: 20,
|
|
977
|
+
height: 3,
|
|
978
|
+
onClick,
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
renderer.unregisterClickable('button-1')
|
|
982
|
+
renderer.emitClick(15, 6)
|
|
983
|
+
|
|
984
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
985
|
+
})
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
describe('click-to-focus', () => {
|
|
989
|
+
it('focuses element on click', async () => {
|
|
990
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
991
|
+
|
|
992
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
993
|
+
|
|
994
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
995
|
+
renderer.registerClickable('input-1', {
|
|
996
|
+
x: 10,
|
|
997
|
+
y: 5,
|
|
998
|
+
width: 20,
|
|
999
|
+
height: 1,
|
|
1000
|
+
focusable: true,
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
renderer.emitClick(15, 5)
|
|
1004
|
+
|
|
1005
|
+
expect(renderer.getFocusedId()).toBe('input-1')
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('allows opting out of click-to-focus', async () => {
|
|
1009
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1010
|
+
|
|
1011
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
1012
|
+
|
|
1013
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
1014
|
+
renderer.registerClickable('input-1', {
|
|
1015
|
+
x: 10,
|
|
1016
|
+
y: 5,
|
|
1017
|
+
width: 20,
|
|
1018
|
+
height: 1,
|
|
1019
|
+
focusable: false,
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
renderer.emitClick(15, 5)
|
|
1023
|
+
|
|
1024
|
+
expect(renderer.getFocusedId()).toBeNull()
|
|
1025
|
+
})
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
describe('mouse events', () => {
|
|
1029
|
+
it('handles double-click', async () => {
|
|
1030
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1031
|
+
|
|
1032
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
1033
|
+
const onDoubleClick = vi.fn()
|
|
1034
|
+
|
|
1035
|
+
renderer.registerClickable('text-1', {
|
|
1036
|
+
x: 10,
|
|
1037
|
+
y: 5,
|
|
1038
|
+
width: 20,
|
|
1039
|
+
height: 1,
|
|
1040
|
+
onDoubleClick,
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
renderer.emitClick(15, 5, { clickCount: 2 })
|
|
1044
|
+
|
|
1045
|
+
expect(onDoubleClick).toHaveBeenCalled()
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
it('handles right-click', async () => {
|
|
1049
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1050
|
+
|
|
1051
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
1052
|
+
const onRightClick = vi.fn()
|
|
1053
|
+
|
|
1054
|
+
renderer.registerClickable('item-1', {
|
|
1055
|
+
x: 10,
|
|
1056
|
+
y: 5,
|
|
1057
|
+
width: 20,
|
|
1058
|
+
height: 1,
|
|
1059
|
+
onRightClick,
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
renderer.emitClick(15, 5, { button: 'right' })
|
|
1063
|
+
|
|
1064
|
+
expect(onRightClick).toHaveBeenCalled()
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
it('handles mouse wheel', async () => {
|
|
1068
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1069
|
+
|
|
1070
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
1071
|
+
const onScroll = vi.fn()
|
|
1072
|
+
|
|
1073
|
+
renderer.onScroll(onScroll)
|
|
1074
|
+
renderer.emitScroll(15, 5, { deltaY: -1 }) // Scroll up
|
|
1075
|
+
|
|
1076
|
+
expect(onScroll).toHaveBeenCalledWith(expect.objectContaining({ deltaY: -1 }))
|
|
1077
|
+
})
|
|
1078
|
+
})
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
// ============================================================================
|
|
1082
|
+
// Cursor Positioning Tests
|
|
1083
|
+
// ============================================================================
|
|
1084
|
+
|
|
1085
|
+
describe('Cursor Positioning', () => {
|
|
1086
|
+
beforeEach(() => {
|
|
1087
|
+
vi.resetModules()
|
|
1088
|
+
vi.clearAllMocks()
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
describe('cursor state', () => {
|
|
1092
|
+
it('tracks cursor position', async () => {
|
|
1093
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1094
|
+
|
|
1095
|
+
const renderer = await createInteractiveRenderer()
|
|
1096
|
+
|
|
1097
|
+
renderer.setCursorPosition(10, 5)
|
|
1098
|
+
|
|
1099
|
+
expect(renderer.getCursorPosition()).toEqual({ x: 10, y: 5 })
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
it('supports cursor visibility', async () => {
|
|
1103
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1104
|
+
|
|
1105
|
+
const renderer = await createInteractiveRenderer()
|
|
1106
|
+
|
|
1107
|
+
expect(renderer.isCursorVisible()).toBe(true)
|
|
1108
|
+
|
|
1109
|
+
renderer.hideCursor()
|
|
1110
|
+
expect(renderer.isCursorVisible()).toBe(false)
|
|
1111
|
+
|
|
1112
|
+
renderer.showCursor()
|
|
1113
|
+
expect(renderer.isCursorVisible()).toBe(true)
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
it('supports cursor styles', async () => {
|
|
1117
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1118
|
+
|
|
1119
|
+
const renderer = await createInteractiveRenderer()
|
|
1120
|
+
|
|
1121
|
+
renderer.setCursorStyle('block')
|
|
1122
|
+
expect(renderer.getCursorStyle()).toBe('block')
|
|
1123
|
+
|
|
1124
|
+
renderer.setCursorStyle('underline')
|
|
1125
|
+
expect(renderer.getCursorStyle()).toBe('underline')
|
|
1126
|
+
|
|
1127
|
+
renderer.setCursorStyle('bar')
|
|
1128
|
+
expect(renderer.getCursorStyle()).toBe('bar')
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
it('supports cursor blinking', async () => {
|
|
1132
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1133
|
+
|
|
1134
|
+
const renderer = await createInteractiveRenderer()
|
|
1135
|
+
|
|
1136
|
+
renderer.setCursorBlink(true)
|
|
1137
|
+
expect(renderer.isCursorBlinking()).toBe(true)
|
|
1138
|
+
|
|
1139
|
+
renderer.setCursorBlink(false)
|
|
1140
|
+
expect(renderer.isCursorBlinking()).toBe(false)
|
|
1141
|
+
})
|
|
1142
|
+
})
|
|
1143
|
+
|
|
1144
|
+
describe('cursor in input fields', () => {
|
|
1145
|
+
it('moves cursor to focused input', async () => {
|
|
1146
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1147
|
+
|
|
1148
|
+
const renderer = await createInteractiveRenderer()
|
|
1149
|
+
|
|
1150
|
+
renderer.registerFocusable('input-1', {
|
|
1151
|
+
tabIndex: 0,
|
|
1152
|
+
cursorPosition: { x: 15, y: 3 },
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
renderer.focusById('input-1')
|
|
1156
|
+
|
|
1157
|
+
expect(renderer.getCursorPosition()).toEqual({ x: 15, y: 3 })
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
it('updates cursor position on text input', async () => {
|
|
1161
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1162
|
+
|
|
1163
|
+
const renderer = await createInteractiveRenderer()
|
|
1164
|
+
|
|
1165
|
+
renderer.registerFocusable('input-1', {
|
|
1166
|
+
tabIndex: 0,
|
|
1167
|
+
cursorPosition: { x: 15, y: 3 },
|
|
1168
|
+
})
|
|
1169
|
+
renderer.focusById('input-1')
|
|
1170
|
+
|
|
1171
|
+
// Simulate typing
|
|
1172
|
+
renderer.updateCursorPosition('input-1', { x: 20, y: 3 })
|
|
1173
|
+
|
|
1174
|
+
expect(renderer.getCursorPosition()).toEqual({ x: 20, y: 3 })
|
|
1175
|
+
})
|
|
1176
|
+
|
|
1177
|
+
it('hides cursor when non-input is focused', async () => {
|
|
1178
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1179
|
+
|
|
1180
|
+
const renderer = await createInteractiveRenderer()
|
|
1181
|
+
|
|
1182
|
+
renderer.registerFocusable('input-1', {
|
|
1183
|
+
tabIndex: 0,
|
|
1184
|
+
cursorPosition: { x: 15, y: 3 },
|
|
1185
|
+
showCursor: true,
|
|
1186
|
+
})
|
|
1187
|
+
renderer.registerFocusable('button-1', {
|
|
1188
|
+
tabIndex: 0,
|
|
1189
|
+
showCursor: false,
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
renderer.focusById('input-1')
|
|
1193
|
+
expect(renderer.isCursorVisible()).toBe(true)
|
|
1194
|
+
|
|
1195
|
+
renderer.focusById('button-1')
|
|
1196
|
+
expect(renderer.isCursorVisible()).toBe(false)
|
|
1197
|
+
})
|
|
1198
|
+
})
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1201
|
+
// ============================================================================
|
|
1202
|
+
// Real-Time Updates Tests
|
|
1203
|
+
// ============================================================================
|
|
1204
|
+
|
|
1205
|
+
describe('Real-Time Updates', () => {
|
|
1206
|
+
beforeEach(() => {
|
|
1207
|
+
vi.resetModules()
|
|
1208
|
+
vi.clearAllMocks()
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
describe('requestRender', () => {
|
|
1212
|
+
it('requests a render update', async () => {
|
|
1213
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1214
|
+
const { createCliRenderer } = await import('@opentui/core')
|
|
1215
|
+
|
|
1216
|
+
const renderer = await createInteractiveRenderer()
|
|
1217
|
+
|
|
1218
|
+
renderer.requestRender()
|
|
1219
|
+
|
|
1220
|
+
expect(createCliRenderer).toHaveBeenCalled()
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
it('batches multiple render requests', async () => {
|
|
1224
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1225
|
+
|
|
1226
|
+
const renderer = await createInteractiveRenderer()
|
|
1227
|
+
const renderSpy = vi.spyOn(renderer, 'requestRender')
|
|
1228
|
+
|
|
1229
|
+
// Request multiple renders rapidly
|
|
1230
|
+
renderer.requestRender()
|
|
1231
|
+
renderer.requestRender()
|
|
1232
|
+
renderer.requestRender()
|
|
1233
|
+
|
|
1234
|
+
// Implementation should batch these
|
|
1235
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1236
|
+
|
|
1237
|
+
// Verify batching behavior (actual render count should be less)
|
|
1238
|
+
expect(renderSpy).toHaveBeenCalled()
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
it('respects target FPS setting', async () => {
|
|
1242
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1243
|
+
|
|
1244
|
+
const renderer = await createInteractiveRenderer({ targetFps: 30 })
|
|
1245
|
+
|
|
1246
|
+
expect(renderer.getTargetFps()).toBe(30)
|
|
1247
|
+
})
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
describe('state updates', () => {
|
|
1251
|
+
it('triggers re-render on state change', async () => {
|
|
1252
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1253
|
+
|
|
1254
|
+
const renderer = await createInteractiveRenderer()
|
|
1255
|
+
const onRender = vi.fn()
|
|
1256
|
+
|
|
1257
|
+
renderer.onRender(onRender)
|
|
1258
|
+
|
|
1259
|
+
renderer.setState('counter', 1)
|
|
1260
|
+
|
|
1261
|
+
expect(onRender).toHaveBeenCalled()
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
it('provides state access', async () => {
|
|
1265
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1266
|
+
|
|
1267
|
+
const renderer = await createInteractiveRenderer()
|
|
1268
|
+
|
|
1269
|
+
renderer.setState('user', { name: 'Test' })
|
|
1270
|
+
|
|
1271
|
+
expect(renderer.getState('user')).toEqual({ name: 'Test' })
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
it('supports state subscriptions', async () => {
|
|
1275
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1276
|
+
|
|
1277
|
+
const renderer = await createInteractiveRenderer()
|
|
1278
|
+
const subscriber = vi.fn()
|
|
1279
|
+
|
|
1280
|
+
renderer.subscribe('counter', subscriber)
|
|
1281
|
+
renderer.setState('counter', 5)
|
|
1282
|
+
|
|
1283
|
+
expect(subscriber).toHaveBeenCalledWith(5, undefined)
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
it('allows unsubscribing from state changes', async () => {
|
|
1287
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1288
|
+
|
|
1289
|
+
const renderer = await createInteractiveRenderer()
|
|
1290
|
+
const subscriber = vi.fn()
|
|
1291
|
+
|
|
1292
|
+
const unsubscribe = renderer.subscribe('counter', subscriber)
|
|
1293
|
+
unsubscribe()
|
|
1294
|
+
renderer.setState('counter', 5)
|
|
1295
|
+
|
|
1296
|
+
expect(subscriber).not.toHaveBeenCalled()
|
|
1297
|
+
})
|
|
1298
|
+
})
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
// ============================================================================
|
|
1302
|
+
// Input Field Handling Tests
|
|
1303
|
+
// ============================================================================
|
|
1304
|
+
|
|
1305
|
+
describe('Input Field Handling', () => {
|
|
1306
|
+
beforeEach(() => {
|
|
1307
|
+
vi.resetModules()
|
|
1308
|
+
vi.clearAllMocks()
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
describe('text input', () => {
|
|
1312
|
+
it('accepts text input when focused', async () => {
|
|
1313
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1314
|
+
|
|
1315
|
+
const renderer = await createInteractiveRenderer()
|
|
1316
|
+
const onChange = vi.fn()
|
|
1317
|
+
|
|
1318
|
+
renderer.registerInput('input-1', {
|
|
1319
|
+
value: '',
|
|
1320
|
+
onChange,
|
|
1321
|
+
})
|
|
1322
|
+
renderer.focusById('input-1')
|
|
1323
|
+
|
|
1324
|
+
renderer.emitKey('a')
|
|
1325
|
+
expect(onChange).toHaveBeenCalledWith('a')
|
|
1326
|
+
|
|
1327
|
+
renderer.emitKey('b')
|
|
1328
|
+
expect(onChange).toHaveBeenCalledWith('ab')
|
|
1329
|
+
})
|
|
1330
|
+
|
|
1331
|
+
it('handles backspace', async () => {
|
|
1332
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1333
|
+
|
|
1334
|
+
const renderer = await createInteractiveRenderer()
|
|
1335
|
+
const onChange = vi.fn()
|
|
1336
|
+
|
|
1337
|
+
renderer.registerInput('input-1', {
|
|
1338
|
+
value: 'test',
|
|
1339
|
+
onChange,
|
|
1340
|
+
})
|
|
1341
|
+
renderer.focusById('input-1')
|
|
1342
|
+
|
|
1343
|
+
renderer.emitKey('backspace')
|
|
1344
|
+
expect(onChange).toHaveBeenCalledWith('tes')
|
|
1345
|
+
})
|
|
1346
|
+
|
|
1347
|
+
it('handles delete key', async () => {
|
|
1348
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1349
|
+
|
|
1350
|
+
const renderer = await createInteractiveRenderer()
|
|
1351
|
+
const onChange = vi.fn()
|
|
1352
|
+
|
|
1353
|
+
renderer.registerInput('input-1', {
|
|
1354
|
+
value: 'test',
|
|
1355
|
+
cursorIndex: 0,
|
|
1356
|
+
onChange,
|
|
1357
|
+
})
|
|
1358
|
+
renderer.focusById('input-1')
|
|
1359
|
+
|
|
1360
|
+
renderer.emitKey('delete')
|
|
1361
|
+
expect(onChange).toHaveBeenCalledWith('est')
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
it('moves cursor with arrow keys', async () => {
|
|
1365
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1366
|
+
|
|
1367
|
+
const renderer = await createInteractiveRenderer()
|
|
1368
|
+
|
|
1369
|
+
renderer.registerInput('input-1', {
|
|
1370
|
+
value: 'test',
|
|
1371
|
+
cursorIndex: 2,
|
|
1372
|
+
})
|
|
1373
|
+
renderer.focusById('input-1')
|
|
1374
|
+
|
|
1375
|
+
renderer.emitKey('left')
|
|
1376
|
+
expect(renderer.getInputCursorIndex('input-1')).toBe(1)
|
|
1377
|
+
|
|
1378
|
+
renderer.emitKey('right')
|
|
1379
|
+
expect(renderer.getInputCursorIndex('input-1')).toBe(2)
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1382
|
+
it('handles Home and End keys', async () => {
|
|
1383
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1384
|
+
|
|
1385
|
+
const renderer = await createInteractiveRenderer()
|
|
1386
|
+
|
|
1387
|
+
renderer.registerInput('input-1', {
|
|
1388
|
+
value: 'test',
|
|
1389
|
+
cursorIndex: 2,
|
|
1390
|
+
})
|
|
1391
|
+
renderer.focusById('input-1')
|
|
1392
|
+
|
|
1393
|
+
renderer.emitKey('home')
|
|
1394
|
+
expect(renderer.getInputCursorIndex('input-1')).toBe(0)
|
|
1395
|
+
|
|
1396
|
+
renderer.emitKey('end')
|
|
1397
|
+
expect(renderer.getInputCursorIndex('input-1')).toBe(4)
|
|
1398
|
+
})
|
|
1399
|
+
})
|
|
1400
|
+
|
|
1401
|
+
describe('input validation', () => {
|
|
1402
|
+
it('supports input masks', async () => {
|
|
1403
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1404
|
+
|
|
1405
|
+
const renderer = await createInteractiveRenderer()
|
|
1406
|
+
const onChange = vi.fn()
|
|
1407
|
+
|
|
1408
|
+
renderer.registerInput('phone-input', {
|
|
1409
|
+
value: '',
|
|
1410
|
+
mask: '(###) ###-####',
|
|
1411
|
+
onChange,
|
|
1412
|
+
})
|
|
1413
|
+
renderer.focusById('phone-input')
|
|
1414
|
+
|
|
1415
|
+
renderer.emitKey('5')
|
|
1416
|
+
expect(onChange).toHaveBeenCalledWith('(5')
|
|
1417
|
+
})
|
|
1418
|
+
|
|
1419
|
+
it('supports max length', async () => {
|
|
1420
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1421
|
+
|
|
1422
|
+
const renderer = await createInteractiveRenderer()
|
|
1423
|
+
const onChange = vi.fn()
|
|
1424
|
+
|
|
1425
|
+
renderer.registerInput('input-1', {
|
|
1426
|
+
value: '12345',
|
|
1427
|
+
maxLength: 5,
|
|
1428
|
+
onChange,
|
|
1429
|
+
})
|
|
1430
|
+
renderer.focusById('input-1')
|
|
1431
|
+
|
|
1432
|
+
renderer.emitKey('6')
|
|
1433
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
it('supports validation function', async () => {
|
|
1437
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1438
|
+
|
|
1439
|
+
const renderer = await createInteractiveRenderer()
|
|
1440
|
+
const onChange = vi.fn()
|
|
1441
|
+
|
|
1442
|
+
renderer.registerInput('input-1', {
|
|
1443
|
+
value: '',
|
|
1444
|
+
validate: (value) => /^\d*$/.test(value), // Numbers only
|
|
1445
|
+
onChange,
|
|
1446
|
+
})
|
|
1447
|
+
renderer.focusById('input-1')
|
|
1448
|
+
|
|
1449
|
+
renderer.emitKey('a')
|
|
1450
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
1451
|
+
|
|
1452
|
+
renderer.emitKey('1')
|
|
1453
|
+
expect(onChange).toHaveBeenCalledWith('1')
|
|
1454
|
+
})
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
describe('input submission', () => {
|
|
1458
|
+
it('submits input on Enter', async () => {
|
|
1459
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1460
|
+
|
|
1461
|
+
const renderer = await createInteractiveRenderer()
|
|
1462
|
+
const onSubmit = vi.fn()
|
|
1463
|
+
|
|
1464
|
+
renderer.registerInput('input-1', {
|
|
1465
|
+
value: 'test value',
|
|
1466
|
+
onSubmit,
|
|
1467
|
+
})
|
|
1468
|
+
renderer.focusById('input-1')
|
|
1469
|
+
|
|
1470
|
+
renderer.emitKey('enter')
|
|
1471
|
+
expect(onSubmit).toHaveBeenCalledWith('test value')
|
|
1472
|
+
})
|
|
1473
|
+
|
|
1474
|
+
it('prevents submission with Ctrl+Enter for multiline', async () => {
|
|
1475
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1476
|
+
|
|
1477
|
+
const renderer = await createInteractiveRenderer()
|
|
1478
|
+
const onSubmit = vi.fn()
|
|
1479
|
+
const onChange = vi.fn()
|
|
1480
|
+
|
|
1481
|
+
renderer.registerInput('textarea-1', {
|
|
1482
|
+
value: 'line 1',
|
|
1483
|
+
multiline: true,
|
|
1484
|
+
onChange,
|
|
1485
|
+
onSubmit,
|
|
1486
|
+
})
|
|
1487
|
+
renderer.focusById('textarea-1')
|
|
1488
|
+
|
|
1489
|
+
renderer.emitKey('enter')
|
|
1490
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
1491
|
+
expect(onChange).toHaveBeenCalledWith('line 1\n')
|
|
1492
|
+
|
|
1493
|
+
renderer.emitKey('ctrl+enter')
|
|
1494
|
+
expect(onSubmit).toHaveBeenCalled()
|
|
1495
|
+
})
|
|
1496
|
+
})
|
|
1497
|
+
})
|
|
1498
|
+
|
|
1499
|
+
// ============================================================================
|
|
1500
|
+
// Component State Management Tests
|
|
1501
|
+
// ============================================================================
|
|
1502
|
+
|
|
1503
|
+
describe('Component State Management', () => {
|
|
1504
|
+
beforeEach(() => {
|
|
1505
|
+
vi.resetModules()
|
|
1506
|
+
vi.clearAllMocks()
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
describe('component registration', () => {
|
|
1510
|
+
it('registers interactive components', async () => {
|
|
1511
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1512
|
+
|
|
1513
|
+
const renderer = await createInteractiveRenderer()
|
|
1514
|
+
|
|
1515
|
+
renderer.registerComponent('select-1', {
|
|
1516
|
+
type: 'select',
|
|
1517
|
+
options: ['Option 1', 'Option 2', 'Option 3'],
|
|
1518
|
+
selectedIndex: 0,
|
|
1519
|
+
})
|
|
1520
|
+
|
|
1521
|
+
expect(renderer.getComponent('select-1')).toBeDefined()
|
|
1522
|
+
expect(renderer.getComponent('select-1')?.type).toBe('select')
|
|
1523
|
+
})
|
|
1524
|
+
|
|
1525
|
+
it('unregisters components', async () => {
|
|
1526
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1527
|
+
|
|
1528
|
+
const renderer = await createInteractiveRenderer()
|
|
1529
|
+
|
|
1530
|
+
renderer.registerComponent('select-1', {
|
|
1531
|
+
type: 'select',
|
|
1532
|
+
options: ['A', 'B'],
|
|
1533
|
+
})
|
|
1534
|
+
|
|
1535
|
+
renderer.unregisterComponent('select-1')
|
|
1536
|
+
|
|
1537
|
+
expect(renderer.getComponent('select-1')).toBeUndefined()
|
|
1538
|
+
})
|
|
1539
|
+
})
|
|
1540
|
+
|
|
1541
|
+
describe('select component', () => {
|
|
1542
|
+
it('navigates options with keyboard', async () => {
|
|
1543
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1544
|
+
|
|
1545
|
+
const renderer = await createInteractiveRenderer()
|
|
1546
|
+
const onChange = vi.fn()
|
|
1547
|
+
|
|
1548
|
+
renderer.registerComponent('select-1', {
|
|
1549
|
+
type: 'select',
|
|
1550
|
+
options: ['A', 'B', 'C'],
|
|
1551
|
+
selectedIndex: 0,
|
|
1552
|
+
onChange,
|
|
1553
|
+
})
|
|
1554
|
+
renderer.focusById('select-1')
|
|
1555
|
+
|
|
1556
|
+
renderer.emitKey('down')
|
|
1557
|
+
expect(onChange).toHaveBeenCalledWith(1, 'B')
|
|
1558
|
+
|
|
1559
|
+
renderer.emitKey('down')
|
|
1560
|
+
expect(onChange).toHaveBeenCalledWith(2, 'C')
|
|
1561
|
+
|
|
1562
|
+
renderer.emitKey('up')
|
|
1563
|
+
expect(onChange).toHaveBeenCalledWith(1, 'B')
|
|
1564
|
+
})
|
|
1565
|
+
|
|
1566
|
+
it('selects option with Enter', async () => {
|
|
1567
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1568
|
+
|
|
1569
|
+
const renderer = await createInteractiveRenderer()
|
|
1570
|
+
const onSelect = vi.fn()
|
|
1571
|
+
|
|
1572
|
+
renderer.registerComponent('select-1', {
|
|
1573
|
+
type: 'select',
|
|
1574
|
+
options: ['A', 'B', 'C'],
|
|
1575
|
+
selectedIndex: 1,
|
|
1576
|
+
onSelect,
|
|
1577
|
+
})
|
|
1578
|
+
renderer.focusById('select-1')
|
|
1579
|
+
|
|
1580
|
+
renderer.emitKey('enter')
|
|
1581
|
+
expect(onSelect).toHaveBeenCalledWith(1, 'B')
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
it('supports type-ahead search', async () => {
|
|
1585
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1586
|
+
|
|
1587
|
+
const renderer = await createInteractiveRenderer()
|
|
1588
|
+
const onChange = vi.fn()
|
|
1589
|
+
|
|
1590
|
+
renderer.registerComponent('select-1', {
|
|
1591
|
+
type: 'select',
|
|
1592
|
+
options: ['Apple', 'Banana', 'Cherry'],
|
|
1593
|
+
selectedIndex: 0,
|
|
1594
|
+
searchable: true,
|
|
1595
|
+
onChange,
|
|
1596
|
+
})
|
|
1597
|
+
renderer.focusById('select-1')
|
|
1598
|
+
|
|
1599
|
+
renderer.emitKey('c')
|
|
1600
|
+
expect(onChange).toHaveBeenCalledWith(2, 'Cherry')
|
|
1601
|
+
})
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
describe('checkbox component', () => {
|
|
1605
|
+
it('toggles checkbox with Space', async () => {
|
|
1606
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1607
|
+
|
|
1608
|
+
const renderer = await createInteractiveRenderer()
|
|
1609
|
+
const onChange = vi.fn()
|
|
1610
|
+
|
|
1611
|
+
renderer.registerComponent('checkbox-1', {
|
|
1612
|
+
type: 'checkbox',
|
|
1613
|
+
checked: false,
|
|
1614
|
+
onChange,
|
|
1615
|
+
})
|
|
1616
|
+
renderer.focusById('checkbox-1')
|
|
1617
|
+
|
|
1618
|
+
renderer.emitKey('space')
|
|
1619
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
1620
|
+
|
|
1621
|
+
renderer.emitKey('space')
|
|
1622
|
+
expect(onChange).toHaveBeenCalledWith(false)
|
|
1623
|
+
})
|
|
1624
|
+
|
|
1625
|
+
it('toggles checkbox with Enter', async () => {
|
|
1626
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1627
|
+
|
|
1628
|
+
const renderer = await createInteractiveRenderer()
|
|
1629
|
+
const onChange = vi.fn()
|
|
1630
|
+
|
|
1631
|
+
renderer.registerComponent('checkbox-1', {
|
|
1632
|
+
type: 'checkbox',
|
|
1633
|
+
checked: false,
|
|
1634
|
+
onChange,
|
|
1635
|
+
})
|
|
1636
|
+
renderer.focusById('checkbox-1')
|
|
1637
|
+
|
|
1638
|
+
renderer.emitKey('enter')
|
|
1639
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
1640
|
+
})
|
|
1641
|
+
})
|
|
1642
|
+
|
|
1643
|
+
describe('radio group component', () => {
|
|
1644
|
+
it('navigates radio options', async () => {
|
|
1645
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1646
|
+
|
|
1647
|
+
const renderer = await createInteractiveRenderer()
|
|
1648
|
+
const onChange = vi.fn()
|
|
1649
|
+
|
|
1650
|
+
renderer.registerComponent('radio-1', {
|
|
1651
|
+
type: 'radiogroup',
|
|
1652
|
+
options: ['Option 1', 'Option 2', 'Option 3'],
|
|
1653
|
+
selectedIndex: 0,
|
|
1654
|
+
onChange,
|
|
1655
|
+
})
|
|
1656
|
+
renderer.focusById('radio-1')
|
|
1657
|
+
|
|
1658
|
+
renderer.emitKey('down')
|
|
1659
|
+
expect(onChange).toHaveBeenCalledWith(1)
|
|
1660
|
+
})
|
|
1661
|
+
|
|
1662
|
+
it('selects radio option with Space', async () => {
|
|
1663
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1664
|
+
|
|
1665
|
+
const renderer = await createInteractiveRenderer()
|
|
1666
|
+
const onSelect = vi.fn()
|
|
1667
|
+
|
|
1668
|
+
renderer.registerComponent('radio-1', {
|
|
1669
|
+
type: 'radiogroup',
|
|
1670
|
+
options: ['Option 1', 'Option 2', 'Option 3'],
|
|
1671
|
+
selectedIndex: 1,
|
|
1672
|
+
onSelect,
|
|
1673
|
+
})
|
|
1674
|
+
renderer.focusById('radio-1')
|
|
1675
|
+
|
|
1676
|
+
renderer.emitKey('space')
|
|
1677
|
+
expect(onSelect).toHaveBeenCalledWith(1, 'Option 2')
|
|
1678
|
+
})
|
|
1679
|
+
})
|
|
1680
|
+
|
|
1681
|
+
describe('slider component', () => {
|
|
1682
|
+
it('adjusts value with arrow keys', async () => {
|
|
1683
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1684
|
+
|
|
1685
|
+
const renderer = await createInteractiveRenderer()
|
|
1686
|
+
const onChange = vi.fn()
|
|
1687
|
+
|
|
1688
|
+
renderer.registerComponent('slider-1', {
|
|
1689
|
+
type: 'slider',
|
|
1690
|
+
value: 50,
|
|
1691
|
+
min: 0,
|
|
1692
|
+
max: 100,
|
|
1693
|
+
step: 10,
|
|
1694
|
+
onChange,
|
|
1695
|
+
})
|
|
1696
|
+
renderer.focusById('slider-1')
|
|
1697
|
+
|
|
1698
|
+
renderer.emitKey('right')
|
|
1699
|
+
expect(onChange).toHaveBeenCalledWith(60)
|
|
1700
|
+
|
|
1701
|
+
renderer.emitKey('left')
|
|
1702
|
+
expect(onChange).toHaveBeenCalledWith(50)
|
|
1703
|
+
})
|
|
1704
|
+
|
|
1705
|
+
it('respects min/max bounds', async () => {
|
|
1706
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1707
|
+
|
|
1708
|
+
const renderer = await createInteractiveRenderer()
|
|
1709
|
+
const onChange = vi.fn()
|
|
1710
|
+
|
|
1711
|
+
renderer.registerComponent('slider-1', {
|
|
1712
|
+
type: 'slider',
|
|
1713
|
+
value: 100,
|
|
1714
|
+
min: 0,
|
|
1715
|
+
max: 100,
|
|
1716
|
+
step: 10,
|
|
1717
|
+
onChange,
|
|
1718
|
+
})
|
|
1719
|
+
renderer.focusById('slider-1')
|
|
1720
|
+
|
|
1721
|
+
renderer.emitKey('right')
|
|
1722
|
+
expect(onChange).toHaveBeenCalledWith(100) // Stays at max
|
|
1723
|
+
})
|
|
1724
|
+
|
|
1725
|
+
it('jumps to min/max with Home/End', async () => {
|
|
1726
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1727
|
+
|
|
1728
|
+
const renderer = await createInteractiveRenderer()
|
|
1729
|
+
const onChange = vi.fn()
|
|
1730
|
+
|
|
1731
|
+
renderer.registerComponent('slider-1', {
|
|
1732
|
+
type: 'slider',
|
|
1733
|
+
value: 50,
|
|
1734
|
+
min: 0,
|
|
1735
|
+
max: 100,
|
|
1736
|
+
onChange,
|
|
1737
|
+
})
|
|
1738
|
+
renderer.focusById('slider-1')
|
|
1739
|
+
|
|
1740
|
+
renderer.emitKey('end')
|
|
1741
|
+
expect(onChange).toHaveBeenCalledWith(100)
|
|
1742
|
+
|
|
1743
|
+
renderer.emitKey('home')
|
|
1744
|
+
expect(onChange).toHaveBeenCalledWith(0)
|
|
1745
|
+
})
|
|
1746
|
+
})
|
|
1747
|
+
|
|
1748
|
+
describe('tree component', () => {
|
|
1749
|
+
it('expands/collapses nodes', async () => {
|
|
1750
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1751
|
+
|
|
1752
|
+
const renderer = await createInteractiveRenderer()
|
|
1753
|
+
const onToggle = vi.fn()
|
|
1754
|
+
|
|
1755
|
+
renderer.registerComponent('tree-1', {
|
|
1756
|
+
type: 'tree',
|
|
1757
|
+
nodes: [
|
|
1758
|
+
{ id: 'root', label: 'Root', children: ['child-1', 'child-2'], expanded: false },
|
|
1759
|
+
{ id: 'child-1', label: 'Child 1', parent: 'root' },
|
|
1760
|
+
{ id: 'child-2', label: 'Child 2', parent: 'root' },
|
|
1761
|
+
],
|
|
1762
|
+
selectedId: 'root',
|
|
1763
|
+
onToggle,
|
|
1764
|
+
})
|
|
1765
|
+
renderer.focusById('tree-1')
|
|
1766
|
+
|
|
1767
|
+
renderer.emitKey('enter')
|
|
1768
|
+
expect(onToggle).toHaveBeenCalledWith('root', true)
|
|
1769
|
+
})
|
|
1770
|
+
|
|
1771
|
+
it('navigates to children on expand', async () => {
|
|
1772
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
1773
|
+
|
|
1774
|
+
const renderer = await createInteractiveRenderer()
|
|
1775
|
+
const onSelect = vi.fn()
|
|
1776
|
+
|
|
1777
|
+
renderer.registerComponent('tree-1', {
|
|
1778
|
+
type: 'tree',
|
|
1779
|
+
nodes: [
|
|
1780
|
+
{ id: 'root', label: 'Root', children: ['child-1'], expanded: true },
|
|
1781
|
+
{ id: 'child-1', label: 'Child 1', parent: 'root' },
|
|
1782
|
+
],
|
|
1783
|
+
selectedId: 'root',
|
|
1784
|
+
onSelect,
|
|
1785
|
+
})
|
|
1786
|
+
renderer.focusById('tree-1')
|
|
1787
|
+
|
|
1788
|
+
renderer.emitKey('down')
|
|
1789
|
+
expect(onSelect).toHaveBeenCalledWith('child-1')
|
|
1790
|
+
})
|
|
1791
|
+
})
|
|
1792
|
+
})
|
|
1793
|
+
|
|
1794
|
+
// ============================================================================
|
|
1795
|
+
// Interactive Component Type Tests
|
|
1796
|
+
// ============================================================================
|
|
1797
|
+
|
|
1798
|
+
describe('Interactive Component Types', () => {
|
|
1799
|
+
beforeEach(() => {
|
|
1800
|
+
vi.resetModules()
|
|
1801
|
+
vi.clearAllMocks()
|
|
1802
|
+
})
|
|
1803
|
+
|
|
1804
|
+
describe('Button', () => {
|
|
1805
|
+
it('activates button on Enter', async () => {
|
|
1806
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1807
|
+
|
|
1808
|
+
const renderer = await createInteractiveRenderer()
|
|
1809
|
+
const onClick = vi.fn()
|
|
1810
|
+
|
|
1811
|
+
const node = {
|
|
1812
|
+
type: 'button',
|
|
1813
|
+
props: { id: 'btn-1', children: 'Click Me', onClick },
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
registerInteractiveNode(node, renderer)
|
|
1817
|
+
renderer.focusById('btn-1')
|
|
1818
|
+
renderer.emitKey('enter')
|
|
1819
|
+
|
|
1820
|
+
expect(onClick).toHaveBeenCalled()
|
|
1821
|
+
})
|
|
1822
|
+
|
|
1823
|
+
it('activates button on Space', async () => {
|
|
1824
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1825
|
+
|
|
1826
|
+
const renderer = await createInteractiveRenderer()
|
|
1827
|
+
const onClick = vi.fn()
|
|
1828
|
+
|
|
1829
|
+
const node = {
|
|
1830
|
+
type: 'button',
|
|
1831
|
+
props: { id: 'btn-1', children: 'Click Me', onClick },
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
registerInteractiveNode(node, renderer)
|
|
1835
|
+
renderer.focusById('btn-1')
|
|
1836
|
+
renderer.emitKey('space')
|
|
1837
|
+
|
|
1838
|
+
expect(onClick).toHaveBeenCalled()
|
|
1839
|
+
})
|
|
1840
|
+
|
|
1841
|
+
it('supports disabled state', async () => {
|
|
1842
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1843
|
+
|
|
1844
|
+
const renderer = await createInteractiveRenderer()
|
|
1845
|
+
const onClick = vi.fn()
|
|
1846
|
+
|
|
1847
|
+
const node = {
|
|
1848
|
+
type: 'button',
|
|
1849
|
+
props: { id: 'btn-1', children: 'Click Me', onClick, disabled: true },
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
registerInteractiveNode(node, renderer)
|
|
1853
|
+
renderer.focusById('btn-1')
|
|
1854
|
+
renderer.emitKey('enter')
|
|
1855
|
+
|
|
1856
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
1857
|
+
})
|
|
1858
|
+
})
|
|
1859
|
+
|
|
1860
|
+
describe('Input', () => {
|
|
1861
|
+
it('renders input with placeholder', async () => {
|
|
1862
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1863
|
+
|
|
1864
|
+
const renderer = await createInteractiveRenderer()
|
|
1865
|
+
|
|
1866
|
+
const node = {
|
|
1867
|
+
type: 'input',
|
|
1868
|
+
props: { id: 'input-1', placeholder: 'Enter text...' },
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
registerInteractiveNode(node, renderer)
|
|
1872
|
+
|
|
1873
|
+
const component = renderer.getComponent('input-1')
|
|
1874
|
+
expect(component?.placeholder).toBe('Enter text...')
|
|
1875
|
+
})
|
|
1876
|
+
|
|
1877
|
+
it('supports password type', async () => {
|
|
1878
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1879
|
+
|
|
1880
|
+
const renderer = await createInteractiveRenderer()
|
|
1881
|
+
|
|
1882
|
+
const node = {
|
|
1883
|
+
type: 'input',
|
|
1884
|
+
props: { id: 'password-1', type: 'password', value: 'secret' },
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
registerInteractiveNode(node, renderer)
|
|
1888
|
+
|
|
1889
|
+
const component = renderer.getComponent('password-1')
|
|
1890
|
+
expect(component?.displayValue).toBe('******')
|
|
1891
|
+
})
|
|
1892
|
+
})
|
|
1893
|
+
|
|
1894
|
+
describe('Select', () => {
|
|
1895
|
+
it('renders select with options', async () => {
|
|
1896
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1897
|
+
|
|
1898
|
+
const renderer = await createInteractiveRenderer()
|
|
1899
|
+
|
|
1900
|
+
const node = {
|
|
1901
|
+
type: 'select',
|
|
1902
|
+
props: {
|
|
1903
|
+
id: 'select-1',
|
|
1904
|
+
options: [
|
|
1905
|
+
{ value: 'a', label: 'Option A' },
|
|
1906
|
+
{ value: 'b', label: 'Option B' },
|
|
1907
|
+
],
|
|
1908
|
+
},
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
registerInteractiveNode(node, renderer)
|
|
1912
|
+
|
|
1913
|
+
const component = renderer.getComponent('select-1')
|
|
1914
|
+
expect(component?.options).toHaveLength(2)
|
|
1915
|
+
})
|
|
1916
|
+
|
|
1917
|
+
it('opens dropdown on Enter', async () => {
|
|
1918
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1919
|
+
|
|
1920
|
+
const renderer = await createInteractiveRenderer()
|
|
1921
|
+
|
|
1922
|
+
const node = {
|
|
1923
|
+
type: 'select',
|
|
1924
|
+
props: {
|
|
1925
|
+
id: 'select-1',
|
|
1926
|
+
options: [{ value: 'a', label: 'Option A' }],
|
|
1927
|
+
},
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
registerInteractiveNode(node, renderer)
|
|
1931
|
+
renderer.focusById('select-1')
|
|
1932
|
+
renderer.emitKey('enter')
|
|
1933
|
+
|
|
1934
|
+
const component = renderer.getComponent('select-1')
|
|
1935
|
+
expect(component?.isOpen).toBe(true)
|
|
1936
|
+
})
|
|
1937
|
+
})
|
|
1938
|
+
|
|
1939
|
+
describe('Dialog', () => {
|
|
1940
|
+
it('traps focus within dialog', async () => {
|
|
1941
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1942
|
+
|
|
1943
|
+
const renderer = await createInteractiveRenderer()
|
|
1944
|
+
|
|
1945
|
+
const node = {
|
|
1946
|
+
type: 'dialog',
|
|
1947
|
+
props: {
|
|
1948
|
+
id: 'dialog-1',
|
|
1949
|
+
open: true,
|
|
1950
|
+
children: [
|
|
1951
|
+
{ type: 'input', props: { id: 'dialog-input' } },
|
|
1952
|
+
{ type: 'button', props: { id: 'dialog-button', children: 'OK' } },
|
|
1953
|
+
],
|
|
1954
|
+
},
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
registerInteractiveNode(node, renderer)
|
|
1958
|
+
|
|
1959
|
+
// Focus should be trapped within dialog
|
|
1960
|
+
const focusableIds = renderer.getFocusableIds()
|
|
1961
|
+
expect(focusableIds).toEqual(['dialog-input', 'dialog-button'])
|
|
1962
|
+
})
|
|
1963
|
+
|
|
1964
|
+
it('closes dialog on Escape', async () => {
|
|
1965
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1966
|
+
|
|
1967
|
+
const renderer = await createInteractiveRenderer()
|
|
1968
|
+
const onClose = vi.fn()
|
|
1969
|
+
|
|
1970
|
+
const node = {
|
|
1971
|
+
type: 'dialog',
|
|
1972
|
+
props: {
|
|
1973
|
+
id: 'dialog-1',
|
|
1974
|
+
open: true,
|
|
1975
|
+
onClose,
|
|
1976
|
+
children: [],
|
|
1977
|
+
},
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
registerInteractiveNode(node, renderer)
|
|
1981
|
+
renderer.emitKey('escape')
|
|
1982
|
+
|
|
1983
|
+
expect(onClose).toHaveBeenCalled()
|
|
1984
|
+
})
|
|
1985
|
+
})
|
|
1986
|
+
|
|
1987
|
+
describe('Table', () => {
|
|
1988
|
+
it('navigates table rows', async () => {
|
|
1989
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
1990
|
+
|
|
1991
|
+
const renderer = await createInteractiveRenderer()
|
|
1992
|
+
const onRowSelect = vi.fn()
|
|
1993
|
+
|
|
1994
|
+
const node = {
|
|
1995
|
+
type: 'table',
|
|
1996
|
+
props: {
|
|
1997
|
+
id: 'table-1',
|
|
1998
|
+
rows: [
|
|
1999
|
+
{ id: 'row-1', cells: ['A1', 'B1'] },
|
|
2000
|
+
{ id: 'row-2', cells: ['A2', 'B2'] },
|
|
2001
|
+
{ id: 'row-3', cells: ['A3', 'B3'] },
|
|
2002
|
+
],
|
|
2003
|
+
onRowSelect,
|
|
2004
|
+
},
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
registerInteractiveNode(node, renderer)
|
|
2008
|
+
renderer.focusById('table-1')
|
|
2009
|
+
|
|
2010
|
+
renderer.emitKey('down')
|
|
2011
|
+
expect(onRowSelect).toHaveBeenCalledWith('row-2')
|
|
2012
|
+
})
|
|
2013
|
+
|
|
2014
|
+
it('navigates table cells', async () => {
|
|
2015
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
2016
|
+
|
|
2017
|
+
const renderer = await createInteractiveRenderer()
|
|
2018
|
+
const onCellSelect = vi.fn()
|
|
2019
|
+
|
|
2020
|
+
const node = {
|
|
2021
|
+
type: 'table',
|
|
2022
|
+
props: {
|
|
2023
|
+
id: 'table-1',
|
|
2024
|
+
cellNavigation: true,
|
|
2025
|
+
rows: [
|
|
2026
|
+
{ id: 'row-1', cells: ['A1', 'B1', 'C1'] },
|
|
2027
|
+
],
|
|
2028
|
+
onCellSelect,
|
|
2029
|
+
},
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
registerInteractiveNode(node, renderer)
|
|
2033
|
+
renderer.focusById('table-1')
|
|
2034
|
+
|
|
2035
|
+
renderer.emitKey('right')
|
|
2036
|
+
expect(onCellSelect).toHaveBeenCalledWith('row-1', 1)
|
|
2037
|
+
})
|
|
2038
|
+
})
|
|
2039
|
+
|
|
2040
|
+
describe('Spinner/Progress', () => {
|
|
2041
|
+
it('updates progress value', async () => {
|
|
2042
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
2043
|
+
|
|
2044
|
+
const renderer = await createInteractiveRenderer()
|
|
2045
|
+
|
|
2046
|
+
const node = {
|
|
2047
|
+
type: 'progress',
|
|
2048
|
+
props: { id: 'progress-1', value: 50, max: 100 },
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
registerInteractiveNode(node, renderer)
|
|
2052
|
+
|
|
2053
|
+
renderer.updateComponent('progress-1', { value: 75 })
|
|
2054
|
+
|
|
2055
|
+
const component = renderer.getComponent('progress-1')
|
|
2056
|
+
expect(component?.value).toBe(75)
|
|
2057
|
+
})
|
|
2058
|
+
|
|
2059
|
+
it('triggers animation frames for spinner', async () => {
|
|
2060
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
2061
|
+
|
|
2062
|
+
const renderer = await createInteractiveRenderer()
|
|
2063
|
+
|
|
2064
|
+
const node = {
|
|
2065
|
+
type: 'spinner',
|
|
2066
|
+
props: { id: 'spinner-1' },
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
registerInteractiveNode(node, renderer)
|
|
2070
|
+
|
|
2071
|
+
const component = renderer.getComponent('spinner-1')
|
|
2072
|
+
expect(component?.isAnimating).toBe(true)
|
|
2073
|
+
})
|
|
2074
|
+
})
|
|
2075
|
+
|
|
2076
|
+
describe('ScrollView', () => {
|
|
2077
|
+
it('scrolls content with arrow keys', async () => {
|
|
2078
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
2079
|
+
|
|
2080
|
+
const renderer = await createInteractiveRenderer()
|
|
2081
|
+
const onScroll = vi.fn()
|
|
2082
|
+
|
|
2083
|
+
const node = {
|
|
2084
|
+
type: 'scrollview',
|
|
2085
|
+
props: {
|
|
2086
|
+
id: 'scroll-1',
|
|
2087
|
+
contentHeight: 100,
|
|
2088
|
+
viewportHeight: 20,
|
|
2089
|
+
onScroll,
|
|
2090
|
+
children: { type: 'text', props: { children: 'Long content...' } },
|
|
2091
|
+
},
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
registerInteractiveNode(node, renderer)
|
|
2095
|
+
renderer.focusById('scroll-1')
|
|
2096
|
+
|
|
2097
|
+
renderer.emitKey('down')
|
|
2098
|
+
expect(onScroll).toHaveBeenCalled()
|
|
2099
|
+
})
|
|
2100
|
+
|
|
2101
|
+
it('supports Page Up/Down', async () => {
|
|
2102
|
+
const { createInteractiveRenderer, registerInteractiveNode } = await import('../../renderers/interactive')
|
|
2103
|
+
|
|
2104
|
+
const renderer = await createInteractiveRenderer()
|
|
2105
|
+
const onScroll = vi.fn()
|
|
2106
|
+
|
|
2107
|
+
const node = {
|
|
2108
|
+
type: 'scrollview',
|
|
2109
|
+
props: {
|
|
2110
|
+
id: 'scroll-1',
|
|
2111
|
+
contentHeight: 100,
|
|
2112
|
+
viewportHeight: 20,
|
|
2113
|
+
scrollY: 40,
|
|
2114
|
+
onScroll,
|
|
2115
|
+
},
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
registerInteractiveNode(node, renderer)
|
|
2119
|
+
renderer.focusById('scroll-1')
|
|
2120
|
+
|
|
2121
|
+
renderer.emitKey('pagedown')
|
|
2122
|
+
expect(onScroll).toHaveBeenCalledWith(expect.objectContaining({ scrollY: 60 }))
|
|
2123
|
+
|
|
2124
|
+
renderer.emitKey('pageup')
|
|
2125
|
+
expect(onScroll).toHaveBeenCalledWith(expect.objectContaining({ scrollY: 40 }))
|
|
2126
|
+
})
|
|
2127
|
+
})
|
|
2128
|
+
})
|
|
2129
|
+
|
|
2130
|
+
// ============================================================================
|
|
2131
|
+
// Edge Cases and Error Handling Tests
|
|
2132
|
+
// ============================================================================
|
|
2133
|
+
|
|
2134
|
+
describe('Edge Cases and Error Handling', () => {
|
|
2135
|
+
beforeEach(() => {
|
|
2136
|
+
vi.resetModules()
|
|
2137
|
+
vi.clearAllMocks()
|
|
2138
|
+
})
|
|
2139
|
+
|
|
2140
|
+
describe('empty states', () => {
|
|
2141
|
+
it('handles empty focusable list', async () => {
|
|
2142
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2143
|
+
|
|
2144
|
+
const renderer = await createInteractiveRenderer()
|
|
2145
|
+
|
|
2146
|
+
expect(renderer.getFocusableIds()).toEqual([])
|
|
2147
|
+
expect(renderer.getFocusedId()).toBeNull()
|
|
2148
|
+
|
|
2149
|
+
// These should not throw
|
|
2150
|
+
expect(() => renderer.focusNext()).not.toThrow()
|
|
2151
|
+
expect(() => renderer.focusPrev()).not.toThrow()
|
|
2152
|
+
})
|
|
2153
|
+
|
|
2154
|
+
it('handles focus on non-existent ID', async () => {
|
|
2155
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2156
|
+
|
|
2157
|
+
const renderer = await createInteractiveRenderer()
|
|
2158
|
+
|
|
2159
|
+
// Should not throw, just ignore
|
|
2160
|
+
expect(() => renderer.focusById('non-existent')).not.toThrow()
|
|
2161
|
+
expect(renderer.getFocusedId()).toBeNull()
|
|
2162
|
+
})
|
|
2163
|
+
|
|
2164
|
+
it('handles click with no clickable areas', async () => {
|
|
2165
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2166
|
+
|
|
2167
|
+
const renderer = await createInteractiveRenderer({ useMouse: true })
|
|
2168
|
+
|
|
2169
|
+
// Should not throw
|
|
2170
|
+
expect(() => renderer.emitClick(10, 5)).not.toThrow()
|
|
2171
|
+
})
|
|
2172
|
+
})
|
|
2173
|
+
|
|
2174
|
+
describe('duplicate handling', () => {
|
|
2175
|
+
it('handles duplicate focusable registration', async () => {
|
|
2176
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2177
|
+
|
|
2178
|
+
const renderer = await createInteractiveRenderer()
|
|
2179
|
+
|
|
2180
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
2181
|
+
renderer.registerFocusable('input-1', { tabIndex: 1 }) // Re-register with different tabIndex
|
|
2182
|
+
|
|
2183
|
+
const ids = renderer.getFocusableIds()
|
|
2184
|
+
expect(ids.filter((id) => id === 'input-1')).toHaveLength(1)
|
|
2185
|
+
})
|
|
2186
|
+
|
|
2187
|
+
it('handles duplicate key handler registration', async () => {
|
|
2188
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2189
|
+
|
|
2190
|
+
const renderer = await createInteractiveRenderer()
|
|
2191
|
+
const handler = vi.fn()
|
|
2192
|
+
|
|
2193
|
+
renderer.onKeyPress('ctrl+s', handler)
|
|
2194
|
+
renderer.onKeyPress('ctrl+s', handler) // Same handler again
|
|
2195
|
+
|
|
2196
|
+
renderer.emitKey('ctrl+s')
|
|
2197
|
+
|
|
2198
|
+
// Should only call once despite double registration
|
|
2199
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
2200
|
+
})
|
|
2201
|
+
})
|
|
2202
|
+
|
|
2203
|
+
describe('lifecycle', () => {
|
|
2204
|
+
it('cleans up on destroy', async () => {
|
|
2205
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2206
|
+
|
|
2207
|
+
const renderer = await createInteractiveRenderer()
|
|
2208
|
+
|
|
2209
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
2210
|
+
renderer.registerClickable('button-1', { x: 0, y: 0, width: 10, height: 1 })
|
|
2211
|
+
renderer.onKeyPress('ctrl+s', vi.fn())
|
|
2212
|
+
|
|
2213
|
+
renderer.destroy()
|
|
2214
|
+
|
|
2215
|
+
expect(renderer.getFocusableIds()).toEqual([])
|
|
2216
|
+
expect(renderer.getClickableAreas()).toEqual([])
|
|
2217
|
+
})
|
|
2218
|
+
|
|
2219
|
+
it('allows multiple destroy calls', async () => {
|
|
2220
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2221
|
+
|
|
2222
|
+
const renderer = await createInteractiveRenderer()
|
|
2223
|
+
|
|
2224
|
+
expect(() => {
|
|
2225
|
+
renderer.destroy()
|
|
2226
|
+
renderer.destroy()
|
|
2227
|
+
renderer.destroy()
|
|
2228
|
+
}).not.toThrow()
|
|
2229
|
+
})
|
|
2230
|
+
|
|
2231
|
+
it('ignores operations after destroy', async () => {
|
|
2232
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2233
|
+
|
|
2234
|
+
const renderer = await createInteractiveRenderer()
|
|
2235
|
+
|
|
2236
|
+
renderer.destroy()
|
|
2237
|
+
|
|
2238
|
+
// These should not throw or have any effect
|
|
2239
|
+
expect(() => renderer.focusNext()).not.toThrow()
|
|
2240
|
+
expect(() => renderer.emitKey('enter')).not.toThrow()
|
|
2241
|
+
})
|
|
2242
|
+
})
|
|
2243
|
+
|
|
2244
|
+
describe('disabled state handling', () => {
|
|
2245
|
+
it('skips disabled elements in focus chain', async () => {
|
|
2246
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2247
|
+
|
|
2248
|
+
const renderer = await createInteractiveRenderer()
|
|
2249
|
+
|
|
2250
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
2251
|
+
renderer.registerFocusable('input-2', { tabIndex: 0, disabled: true })
|
|
2252
|
+
renderer.registerFocusable('input-3', { tabIndex: 0 })
|
|
2253
|
+
|
|
2254
|
+
renderer.focusById('input-1')
|
|
2255
|
+
renderer.focusNext()
|
|
2256
|
+
|
|
2257
|
+
expect(renderer.getFocusedId()).toBe('input-3')
|
|
2258
|
+
})
|
|
2259
|
+
|
|
2260
|
+
it('handles all elements disabled', async () => {
|
|
2261
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2262
|
+
|
|
2263
|
+
const renderer = await createInteractiveRenderer()
|
|
2264
|
+
|
|
2265
|
+
renderer.registerFocusable('input-1', { tabIndex: 0, disabled: true })
|
|
2266
|
+
renderer.registerFocusable('input-2', { tabIndex: 0, disabled: true })
|
|
2267
|
+
|
|
2268
|
+
expect(() => renderer.focusNext()).not.toThrow()
|
|
2269
|
+
expect(renderer.getFocusedId()).toBeNull()
|
|
2270
|
+
})
|
|
2271
|
+
})
|
|
2272
|
+
|
|
2273
|
+
describe('concurrent updates', () => {
|
|
2274
|
+
it('handles rapid focus changes', async () => {
|
|
2275
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2276
|
+
|
|
2277
|
+
const renderer = await createInteractiveRenderer()
|
|
2278
|
+
|
|
2279
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
2280
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
2281
|
+
renderer.registerFocusable('input-3', { tabIndex: 0 })
|
|
2282
|
+
|
|
2283
|
+
// Rapid focus changes
|
|
2284
|
+
renderer.focusById('input-1')
|
|
2285
|
+
renderer.focusById('input-2')
|
|
2286
|
+
renderer.focusById('input-3')
|
|
2287
|
+
renderer.focusById('input-1')
|
|
2288
|
+
|
|
2289
|
+
expect(renderer.getFocusedId()).toBe('input-1')
|
|
2290
|
+
})
|
|
2291
|
+
|
|
2292
|
+
it('handles registration during focus traversal', async () => {
|
|
2293
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2294
|
+
|
|
2295
|
+
const renderer = await createInteractiveRenderer()
|
|
2296
|
+
|
|
2297
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
2298
|
+
renderer.registerFocusable('input-2', { tabIndex: 0 })
|
|
2299
|
+
|
|
2300
|
+
renderer.focusById('input-1')
|
|
2301
|
+
|
|
2302
|
+
// Add new focusable during operation
|
|
2303
|
+
renderer.registerFocusable('input-3', { tabIndex: 1 })
|
|
2304
|
+
|
|
2305
|
+
renderer.focusNext()
|
|
2306
|
+
|
|
2307
|
+
// Should handle the new element properly
|
|
2308
|
+
expect(renderer.getFocusableIds()).toContain('input-3')
|
|
2309
|
+
})
|
|
2310
|
+
})
|
|
2311
|
+
|
|
2312
|
+
describe('memory management', () => {
|
|
2313
|
+
it('clears callbacks on component unregister', async () => {
|
|
2314
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2315
|
+
|
|
2316
|
+
const renderer = await createInteractiveRenderer()
|
|
2317
|
+
const onFocus = vi.fn()
|
|
2318
|
+
const onBlur = vi.fn()
|
|
2319
|
+
|
|
2320
|
+
renderer.registerFocusable('input-1', {
|
|
2321
|
+
tabIndex: 0,
|
|
2322
|
+
onFocus,
|
|
2323
|
+
onBlur,
|
|
2324
|
+
})
|
|
2325
|
+
|
|
2326
|
+
renderer.unregisterFocusable('input-1')
|
|
2327
|
+
|
|
2328
|
+
// Re-register and focus - old callbacks should not fire
|
|
2329
|
+
renderer.registerFocusable('input-1', { tabIndex: 0 })
|
|
2330
|
+
renderer.focusById('input-1')
|
|
2331
|
+
|
|
2332
|
+
expect(onFocus).not.toHaveBeenCalled()
|
|
2333
|
+
})
|
|
2334
|
+
|
|
2335
|
+
it('cleans up sequence timers on destroy', async () => {
|
|
2336
|
+
const { createInteractiveRenderer } = await import('../../renderers/interactive')
|
|
2337
|
+
|
|
2338
|
+
const renderer = await createInteractiveRenderer({ sequenceTimeout: 100 })
|
|
2339
|
+
const handler = vi.fn()
|
|
2340
|
+
|
|
2341
|
+
renderer.onKeySequence('dd', handler)
|
|
2342
|
+
renderer.emitKey('d')
|
|
2343
|
+
|
|
2344
|
+
// Destroy before sequence timeout
|
|
2345
|
+
renderer.destroy()
|
|
2346
|
+
|
|
2347
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
2348
|
+
|
|
2349
|
+
// Handler should not have been called
|
|
2350
|
+
expect(handler).not.toHaveBeenCalled()
|
|
2351
|
+
})
|
|
2352
|
+
})
|
|
2353
|
+
})
|