@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,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mdxui/terminal Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* TDD RED Phase: End-to-end integration tests for the terminal render pipeline.
|
|
5
|
+
* These tests verify that the full system works together correctly:
|
|
6
|
+
* - Components render to string output
|
|
7
|
+
* - Nested components compose properly
|
|
8
|
+
* - Theme applies across component trees
|
|
9
|
+
* - Focus state affects rendering
|
|
10
|
+
* - Keyboard input updates component state
|
|
11
|
+
* - Terminal capabilities are respected
|
|
12
|
+
*
|
|
13
|
+
* All tests should initially define contracts that the implementation must fulfill.
|
|
14
|
+
*
|
|
15
|
+
* NOTE: React hooks (useFocus, useNavigableList, etc.) cannot be tested outside
|
|
16
|
+
* of a React component context. Those tests use the keyboard manager and other
|
|
17
|
+
* non-hook APIs to verify integration behavior.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
20
|
+
import React from 'react'
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Test Utilities
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Simulates rendering a component to terminal strings.
|
|
28
|
+
*/
|
|
29
|
+
async function renderToTerminal(element: React.ReactElement): Promise<string[]> {
|
|
30
|
+
const { renderComponent } = await import('../components')
|
|
31
|
+
|
|
32
|
+
if (typeof renderComponent !== 'function') {
|
|
33
|
+
throw new Error('renderComponent function not exported from @mdxui/terminal/components')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return renderComponent(element)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a string contains ANSI escape codes
|
|
41
|
+
*/
|
|
42
|
+
function hasAnsiCodes(str: string): boolean {
|
|
43
|
+
// eslint-disable-next-line no-control-regex
|
|
44
|
+
return /\x1b\[[\d;]*m/.test(str)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strip ANSI codes from string for content testing
|
|
49
|
+
*/
|
|
50
|
+
function stripAnsi(str: string): string {
|
|
51
|
+
// eslint-disable-next-line no-control-regex
|
|
52
|
+
return str.replace(/\x1b\[[\d;]*m/g, '')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Join and strip ANSI from rendered lines
|
|
57
|
+
*/
|
|
58
|
+
function getPlainContent(lines: string[]): string {
|
|
59
|
+
return stripAnsi(lines.join('\n'))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// 1. Full Render Pipeline Tests
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
describe('Integration', () => {
|
|
67
|
+
describe('Full Render Pipeline', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.resetModules()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('TerminalApp renders to string output', async () => {
|
|
73
|
+
const { TerminalApp, Text } = await import('..')
|
|
74
|
+
|
|
75
|
+
const element = React.createElement(
|
|
76
|
+
TerminalApp,
|
|
77
|
+
{ children: React.createElement(Text, {}, 'Hello Terminal') }
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const lines = await renderToTerminal(element)
|
|
81
|
+
|
|
82
|
+
// Should produce at least one line of output
|
|
83
|
+
expect(lines.length).toBeGreaterThan(0)
|
|
84
|
+
|
|
85
|
+
// Should contain the text content
|
|
86
|
+
const content = getPlainContent(lines)
|
|
87
|
+
expect(content).toContain('Hello Terminal')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('nested components render correctly', async () => {
|
|
91
|
+
const { Box, Text } = await import('../components')
|
|
92
|
+
|
|
93
|
+
// Create nested structure: Box > Box > Text
|
|
94
|
+
const innerText = React.createElement(Text, { color: 'cyan' }, 'Inner content')
|
|
95
|
+
const innerBox = React.createElement(Box, { border: 'single', padding: 1 }, innerText)
|
|
96
|
+
const outerBox = React.createElement(Box, { border: 'double', padding: 1 }, innerBox)
|
|
97
|
+
|
|
98
|
+
const lines = await renderToTerminal(outerBox)
|
|
99
|
+
|
|
100
|
+
const content = getPlainContent(lines)
|
|
101
|
+
|
|
102
|
+
// Should contain the nested text
|
|
103
|
+
expect(content).toContain('Inner content')
|
|
104
|
+
|
|
105
|
+
// Should have double border characters from outer box
|
|
106
|
+
expect(lines.join('')).toMatch(/[\u2550\u2554\u2557\u255A\u255D]/)
|
|
107
|
+
|
|
108
|
+
// Should have single border characters from inner box
|
|
109
|
+
expect(lines.join('')).toMatch(/[\u2500\u250C\u2510\u2514\u2518]/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('theme applies to all children', async () => {
|
|
113
|
+
const { Box, Text } = await import('../components')
|
|
114
|
+
|
|
115
|
+
// Test that styled text renders with ANSI codes
|
|
116
|
+
const textElement = React.createElement(Text, { color: 'red', bold: true }, 'Styled text')
|
|
117
|
+
const boxElement = React.createElement(
|
|
118
|
+
Box,
|
|
119
|
+
{ border: 'single' },
|
|
120
|
+
textElement
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const lines = await renderToTerminal(boxElement)
|
|
124
|
+
|
|
125
|
+
// Output should contain ANSI codes (theme colors applied)
|
|
126
|
+
const output = lines.join('')
|
|
127
|
+
expect(hasAnsiCodes(output)).toBe(true)
|
|
128
|
+
|
|
129
|
+
// Content should still be present
|
|
130
|
+
expect(getPlainContent(lines)).toContain('Styled text')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('focus state affects rendering', async () => {
|
|
134
|
+
const { Box, Input } = await import('../components')
|
|
135
|
+
|
|
136
|
+
// Unfocused input
|
|
137
|
+
const unfocusedInput = React.createElement(Input, {
|
|
138
|
+
value: 'test',
|
|
139
|
+
focused: false,
|
|
140
|
+
onChange: () => {},
|
|
141
|
+
})
|
|
142
|
+
const unfocusedLines = await renderToTerminal(
|
|
143
|
+
React.createElement(Box, {}, unfocusedInput)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// Focused input
|
|
147
|
+
const focusedInput = React.createElement(Input, {
|
|
148
|
+
value: 'test',
|
|
149
|
+
focused: true,
|
|
150
|
+
onChange: () => {},
|
|
151
|
+
})
|
|
152
|
+
const focusedLines = await renderToTerminal(
|
|
153
|
+
React.createElement(Box, {}, focusedInput)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// Focused input should have different styling (more ANSI codes for cursor/highlight)
|
|
157
|
+
const unfocusedOutput = unfocusedLines.join('')
|
|
158
|
+
const focusedOutput = focusedLines.join('')
|
|
159
|
+
|
|
160
|
+
// Both should contain the value
|
|
161
|
+
expect(stripAnsi(unfocusedOutput)).toContain('test')
|
|
162
|
+
expect(stripAnsi(focusedOutput)).toContain('test')
|
|
163
|
+
|
|
164
|
+
// Focused should have cursor indicator (inverse video or underline)
|
|
165
|
+
expect(focusedOutput).toMatch(/\x1b\[7m|\x1b\[4m/)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// 2. Keyboard Integration Tests
|
|
171
|
+
// ============================================================================
|
|
172
|
+
|
|
173
|
+
describe('Keyboard Integration', () => {
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
vi.resetModules()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('key press updates component state', async () => {
|
|
179
|
+
const { createKeyboardManager, VIM_BINDINGS } = await import('..')
|
|
180
|
+
|
|
181
|
+
// Track state changes
|
|
182
|
+
let currentIndex = 0
|
|
183
|
+
const onAction = vi.fn((action: string) => {
|
|
184
|
+
if (action === 'move-down') currentIndex++
|
|
185
|
+
if (action === 'move-up') currentIndex--
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const manager = createKeyboardManager({
|
|
189
|
+
bindings: VIM_BINDINGS,
|
|
190
|
+
onAction,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Simulate key presses
|
|
194
|
+
manager.handleKey('j') // move down
|
|
195
|
+
expect(currentIndex).toBe(1)
|
|
196
|
+
expect(onAction).toHaveBeenCalledWith('move-down', expect.any(Object))
|
|
197
|
+
|
|
198
|
+
manager.handleKey('j') // move down
|
|
199
|
+
expect(currentIndex).toBe(2)
|
|
200
|
+
|
|
201
|
+
manager.handleKey('k') // move up
|
|
202
|
+
expect(currentIndex).toBe(1)
|
|
203
|
+
expect(onAction).toHaveBeenCalledWith('move-up', expect.any(Object))
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('focus changes update rendering via props', async () => {
|
|
207
|
+
const { Input } = await import('../components')
|
|
208
|
+
|
|
209
|
+
// Test that focus prop changes rendering
|
|
210
|
+
const unfocusedInput = React.createElement(Input, {
|
|
211
|
+
value: 'test',
|
|
212
|
+
focused: false,
|
|
213
|
+
onChange: () => {},
|
|
214
|
+
})
|
|
215
|
+
const unfocusedLines = await renderToTerminal(unfocusedInput)
|
|
216
|
+
|
|
217
|
+
const focusedInput = React.createElement(Input, {
|
|
218
|
+
value: 'test',
|
|
219
|
+
focused: true,
|
|
220
|
+
onChange: () => {},
|
|
221
|
+
})
|
|
222
|
+
const focusedLines = await renderToTerminal(focusedInput)
|
|
223
|
+
|
|
224
|
+
// Both should contain the value
|
|
225
|
+
expect(getPlainContent(unfocusedLines)).toContain('test')
|
|
226
|
+
expect(getPlainContent(focusedLines)).toContain('test')
|
|
227
|
+
|
|
228
|
+
// Focused state should produce different output
|
|
229
|
+
const unfocusedOutput = unfocusedLines.join('')
|
|
230
|
+
const focusedOutput = focusedLines.join('')
|
|
231
|
+
|
|
232
|
+
// Focused input should have cursor indicator (inverse video or underline)
|
|
233
|
+
expect(focusedOutput).toMatch(/\x1b\[7m|\x1b\[4m/)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('navigation state tracked via keyboard manager', async () => {
|
|
237
|
+
const { createKeyboardManager, VIM_BINDINGS } = await import('..')
|
|
238
|
+
|
|
239
|
+
// Simulate navigable list state management
|
|
240
|
+
const items = ['Apple', 'Banana', 'Cherry', 'Date']
|
|
241
|
+
let currentIndex = 0
|
|
242
|
+
|
|
243
|
+
const onAction = vi.fn((action: string) => {
|
|
244
|
+
if (action === 'move-down') {
|
|
245
|
+
currentIndex = Math.min(currentIndex + 1, items.length - 1)
|
|
246
|
+
}
|
|
247
|
+
if (action === 'move-up') {
|
|
248
|
+
currentIndex = Math.max(currentIndex - 1, 0)
|
|
249
|
+
}
|
|
250
|
+
if (action === 'move-first') {
|
|
251
|
+
currentIndex = 0
|
|
252
|
+
}
|
|
253
|
+
if (action === 'move-last') {
|
|
254
|
+
currentIndex = items.length - 1
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const manager = createKeyboardManager({
|
|
259
|
+
bindings: VIM_BINDINGS,
|
|
260
|
+
onAction,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Initially at first item
|
|
264
|
+
expect(currentIndex).toBe(0)
|
|
265
|
+
expect(items[currentIndex]).toBe('Apple')
|
|
266
|
+
|
|
267
|
+
// Move down
|
|
268
|
+
manager.handleKey('j')
|
|
269
|
+
expect(currentIndex).toBe(1)
|
|
270
|
+
expect(items[currentIndex]).toBe('Banana')
|
|
271
|
+
|
|
272
|
+
// Move to last (gg sequence for move-first, G for move-last)
|
|
273
|
+
manager.handleKey('G')
|
|
274
|
+
expect(currentIndex).toBe(3)
|
|
275
|
+
expect(items[currentIndex]).toBe('Date')
|
|
276
|
+
|
|
277
|
+
// Move to first
|
|
278
|
+
manager.handleKey('g')
|
|
279
|
+
manager.handleKey('g')
|
|
280
|
+
expect(currentIndex).toBe(0)
|
|
281
|
+
expect(items[currentIndex]).toBe('Apple')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('actions trigger callbacks', async () => {
|
|
285
|
+
const { createKeyboardManager } = await import('..')
|
|
286
|
+
|
|
287
|
+
const onSelect = vi.fn()
|
|
288
|
+
const onBack = vi.fn()
|
|
289
|
+
const onQuit = vi.fn()
|
|
290
|
+
|
|
291
|
+
const manager = createKeyboardManager({
|
|
292
|
+
bindings: {
|
|
293
|
+
enter: 'select',
|
|
294
|
+
escape: 'back',
|
|
295
|
+
q: 'quit',
|
|
296
|
+
},
|
|
297
|
+
onAction: (action) => {
|
|
298
|
+
if (action === 'select') onSelect()
|
|
299
|
+
if (action === 'back') onBack()
|
|
300
|
+
if (action === 'quit') onQuit()
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
manager.handleKey('enter')
|
|
305
|
+
expect(onSelect).toHaveBeenCalled()
|
|
306
|
+
|
|
307
|
+
manager.handleKey('escape')
|
|
308
|
+
expect(onBack).toHaveBeenCalled()
|
|
309
|
+
|
|
310
|
+
manager.handleKey('q')
|
|
311
|
+
expect(onQuit).toHaveBeenCalled()
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// ============================================================================
|
|
316
|
+
// 3. Real Components Tests
|
|
317
|
+
// ============================================================================
|
|
318
|
+
|
|
319
|
+
describe('Real Components', () => {
|
|
320
|
+
beforeEach(() => {
|
|
321
|
+
vi.resetModules()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('dashboard with sidebar and content renders', async () => {
|
|
325
|
+
const { Box, SidebarItem, Text } = await import('../components')
|
|
326
|
+
|
|
327
|
+
// Build a dashboard layout - render sidebar items individually to verify they work
|
|
328
|
+
const sidebarItem1 = React.createElement(SidebarItem, { label: 'Dashboard', icon: '\u2302', active: true })
|
|
329
|
+
const sidebarItem2 = React.createElement(SidebarItem, { label: 'Settings', icon: '\u2699' })
|
|
330
|
+
|
|
331
|
+
const lines1 = await renderToTerminal(sidebarItem1)
|
|
332
|
+
const lines2 = await renderToTerminal(sidebarItem2)
|
|
333
|
+
|
|
334
|
+
// Each sidebar item should render its label
|
|
335
|
+
expect(getPlainContent(lines1)).toContain('Dashboard')
|
|
336
|
+
expect(getPlainContent(lines2)).toContain('Settings')
|
|
337
|
+
|
|
338
|
+
// Now test the main content area
|
|
339
|
+
const content = React.createElement(
|
|
340
|
+
Box,
|
|
341
|
+
{ flexDirection: 'column' },
|
|
342
|
+
React.createElement(Text, { bold: true }, 'Welcome to Dashboard'),
|
|
343
|
+
React.createElement(Text, {}, 'Select an option from the sidebar')
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
const contentLines = await renderToTerminal(content)
|
|
347
|
+
const plainContent = getPlainContent(contentLines)
|
|
348
|
+
|
|
349
|
+
// Should contain main content
|
|
350
|
+
expect(plainContent).toContain('Welcome to Dashboard')
|
|
351
|
+
expect(plainContent).toContain('Select an option from the sidebar')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('form with inputs and buttons renders', async () => {
|
|
355
|
+
const { Input, Button, Text } = await import('../components')
|
|
356
|
+
|
|
357
|
+
// Test individual form components to verify they render correctly
|
|
358
|
+
const title = React.createElement(Text, { bold: true }, 'Login Form')
|
|
359
|
+
const titleLines = await renderToTerminal(title)
|
|
360
|
+
expect(getPlainContent(titleLines)).toContain('Login Form')
|
|
361
|
+
|
|
362
|
+
// Test input with label
|
|
363
|
+
const usernameInput = React.createElement(Input, {
|
|
364
|
+
label: 'Username',
|
|
365
|
+
value: '',
|
|
366
|
+
placeholder: 'Enter username',
|
|
367
|
+
onChange: () => {},
|
|
368
|
+
})
|
|
369
|
+
const inputLines = await renderToTerminal(usernameInput)
|
|
370
|
+
const inputContent = getPlainContent(inputLines)
|
|
371
|
+
// Input should contain label and/or placeholder
|
|
372
|
+
expect(inputContent).toMatch(/Username|Enter username/)
|
|
373
|
+
|
|
374
|
+
// Test password input (masked)
|
|
375
|
+
const passwordInput = React.createElement(Input, {
|
|
376
|
+
value: 'secret123',
|
|
377
|
+
type: 'password',
|
|
378
|
+
onChange: () => {},
|
|
379
|
+
})
|
|
380
|
+
const passwordLines = await renderToTerminal(passwordInput)
|
|
381
|
+
const passwordContent = getPlainContent(passwordLines)
|
|
382
|
+
// Password should be masked (not showing actual value)
|
|
383
|
+
expect(passwordContent).not.toContain('secret123')
|
|
384
|
+
|
|
385
|
+
// Test buttons
|
|
386
|
+
const loginButton = React.createElement(Button, { variant: 'primary', onPress: () => {} }, 'Login')
|
|
387
|
+
const cancelButton = React.createElement(Button, { variant: 'default', onPress: () => {} }, 'Cancel')
|
|
388
|
+
|
|
389
|
+
const loginLines = await renderToTerminal(loginButton)
|
|
390
|
+
const cancelLines = await renderToTerminal(cancelButton)
|
|
391
|
+
|
|
392
|
+
expect(getPlainContent(loginLines)).toContain('Login')
|
|
393
|
+
expect(getPlainContent(cancelLines)).toContain('Cancel')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('table with data renders', async () => {
|
|
397
|
+
const { Table } = await import('../components')
|
|
398
|
+
|
|
399
|
+
interface User {
|
|
400
|
+
name: string
|
|
401
|
+
email: string
|
|
402
|
+
role: string
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const data: User[] = [
|
|
406
|
+
{ name: 'Alice', email: 'alice@example.com', role: 'Admin' },
|
|
407
|
+
{ name: 'Bob', email: 'bob@example.com', role: 'User' },
|
|
408
|
+
{ name: 'Charlie', email: 'charlie@example.com', role: 'User' },
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
const columns = [
|
|
412
|
+
{ key: 'name' as const, header: 'Name', width: 15 },
|
|
413
|
+
{ key: 'email' as const, header: 'Email', width: 25 },
|
|
414
|
+
{ key: 'role' as const, header: 'Role', width: 10 },
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
const table = React.createElement(Table, { data, columns })
|
|
418
|
+
|
|
419
|
+
const lines = await renderToTerminal(table)
|
|
420
|
+
const plainContent = getPlainContent(lines)
|
|
421
|
+
|
|
422
|
+
// Should contain headers
|
|
423
|
+
expect(plainContent).toContain('Name')
|
|
424
|
+
expect(plainContent).toContain('Email')
|
|
425
|
+
expect(plainContent).toContain('Role')
|
|
426
|
+
|
|
427
|
+
// Should contain data
|
|
428
|
+
expect(plainContent).toContain('Alice')
|
|
429
|
+
expect(plainContent).toContain('alice@example.com')
|
|
430
|
+
expect(plainContent).toContain('Admin')
|
|
431
|
+
expect(plainContent).toContain('Bob')
|
|
432
|
+
expect(plainContent).toContain('Charlie')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('dialog modal renders', async () => {
|
|
436
|
+
const { Dialog, Box, Text, Button } = await import('../components')
|
|
437
|
+
|
|
438
|
+
const dialogContent = React.createElement(
|
|
439
|
+
Box,
|
|
440
|
+
{ flexDirection: 'column', gap: 1 },
|
|
441
|
+
React.createElement(Text, {}, 'Are you sure you want to delete this item?'),
|
|
442
|
+
React.createElement(
|
|
443
|
+
Box,
|
|
444
|
+
{ flexDirection: 'row', gap: 2 },
|
|
445
|
+
React.createElement(Button, { variant: 'destructive', onPress: () => {} }, 'Delete'),
|
|
446
|
+
React.createElement(Button, { variant: 'default', onPress: () => {} }, 'Cancel')
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
const dialog = React.createElement(Dialog, {
|
|
451
|
+
open: true,
|
|
452
|
+
title: 'Confirm Deletion',
|
|
453
|
+
onClose: () => {},
|
|
454
|
+
children: dialogContent,
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
const lines = await renderToTerminal(dialog)
|
|
458
|
+
const plainContent = getPlainContent(lines)
|
|
459
|
+
|
|
460
|
+
// Should contain dialog title
|
|
461
|
+
expect(plainContent).toContain('Confirm Deletion')
|
|
462
|
+
|
|
463
|
+
// Should contain dialog content
|
|
464
|
+
expect(plainContent).toContain('Are you sure you want to delete this item?')
|
|
465
|
+
expect(plainContent).toContain('Delete')
|
|
466
|
+
expect(plainContent).toContain('Cancel')
|
|
467
|
+
|
|
468
|
+
// Should have modal styling (border)
|
|
469
|
+
const output = lines.join('')
|
|
470
|
+
expect(output).toMatch(/[\u250C\u2554\u256D]/) // top-left border corners
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
// ============================================================================
|
|
475
|
+
// 4. Terminal Capabilities Tests
|
|
476
|
+
// ============================================================================
|
|
477
|
+
|
|
478
|
+
describe('Terminal Capabilities', () => {
|
|
479
|
+
beforeEach(() => {
|
|
480
|
+
vi.resetModules()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('color degradation works (256 to 16)', async () => {
|
|
484
|
+
const { degradeColor } = await import('../theme')
|
|
485
|
+
|
|
486
|
+
// ANSI 256 blue (color 33)
|
|
487
|
+
const color256 = '\x1b[38;5;33m'
|
|
488
|
+
|
|
489
|
+
// Degrade to 16 colors
|
|
490
|
+
const color16 = degradeColor(color256, '16')
|
|
491
|
+
|
|
492
|
+
// Should be a basic 16-color code (30-37 or 90-97)
|
|
493
|
+
expect(color16).toMatch(/\x1b\[3[0-7]m|\x1b\[9[0-7]m/)
|
|
494
|
+
|
|
495
|
+
// Should not be empty
|
|
496
|
+
expect(color16.length).toBeGreaterThan(0)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('color degradation returns empty for none support', async () => {
|
|
500
|
+
const { degradeColor } = await import('../theme')
|
|
501
|
+
|
|
502
|
+
const color256 = '\x1b[38;5;33m'
|
|
503
|
+
const colorNone = degradeColor(color256, 'none')
|
|
504
|
+
|
|
505
|
+
// Should return empty string for no color support
|
|
506
|
+
expect(colorNone).toBe('')
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('truecolor to 256 conversion works', async () => {
|
|
510
|
+
const { degradeColor } = await import('../theme')
|
|
511
|
+
|
|
512
|
+
// Truecolor blue
|
|
513
|
+
const truecolor = '\x1b[38;2;59;130;246m'
|
|
514
|
+
|
|
515
|
+
// Degrade to 256 colors
|
|
516
|
+
const color256 = degradeColor(truecolor, '256')
|
|
517
|
+
|
|
518
|
+
// Should be a valid 256-color code
|
|
519
|
+
expect(color256).toMatch(/\x1b\[38;5;\d+m/)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('size constraints respected', async () => {
|
|
523
|
+
const { Box, Text } = await import('../components')
|
|
524
|
+
|
|
525
|
+
// Create a box with fixed dimensions
|
|
526
|
+
const constrainedBox = React.createElement(
|
|
527
|
+
Box,
|
|
528
|
+
{ width: 30, height: 5, border: 'single' },
|
|
529
|
+
React.createElement(Text, {}, 'Short content')
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
const lines = await renderToTerminal(constrainedBox)
|
|
533
|
+
|
|
534
|
+
// Should have exactly 5 lines (the specified height)
|
|
535
|
+
expect(lines.length).toBe(5)
|
|
536
|
+
|
|
537
|
+
// Content should be within the box
|
|
538
|
+
const content = getPlainContent(lines)
|
|
539
|
+
expect(content).toContain('Short content')
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('ANSI codes properly formatted in output', async () => {
|
|
543
|
+
const { Text } = await import('../components')
|
|
544
|
+
|
|
545
|
+
// Create styled text
|
|
546
|
+
const styledText = React.createElement(
|
|
547
|
+
Text,
|
|
548
|
+
{ bold: true, color: 'red', underline: true },
|
|
549
|
+
'Styled content'
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
const lines = await renderToTerminal(styledText)
|
|
553
|
+
const output = lines.join('')
|
|
554
|
+
|
|
555
|
+
// Should contain bold ANSI code
|
|
556
|
+
expect(output).toContain('\x1b[1m')
|
|
557
|
+
|
|
558
|
+
// Should contain red foreground ANSI code
|
|
559
|
+
expect(output).toContain('\x1b[31m')
|
|
560
|
+
|
|
561
|
+
// Should contain underline ANSI code
|
|
562
|
+
expect(output).toContain('\x1b[4m')
|
|
563
|
+
|
|
564
|
+
// Should contain reset ANSI code at end
|
|
565
|
+
expect(output).toContain('\x1b[0m')
|
|
566
|
+
|
|
567
|
+
// Content should still be present
|
|
568
|
+
expect(stripAnsi(output)).toContain('Styled content')
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('detectColorSupport returns valid level', async () => {
|
|
572
|
+
const { detectColorSupport } = await import('../theme')
|
|
573
|
+
|
|
574
|
+
const support = detectColorSupport()
|
|
575
|
+
|
|
576
|
+
// Should return one of the valid levels
|
|
577
|
+
expect(['none', '16', '256', 'truecolor']).toContain(support)
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
// ============================================================================
|
|
582
|
+
// 5. Component Composition Tests
|
|
583
|
+
// ============================================================================
|
|
584
|
+
|
|
585
|
+
describe('Component Composition', () => {
|
|
586
|
+
beforeEach(() => {
|
|
587
|
+
vi.resetModules()
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('Badge inside Text renders correctly', async () => {
|
|
591
|
+
const { Box, Text, Badge } = await import('../components')
|
|
592
|
+
|
|
593
|
+
const element = React.createElement(
|
|
594
|
+
Box,
|
|
595
|
+
{},
|
|
596
|
+
React.createElement(Text, {}, 'Status: '),
|
|
597
|
+
React.createElement(Badge, { variant: 'success' }, 'Active')
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
const lines = await renderToTerminal(element)
|
|
601
|
+
const content = getPlainContent(lines)
|
|
602
|
+
|
|
603
|
+
expect(content).toContain('Status:')
|
|
604
|
+
expect(content).toContain('Active')
|
|
605
|
+
|
|
606
|
+
// Badge should have some styling (ANSI codes)
|
|
607
|
+
// Success variant should have color styling
|
|
608
|
+
const output = lines.join('')
|
|
609
|
+
// Verify badge renders content - styling is implementation detail
|
|
610
|
+
expect(content).toMatch(/Active/)
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('Breadcrumb navigation renders', async () => {
|
|
614
|
+
const { Breadcrumb } = await import('../components')
|
|
615
|
+
|
|
616
|
+
const breadcrumb = React.createElement(Breadcrumb, {
|
|
617
|
+
items: [
|
|
618
|
+
{ label: 'Home', path: '/' },
|
|
619
|
+
{ label: 'Products', path: '/products' },
|
|
620
|
+
{ label: 'Electronics', path: '/products/electronics' },
|
|
621
|
+
{ label: 'Phones' }, // Current page (no path)
|
|
622
|
+
],
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
const lines = await renderToTerminal(breadcrumb)
|
|
626
|
+
const content = getPlainContent(lines)
|
|
627
|
+
|
|
628
|
+
// All items should be rendered
|
|
629
|
+
expect(content).toContain('Home')
|
|
630
|
+
expect(content).toContain('Products')
|
|
631
|
+
expect(content).toContain('Electronics')
|
|
632
|
+
expect(content).toContain('Phones')
|
|
633
|
+
|
|
634
|
+
// Should have separators
|
|
635
|
+
expect(content).toMatch(/[\/>\u203A\u2192]/)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('Spinner animation frame renders', async () => {
|
|
639
|
+
const { Spinner } = await import('../components')
|
|
640
|
+
|
|
641
|
+
const spinner = React.createElement(Spinner, { label: 'Loading...' })
|
|
642
|
+
|
|
643
|
+
const lines = await renderToTerminal(spinner)
|
|
644
|
+
const content = getPlainContent(lines)
|
|
645
|
+
|
|
646
|
+
// Should contain the label
|
|
647
|
+
expect(content).toContain('Loading...')
|
|
648
|
+
|
|
649
|
+
// Should contain a spinner frame character
|
|
650
|
+
// Common spinner chars: dots, braille patterns, bars
|
|
651
|
+
expect(lines.join('')).toMatch(/[\u280B\u2819\u2839\u28B9\u28F9\u28FC\u28E4\u2846|\\/-]/)
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('List with selection renders', async () => {
|
|
655
|
+
const { List } = await import('../components')
|
|
656
|
+
|
|
657
|
+
const list = React.createElement(List, {
|
|
658
|
+
items: ['First item', 'Second item', 'Third item'],
|
|
659
|
+
selectedIndex: 1,
|
|
660
|
+
onSelect: () => {},
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
const lines = await renderToTerminal(list)
|
|
664
|
+
const content = getPlainContent(lines)
|
|
665
|
+
|
|
666
|
+
// All items should be present
|
|
667
|
+
expect(content).toContain('First item')
|
|
668
|
+
expect(content).toContain('Second item')
|
|
669
|
+
expect(content).toContain('Third item')
|
|
670
|
+
|
|
671
|
+
// Selected item should have highlight styling
|
|
672
|
+
const output = lines.join('')
|
|
673
|
+
expect(hasAnsiCodes(output)).toBe(true)
|
|
674
|
+
})
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
// ============================================================================
|
|
678
|
+
// 6. Theme Provider Integration Tests
|
|
679
|
+
// ============================================================================
|
|
680
|
+
|
|
681
|
+
describe('Theme Provider Integration', () => {
|
|
682
|
+
beforeEach(() => {
|
|
683
|
+
vi.resetModules()
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('dark theme provides dark mode colors', async () => {
|
|
687
|
+
const { createTerminalTheme } = await import('../theme')
|
|
688
|
+
|
|
689
|
+
const darkTheme = createTerminalTheme({ mode: 'dark' })
|
|
690
|
+
|
|
691
|
+
expect(darkTheme.mode).toBe('dark')
|
|
692
|
+
expect(darkTheme.colors.foreground).toBeDefined()
|
|
693
|
+
expect(darkTheme.colors.background).toBeDefined()
|
|
694
|
+
expect(darkTheme.colors.primary).toBeDefined()
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('light theme provides light mode colors', async () => {
|
|
698
|
+
const { createTerminalTheme } = await import('../theme')
|
|
699
|
+
|
|
700
|
+
const lightTheme = createTerminalTheme({ mode: 'light' })
|
|
701
|
+
|
|
702
|
+
expect(lightTheme.mode).toBe('light')
|
|
703
|
+
expect(lightTheme.colors.foreground).toBeDefined()
|
|
704
|
+
expect(lightTheme.colors.background).toBeDefined()
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it('createTerminalTheme creates usable theme', async () => {
|
|
708
|
+
const { createTerminalTheme } = await import('../theme')
|
|
709
|
+
|
|
710
|
+
const darkTheme = createTerminalTheme({ mode: 'dark' })
|
|
711
|
+
|
|
712
|
+
expect(darkTheme.mode).toBe('dark')
|
|
713
|
+
expect(darkTheme.colors.foreground).toBeDefined()
|
|
714
|
+
expect(typeof darkTheme.colors.primary).toBe('string')
|
|
715
|
+
expect(darkTheme.colors.primary).toContain('\x1b[') // ANSI code
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
// ============================================================================
|
|
720
|
+
// 7. Focus Provider Integration Tests
|
|
721
|
+
// ============================================================================
|
|
722
|
+
|
|
723
|
+
describe('Focus Provider Integration', () => {
|
|
724
|
+
beforeEach(() => {
|
|
725
|
+
vi.resetModules()
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('FocusContext is exported and available', async () => {
|
|
729
|
+
const { FocusContext } = await import('..')
|
|
730
|
+
|
|
731
|
+
// FocusContext should be a valid React context
|
|
732
|
+
expect(FocusContext).toBeDefined()
|
|
733
|
+
expect(FocusContext.Provider).toBeDefined()
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it('createKeyboardManager can track focus state externally', async () => {
|
|
737
|
+
const { createKeyboardManager, COMMON_BINDINGS } = await import('..')
|
|
738
|
+
|
|
739
|
+
// Simulate focus management with keyboard manager
|
|
740
|
+
let focusedIndex = 0
|
|
741
|
+
const focusableElements = ['input-1', 'input-2', 'button-1']
|
|
742
|
+
|
|
743
|
+
const onAction = vi.fn((action: string) => {
|
|
744
|
+
if (action === 'focus-next') {
|
|
745
|
+
focusedIndex = (focusedIndex + 1) % focusableElements.length
|
|
746
|
+
}
|
|
747
|
+
if (action === 'focus-prev') {
|
|
748
|
+
focusedIndex = (focusedIndex - 1 + focusableElements.length) % focusableElements.length
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
const manager = createKeyboardManager({
|
|
753
|
+
bindings: COMMON_BINDINGS,
|
|
754
|
+
onAction,
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
// Tab should move focus forward
|
|
758
|
+
manager.handleKey('tab')
|
|
759
|
+
expect(focusedIndex).toBe(1)
|
|
760
|
+
|
|
761
|
+
manager.handleKey('tab')
|
|
762
|
+
expect(focusedIndex).toBe(2)
|
|
763
|
+
|
|
764
|
+
// Shift+Tab should move focus backward
|
|
765
|
+
manager.handleKey('shift+tab')
|
|
766
|
+
expect(focusedIndex).toBe(1)
|
|
767
|
+
})
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
// ============================================================================
|
|
771
|
+
// 8. Grid Navigation Tests
|
|
772
|
+
// ============================================================================
|
|
773
|
+
|
|
774
|
+
describe('Grid Navigation', () => {
|
|
775
|
+
beforeEach(() => {
|
|
776
|
+
vi.resetModules()
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
it('keyboard manager supports 2D grid navigation', async () => {
|
|
780
|
+
const { createKeyboardManager, VIM_BINDINGS, ARROW_BINDINGS } = await import('..')
|
|
781
|
+
|
|
782
|
+
// Simulate 2D grid state
|
|
783
|
+
const rows = 3
|
|
784
|
+
const cols = 4
|
|
785
|
+
let row = 0
|
|
786
|
+
let col = 0
|
|
787
|
+
|
|
788
|
+
const onAction = vi.fn((action: string) => {
|
|
789
|
+
switch (action) {
|
|
790
|
+
case 'move-up':
|
|
791
|
+
row = Math.max(0, row - 1)
|
|
792
|
+
break
|
|
793
|
+
case 'move-down':
|
|
794
|
+
row = Math.min(rows - 1, row + 1)
|
|
795
|
+
break
|
|
796
|
+
case 'move-left':
|
|
797
|
+
col = Math.max(0, col - 1)
|
|
798
|
+
break
|
|
799
|
+
case 'move-right':
|
|
800
|
+
col = Math.min(cols - 1, col + 1)
|
|
801
|
+
break
|
|
802
|
+
}
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
const manager = createKeyboardManager({
|
|
806
|
+
bindings: { ...VIM_BINDINGS, ...ARROW_BINDINGS },
|
|
807
|
+
onAction,
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
// Initial position
|
|
811
|
+
expect(row).toBe(0)
|
|
812
|
+
expect(col).toBe(0)
|
|
813
|
+
|
|
814
|
+
// Move right (l or right arrow)
|
|
815
|
+
manager.handleKey('l')
|
|
816
|
+
expect(col).toBe(1)
|
|
817
|
+
|
|
818
|
+
manager.handleKey('right')
|
|
819
|
+
expect(col).toBe(2)
|
|
820
|
+
|
|
821
|
+
// Move down (j or down arrow)
|
|
822
|
+
manager.handleKey('j')
|
|
823
|
+
expect(row).toBe(1)
|
|
824
|
+
|
|
825
|
+
manager.handleKey('down')
|
|
826
|
+
expect(row).toBe(2)
|
|
827
|
+
|
|
828
|
+
// Move left (h or left arrow)
|
|
829
|
+
manager.handleKey('h')
|
|
830
|
+
expect(col).toBe(1)
|
|
831
|
+
|
|
832
|
+
// Move up (k or up arrow)
|
|
833
|
+
manager.handleKey('k')
|
|
834
|
+
expect(row).toBe(1)
|
|
835
|
+
|
|
836
|
+
// Boundary checking - should not go past edges
|
|
837
|
+
manager.handleKey('up') // row = 0
|
|
838
|
+
manager.handleKey('up') // should stay at 0
|
|
839
|
+
expect(row).toBe(0)
|
|
840
|
+
|
|
841
|
+
manager.handleKey('left') // col = 0
|
|
842
|
+
manager.handleKey('left') // should stay at 0
|
|
843
|
+
expect(col).toBe(0)
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
// ============================================================================
|
|
848
|
+
// 9. Error Handling Tests
|
|
849
|
+
// ============================================================================
|
|
850
|
+
|
|
851
|
+
describe('Error Handling', () => {
|
|
852
|
+
beforeEach(() => {
|
|
853
|
+
vi.resetModules()
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it('renders gracefully with empty children', async () => {
|
|
857
|
+
const { Box } = await import('../components')
|
|
858
|
+
|
|
859
|
+
const emptyBox = React.createElement(Box, { border: 'single', width: 10, height: 3 })
|
|
860
|
+
|
|
861
|
+
const lines = await renderToTerminal(emptyBox)
|
|
862
|
+
|
|
863
|
+
// Should still render the box structure
|
|
864
|
+
expect(lines.length).toBeGreaterThan(0)
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it('handles null children without crashing', async () => {
|
|
868
|
+
const { Box, Text } = await import('../components')
|
|
869
|
+
|
|
870
|
+
const element = React.createElement(
|
|
871
|
+
Box,
|
|
872
|
+
{},
|
|
873
|
+
React.createElement(Text, {}, 'Before'),
|
|
874
|
+
null,
|
|
875
|
+
React.createElement(Text, {}, 'After')
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
const lines = await renderToTerminal(element)
|
|
879
|
+
const content = getPlainContent(lines)
|
|
880
|
+
|
|
881
|
+
expect(content).toContain('Before')
|
|
882
|
+
expect(content).toContain('After')
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it('disabled keyboard manager ignores keys', async () => {
|
|
886
|
+
const { createKeyboardManager } = await import('..')
|
|
887
|
+
|
|
888
|
+
const onAction = vi.fn()
|
|
889
|
+
const manager = createKeyboardManager({
|
|
890
|
+
bindings: { j: 'move-down' },
|
|
891
|
+
onAction,
|
|
892
|
+
enabled: false,
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
// Should not trigger when disabled
|
|
896
|
+
manager.handleKey('j')
|
|
897
|
+
expect(onAction).not.toHaveBeenCalled()
|
|
898
|
+
|
|
899
|
+
// Should work after enabling
|
|
900
|
+
manager.enable()
|
|
901
|
+
manager.handleKey('j')
|
|
902
|
+
expect(onAction).toHaveBeenCalledWith('move-down', expect.any(Object))
|
|
903
|
+
})
|
|
904
|
+
})
|
|
905
|
+
})
|