@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.
Files changed (191) hide show
  1. package/README.md +571 -0
  2. package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
  3. package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
  4. package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
  5. package/dist/chunk-3EFDH7PK.js +5235 -0
  6. package/dist/chunk-3RG5ZIWI.js +10 -0
  7. package/dist/chunk-3X5IR6WE.js +884 -0
  8. package/dist/chunk-4FV5ZDCE.js +5236 -0
  9. package/dist/chunk-4OVMSF2J.js +243 -0
  10. package/dist/chunk-63FEETIS.js +4048 -0
  11. package/dist/chunk-B43KP7XJ.js +884 -0
  12. package/dist/chunk-BMTJXWUV.js +655 -0
  13. package/dist/chunk-C3SVH4N7.js +882 -0
  14. package/dist/chunk-EVWR7Y47.js +874 -0
  15. package/dist/chunk-F6A5VWUC.js +1285 -0
  16. package/dist/chunk-FD7KW7GE.js +882 -0
  17. package/dist/chunk-GBQ6UD6I.js +655 -0
  18. package/dist/chunk-GMDD3M6U.js +5227 -0
  19. package/dist/chunk-JBHRXOXM.js +1058 -0
  20. package/dist/chunk-JFOO3EYO.js +1182 -0
  21. package/dist/chunk-JQ5H3WXL.js +1291 -0
  22. package/dist/chunk-JQD5NASE.js +234 -0
  23. package/dist/chunk-KRHJP5R7.js +592 -0
  24. package/dist/chunk-KWF6WVJE.js +962 -0
  25. package/dist/chunk-LHYQVN3H.js +1038 -0
  26. package/dist/chunk-M3TLQLGC.js +1032 -0
  27. package/dist/chunk-MVW4Q5OP.js +240 -0
  28. package/dist/chunk-NXCZSWLU.js +1294 -0
  29. package/dist/chunk-O25TNRO6.js +607 -0
  30. package/dist/chunk-PNECDA2I.js +884 -0
  31. package/dist/chunk-QIHWRLJR.js +962 -0
  32. package/dist/chunk-QW5YMQ7K.js +882 -0
  33. package/dist/chunk-R5U7XKVJ.js +16 -0
  34. package/dist/chunk-RP2MVQLR.js +962 -0
  35. package/dist/chunk-TP6RXGXA.js +1087 -0
  36. package/dist/chunk-TQQSTITZ.js +655 -0
  37. package/dist/chunk-X24GWXQV.js +1281 -0
  38. package/dist/components/index.d.ts +802 -0
  39. package/dist/components/index.js +149 -0
  40. package/dist/data/index.d.ts +2554 -0
  41. package/dist/data/index.js +51 -0
  42. package/dist/forms/index.d.ts +1596 -0
  43. package/dist/forms/index.js +464 -0
  44. package/dist/index-CQRFZntR.d.ts +867 -0
  45. package/dist/index.d.ts +579 -0
  46. package/dist/index.js +786 -0
  47. package/dist/interactive-D0JkWosD.d.ts +217 -0
  48. package/dist/keyboard/index.d.ts +2 -0
  49. package/dist/keyboard/index.js +43 -0
  50. package/dist/renderers/index.d.ts +546 -0
  51. package/dist/renderers/index.js +2157 -0
  52. package/dist/storybook/index.d.ts +396 -0
  53. package/dist/storybook/index.js +641 -0
  54. package/dist/theme/index.d.ts +1339 -0
  55. package/dist/theme/index.js +123 -0
  56. package/dist/types-Bxu5PAgA.d.ts +710 -0
  57. package/dist/types-CIlop5Ji.d.ts +701 -0
  58. package/dist/types-Ca8p_p5X.d.ts +710 -0
  59. package/package.json +90 -0
  60. package/src/__tests__/components/data/card.test.ts +458 -0
  61. package/src/__tests__/components/data/list.test.ts +473 -0
  62. package/src/__tests__/components/data/metrics.test.ts +541 -0
  63. package/src/__tests__/components/data/table.test.ts +448 -0
  64. package/src/__tests__/components/input/field.test.ts +555 -0
  65. package/src/__tests__/components/input/form.test.ts +870 -0
  66. package/src/__tests__/components/input/search.test.ts +1238 -0
  67. package/src/__tests__/components/input/select.test.ts +658 -0
  68. package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
  69. package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
  70. package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
  71. package/src/__tests__/components/navigation/tabs.test.ts +995 -0
  72. package/src/__tests__/components.test.tsx +1197 -0
  73. package/src/__tests__/core/compiler.test.ts +986 -0
  74. package/src/__tests__/core/parser.test.ts +785 -0
  75. package/src/__tests__/core/tier-switcher.test.ts +1103 -0
  76. package/src/__tests__/core/types.test.ts +1398 -0
  77. package/src/__tests__/data/collections.test.ts +1337 -0
  78. package/src/__tests__/data/db.test.ts +1265 -0
  79. package/src/__tests__/data/reactive.test.ts +1010 -0
  80. package/src/__tests__/data/sync.test.ts +1614 -0
  81. package/src/__tests__/errors.test.ts +660 -0
  82. package/src/__tests__/forms/integration.test.ts +444 -0
  83. package/src/__tests__/integration.test.ts +905 -0
  84. package/src/__tests__/keyboard.test.ts +1791 -0
  85. package/src/__tests__/renderer.test.ts +489 -0
  86. package/src/__tests__/renderers/ansi-css.test.ts +948 -0
  87. package/src/__tests__/renderers/ansi.test.ts +1366 -0
  88. package/src/__tests__/renderers/ascii.test.ts +1360 -0
  89. package/src/__tests__/renderers/interactive.test.ts +2353 -0
  90. package/src/__tests__/renderers/markdown.test.ts +1483 -0
  91. package/src/__tests__/renderers/text.test.ts +1369 -0
  92. package/src/__tests__/renderers/unicode.test.ts +1307 -0
  93. package/src/__tests__/theme.test.ts +639 -0
  94. package/src/__tests__/utils/assertions.ts +685 -0
  95. package/src/__tests__/utils/index.ts +115 -0
  96. package/src/__tests__/utils/test-renderer.ts +381 -0
  97. package/src/__tests__/utils/utils.test.ts +560 -0
  98. package/src/components/containers/card.ts +56 -0
  99. package/src/components/containers/dialog.ts +53 -0
  100. package/src/components/containers/index.ts +9 -0
  101. package/src/components/containers/panel.ts +59 -0
  102. package/src/components/feedback/badge.ts +40 -0
  103. package/src/components/feedback/index.ts +8 -0
  104. package/src/components/feedback/spinner.ts +23 -0
  105. package/src/components/helpers.ts +81 -0
  106. package/src/components/index.ts +153 -0
  107. package/src/components/layout/breadcrumb.ts +31 -0
  108. package/src/components/layout/index.ts +10 -0
  109. package/src/components/layout/list.ts +29 -0
  110. package/src/components/layout/sidebar.ts +79 -0
  111. package/src/components/layout/table.ts +62 -0
  112. package/src/components/primitives/box.ts +95 -0
  113. package/src/components/primitives/button.ts +54 -0
  114. package/src/components/primitives/index.ts +11 -0
  115. package/src/components/primitives/input.ts +88 -0
  116. package/src/components/primitives/select.ts +97 -0
  117. package/src/components/primitives/text.ts +60 -0
  118. package/src/components/render.ts +155 -0
  119. package/src/components/templates/app.ts +43 -0
  120. package/src/components/templates/index.ts +8 -0
  121. package/src/components/templates/site.ts +54 -0
  122. package/src/components/types.ts +777 -0
  123. package/src/core/compiler.ts +718 -0
  124. package/src/core/parser.ts +127 -0
  125. package/src/core/tier-switcher.ts +607 -0
  126. package/src/core/types.ts +672 -0
  127. package/src/data/collection.ts +316 -0
  128. package/src/data/collections.ts +50 -0
  129. package/src/data/context.tsx +174 -0
  130. package/src/data/db.ts +127 -0
  131. package/src/data/hooks.ts +532 -0
  132. package/src/data/index.ts +138 -0
  133. package/src/data/reactive.ts +1225 -0
  134. package/src/data/saas-collections.ts +375 -0
  135. package/src/data/sync.ts +1213 -0
  136. package/src/data/types.ts +660 -0
  137. package/src/forms/converters.ts +512 -0
  138. package/src/forms/index.ts +133 -0
  139. package/src/forms/schemas.ts +403 -0
  140. package/src/forms/types.ts +476 -0
  141. package/src/index.ts +542 -0
  142. package/src/keyboard/focus.ts +748 -0
  143. package/src/keyboard/index.ts +96 -0
  144. package/src/keyboard/integration.ts +371 -0
  145. package/src/keyboard/manager.ts +377 -0
  146. package/src/keyboard/presets.ts +90 -0
  147. package/src/renderers/ansi-css.ts +576 -0
  148. package/src/renderers/ansi.ts +802 -0
  149. package/src/renderers/ascii.ts +680 -0
  150. package/src/renderers/breadcrumb.ts +480 -0
  151. package/src/renderers/command-palette.ts +802 -0
  152. package/src/renderers/components/field.ts +210 -0
  153. package/src/renderers/components/form.ts +327 -0
  154. package/src/renderers/components/index.ts +21 -0
  155. package/src/renderers/components/search.ts +449 -0
  156. package/src/renderers/components/select.ts +222 -0
  157. package/src/renderers/index.ts +101 -0
  158. package/src/renderers/interactive/component-handlers.ts +622 -0
  159. package/src/renderers/interactive/cursor-manager.ts +147 -0
  160. package/src/renderers/interactive/focus-manager.ts +279 -0
  161. package/src/renderers/interactive/index.ts +661 -0
  162. package/src/renderers/interactive/input-handler.ts +164 -0
  163. package/src/renderers/interactive/keyboard-handler.ts +212 -0
  164. package/src/renderers/interactive/mouse-handler.ts +167 -0
  165. package/src/renderers/interactive/state-manager.ts +109 -0
  166. package/src/renderers/interactive/types.ts +338 -0
  167. package/src/renderers/interactive-string.ts +299 -0
  168. package/src/renderers/interactive.ts +59 -0
  169. package/src/renderers/markdown.ts +950 -0
  170. package/src/renderers/sidebar.ts +549 -0
  171. package/src/renderers/tabs.ts +682 -0
  172. package/src/renderers/text.ts +791 -0
  173. package/src/renderers/unicode.ts +917 -0
  174. package/src/renderers/utils.ts +942 -0
  175. package/src/router/adapters.ts +383 -0
  176. package/src/router/types.ts +140 -0
  177. package/src/router/utils.ts +452 -0
  178. package/src/schemas.ts +205 -0
  179. package/src/storybook/index.ts +91 -0
  180. package/src/storybook/interactive-decorator.tsx +659 -0
  181. package/src/storybook/keyboard-simulator.ts +501 -0
  182. package/src/theme/ansi-codes.ts +80 -0
  183. package/src/theme/box-drawing.ts +132 -0
  184. package/src/theme/color-convert.ts +254 -0
  185. package/src/theme/color-support.ts +321 -0
  186. package/src/theme/index.ts +134 -0
  187. package/src/theme/strip-ansi.ts +50 -0
  188. package/src/theme/tailwind-map.ts +469 -0
  189. package/src/theme/text-styles.ts +206 -0
  190. package/src/theme/theme-system.ts +568 -0
  191. package/src/types.ts +103 -0
@@ -0,0 +1,1360 @@
1
+ /**
2
+ * @mdxui/terminal ASCII Renderer Tests (RED phase)
3
+ *
4
+ * TDD RED Phase: These tests define the contract for the ASCII renderer,
5
+ * which outputs basic ASCII art for low-capability terminals (no unicode, no colors).
6
+ *
7
+ * The ASCII renderer is part of the Universal Terminal UI 6-tier rendering system:
8
+ * - TEXT: Plain text without formatting
9
+ * - MARKDOWN: Markdown syntax for simple formatting
10
+ * - ASCII: ASCII art and basic drawing characters (this renderer)
11
+ * - UNICODE: Unicode box drawing and symbols
12
+ * - ANSI: Full ANSI escape sequences for colors/styles
13
+ * - INTERACTIVE: Full interactive terminal UI with input handling
14
+ *
15
+ * CRITICAL CONSTRAINTS:
16
+ * - NO unicode characters allowed in output
17
+ * - Only ASCII characters (0x00-0x7F)
18
+ * - Box drawing uses +---+ and | characters
19
+ * - Bullet points use * or -
20
+ * - Progress bars use [==== ]
21
+ *
22
+ * NOTE: These tests are expected to FAIL until implementation is complete.
23
+ * Run: pnpm --filter @mdxui/terminal test
24
+ */
25
+ import { describe, it, expect, beforeEach } from 'vitest'
26
+ import type { UINode, RenderContext, ThemeTokens } from '../../core/types'
27
+
28
+ // ============================================================================
29
+ // This import WILL FAIL until src/renderers/ascii.ts is implemented
30
+ // ============================================================================
31
+ import { renderASCII } from '../../renderers/ascii'
32
+
33
+ // ============================================================================
34
+ // Test Helpers
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Check if a string contains only ASCII characters (0x00-0x7F)
39
+ */
40
+ function isASCIIOnly(str: string): boolean {
41
+ // eslint-disable-next-line no-control-regex
42
+ return /^[\x00-\x7F]*$/.test(str)
43
+ }
44
+
45
+ /**
46
+ * Check if output contains any unicode box drawing characters
47
+ */
48
+ function containsUnicodeBoxDrawing(str: string): boolean {
49
+ // Unicode box drawing range: U+2500 to U+257F
50
+ return /[\u2500-\u257F]/.test(str)
51
+ }
52
+
53
+ /**
54
+ * Check if output contains any ANSI escape sequences
55
+ */
56
+ function containsANSI(str: string): boolean {
57
+ // ANSI escape codes start with ESC (0x1B) or \x1b
58
+ return /\x1b\[[\d;]*m/.test(str)
59
+ }
60
+
61
+ /**
62
+ * Create a minimal test context
63
+ */
64
+ function createTestContext(overrides: Partial<RenderContext> = {}): RenderContext {
65
+ const defaultTheme: ThemeTokens = {
66
+ primary: '',
67
+ secondary: '',
68
+ muted: '',
69
+ foreground: '',
70
+ background: '',
71
+ border: '',
72
+ success: '',
73
+ warning: '',
74
+ error: '',
75
+ info: '',
76
+ }
77
+
78
+ return {
79
+ tier: 'ascii',
80
+ width: 80,
81
+ height: 24,
82
+ depth: 0,
83
+ theme: defaultTheme,
84
+ interactive: false,
85
+ ...overrides,
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Create a text node
91
+ */
92
+ function textNode(content: string, props: Record<string, unknown> = {}): UINode {
93
+ return {
94
+ type: 'text',
95
+ props: { content, ...props },
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Create a box node
101
+ */
102
+ function boxNode(
103
+ children: UINode[] = [],
104
+ props: Record<string, unknown> = {}
105
+ ): UINode {
106
+ return {
107
+ type: 'box',
108
+ props: { border: 'single', ...props },
109
+ children,
110
+ }
111
+ }
112
+
113
+ // ============================================================================
114
+ // Core Renderer Contract Tests
115
+ // ============================================================================
116
+
117
+ describe('renderASCII', () => {
118
+ describe('function signature', () => {
119
+ it('exists and is a function', () => {
120
+ expect(renderASCII).toBeDefined()
121
+ expect(typeof renderASCII).toBe('function')
122
+ })
123
+
124
+ it('accepts UINode and returns string', () => {
125
+ const node: UINode = textNode('Hello')
126
+ const result = renderASCII(node)
127
+
128
+ expect(typeof result).toBe('string')
129
+ })
130
+
131
+ it('accepts optional RenderContext parameter', () => {
132
+ const node: UINode = textNode('Hello')
133
+ const context = createTestContext({ width: 40 })
134
+ const result = renderASCII(node, context)
135
+
136
+ expect(typeof result).toBe('string')
137
+ })
138
+ })
139
+
140
+ describe('ASCII-only output constraint', () => {
141
+ it('outputs only ASCII characters for text nodes', () => {
142
+ const node: UINode = textNode('Hello World')
143
+ const result = renderASCII(node)
144
+
145
+ expect(isASCIIOnly(result)).toBe(true)
146
+ })
147
+
148
+ it('outputs only ASCII characters for box nodes', () => {
149
+ const node: UINode = boxNode([textNode('Content')])
150
+ const result = renderASCII(node)
151
+
152
+ expect(isASCIIOnly(result)).toBe(true)
153
+ })
154
+
155
+ it('never outputs unicode box drawing characters', () => {
156
+ const node: UINode = boxNode([textNode('Content')], { border: 'single' })
157
+ const result = renderASCII(node)
158
+
159
+ expect(containsUnicodeBoxDrawing(result)).toBe(false)
160
+ })
161
+
162
+ it('never outputs unicode box drawing for double borders', () => {
163
+ const node: UINode = boxNode([textNode('Content')], { border: 'double' })
164
+ const result = renderASCII(node)
165
+
166
+ expect(containsUnicodeBoxDrawing(result)).toBe(false)
167
+ })
168
+
169
+ it('never outputs unicode box drawing for rounded borders', () => {
170
+ const node: UINode = boxNode([textNode('Content')], { border: 'rounded' })
171
+ const result = renderASCII(node)
172
+
173
+ expect(containsUnicodeBoxDrawing(result)).toBe(false)
174
+ })
175
+
176
+ it('never outputs ANSI escape sequences', () => {
177
+ const node: UINode = textNode('Colored text', { color: 'red', bold: true })
178
+ const result = renderASCII(node)
179
+
180
+ expect(containsANSI(result)).toBe(false)
181
+ })
182
+
183
+ it('handles unicode content by replacing with ASCII equivalents', () => {
184
+ const node: UINode = textNode('Hello \u2022 World') // Unicode bullet
185
+ const result = renderASCII(node)
186
+
187
+ expect(isASCIIOnly(result)).toBe(true)
188
+ expect(result).not.toContain('\u2022')
189
+ })
190
+
191
+ it('handles emoji by replacing or removing', () => {
192
+ const node: UINode = textNode('Hello \u{1F600} World') // Emoji
193
+ const result = renderASCII(node)
194
+
195
+ expect(isASCIIOnly(result)).toBe(true)
196
+ })
197
+ })
198
+ })
199
+
200
+ // ============================================================================
201
+ // Box Drawing Tests
202
+ // ============================================================================
203
+
204
+ describe('ASCII Box Drawing', () => {
205
+ describe('basic boxes', () => {
206
+ it('draws top-left corner with +', () => {
207
+ const node: UINode = boxNode([textNode('X')])
208
+ const result = renderASCII(node)
209
+ const lines = result.split('\n')
210
+
211
+ expect(lines[0][0]).toBe('+')
212
+ })
213
+
214
+ it('draws top-right corner with +', () => {
215
+ const node: UINode = boxNode([textNode('X')])
216
+ const result = renderASCII(node)
217
+ const lines = result.split('\n')
218
+
219
+ // Top-right corner is last non-whitespace char on first line
220
+ const topLine = lines[0].trimEnd()
221
+ expect(topLine[topLine.length - 1]).toBe('+')
222
+ })
223
+
224
+ it('draws bottom-left corner with +', () => {
225
+ const node: UINode = boxNode([textNode('X')])
226
+ const result = renderASCII(node)
227
+ const lines = result.split('\n').filter((l) => l.trim())
228
+
229
+ const bottomLine = lines[lines.length - 1]
230
+ expect(bottomLine[0]).toBe('+')
231
+ })
232
+
233
+ it('draws bottom-right corner with +', () => {
234
+ const node: UINode = boxNode([textNode('X')])
235
+ const result = renderASCII(node)
236
+ const lines = result.split('\n').filter((l) => l.trim())
237
+
238
+ const bottomLine = lines[lines.length - 1].trimEnd()
239
+ expect(bottomLine[bottomLine.length - 1]).toBe('+')
240
+ })
241
+
242
+ it('draws horizontal borders with -', () => {
243
+ const node: UINode = boxNode([textNode('Content')])
244
+ const result = renderASCII(node)
245
+ const lines = result.split('\n')
246
+
247
+ // Top line should have dashes between corners
248
+ expect(lines[0]).toMatch(/^\+[-]+\+$/)
249
+ })
250
+
251
+ it('draws vertical borders with |', () => {
252
+ const node: UINode = boxNode([textNode('Content')])
253
+ const result = renderASCII(node)
254
+ const lines = result.split('\n').filter((l) => l.trim())
255
+
256
+ // Middle lines should start and end with |
257
+ for (let i = 1; i < lines.length - 1; i++) {
258
+ const line = lines[i].trimEnd()
259
+ expect(line[0]).toBe('|')
260
+ expect(line[line.length - 1]).toBe('|')
261
+ }
262
+ })
263
+
264
+ it('creates properly closed box structure', () => {
265
+ const node: UINode = boxNode([textNode('Test')])
266
+ const result = renderASCII(node)
267
+ const lines = result.split('\n').filter((l) => l.trim())
268
+
269
+ // Should have at least 3 lines (top, content, bottom)
270
+ expect(lines.length).toBeGreaterThanOrEqual(3)
271
+
272
+ // Top and bottom should be borders
273
+ expect(lines[0]).toMatch(/^\+[-]+\+$/)
274
+ expect(lines[lines.length - 1]).toMatch(/^\+[-]+\+$/)
275
+ })
276
+ })
277
+
278
+ describe('box sizing', () => {
279
+ it('adjusts box width to content', () => {
280
+ const shortNode = boxNode([textNode('Hi')])
281
+ const longNode = boxNode([textNode('Hello World')])
282
+
283
+ const shortResult = renderASCII(shortNode)
284
+ const longResult = renderASCII(longNode)
285
+
286
+ const shortWidth = shortResult.split('\n')[0].length
287
+ const longWidth = longResult.split('\n')[0].length
288
+
289
+ expect(longWidth).toBeGreaterThan(shortWidth)
290
+ })
291
+
292
+ it('respects explicit width prop', () => {
293
+ const node: UINode = boxNode([textNode('Hi')], { width: 20 })
294
+ const result = renderASCII(node)
295
+ const lines = result.split('\n')
296
+
297
+ expect(lines[0].trimEnd().length).toBe(20)
298
+ })
299
+
300
+ it('respects context width for wrapping', () => {
301
+ const node: UINode = boxNode([textNode('A '.repeat(50))])
302
+ const context = createTestContext({ width: 40 })
303
+ const result = renderASCII(node, context)
304
+ const lines = result.split('\n')
305
+
306
+ // All lines should respect max width
307
+ lines.forEach((line) => {
308
+ expect(line.length).toBeLessThanOrEqual(40)
309
+ })
310
+ })
311
+
312
+ it('handles minimum box size', () => {
313
+ const node: UINode = boxNode([textNode('')])
314
+ const result = renderASCII(node)
315
+ const lines = result.split('\n').filter((l) => l.trim())
316
+
317
+ // Even empty box should have valid structure
318
+ expect(lines.length).toBeGreaterThanOrEqual(2) // At least top and bottom
319
+ expect(lines[0].length).toBeGreaterThanOrEqual(2) // At least two corners
320
+ })
321
+ })
322
+
323
+ describe('box styles', () => {
324
+ it('renders single border style with ASCII', () => {
325
+ const node: UINode = boxNode([textNode('Single')], { border: 'single' })
326
+ const result = renderASCII(node)
327
+
328
+ expect(isASCIIOnly(result)).toBe(true)
329
+ expect(result).toMatch(/^\+[-]+\+/)
330
+ })
331
+
332
+ it('renders double border style with ASCII fallback', () => {
333
+ const node: UINode = boxNode([textNode('Double')], { border: 'double' })
334
+ const result = renderASCII(node)
335
+
336
+ // Double borders should fall back to ASCII
337
+ expect(isASCIIOnly(result)).toBe(true)
338
+ expect(result).toMatch(/^\+[=]+\+/) // Double uses = or still -
339
+ })
340
+
341
+ it('renders rounded border style with ASCII fallback', () => {
342
+ const node: UINode = boxNode([textNode('Rounded')], { border: 'rounded' })
343
+ const result = renderASCII(node)
344
+
345
+ // Rounded should use same ASCII as single
346
+ expect(isASCIIOnly(result)).toBe(true)
347
+ expect(result).toMatch(/^\+[-]+\+/)
348
+ })
349
+
350
+ it('renders no border when border is none', () => {
351
+ const node: UINode = boxNode([textNode('No Border')], { border: 'none' })
352
+ const result = renderASCII(node)
353
+
354
+ expect(result).not.toContain('+')
355
+ expect(result).not.toContain('|')
356
+ expect(result).toContain('No Border')
357
+ })
358
+ })
359
+
360
+ describe('nested boxes', () => {
361
+ it('renders nested boxes correctly', () => {
362
+ const innerBox = boxNode([textNode('Inner')])
363
+ const outerBox = boxNode([innerBox])
364
+ const result = renderASCII(outerBox)
365
+
366
+ // Should have nested box structure
367
+ const lines = result.split('\n').filter((l) => l.trim())
368
+
369
+ // Count + characters - should have more than 4 (outer corners) + 4 (inner corners)
370
+ const plusCount = (result.match(/\+/g) || []).length
371
+ expect(plusCount).toBeGreaterThanOrEqual(8)
372
+ })
373
+
374
+ it('properly indents nested content', () => {
375
+ const innerBox = boxNode([textNode('Nested')])
376
+ const outerBox = boxNode([innerBox], { padding: 1 })
377
+ const result = renderASCII(outerBox)
378
+
379
+ const lines = result.split('\n')
380
+ // Inner box should be indented from outer
381
+ const innerBoxLine = lines.find(
382
+ (l) => l.includes('+') && !l.startsWith('+')
383
+ )
384
+ expect(innerBoxLine).toBeDefined()
385
+ })
386
+ })
387
+ })
388
+
389
+ // ============================================================================
390
+ // Table Rendering Tests
391
+ // ============================================================================
392
+
393
+ describe('ASCII Table Rendering', () => {
394
+ /**
395
+ * Create a table node
396
+ */
397
+ function tableNode(
398
+ headers: string[],
399
+ rows: string[][],
400
+ props: Record<string, unknown> = {}
401
+ ): UINode {
402
+ return {
403
+ type: 'table',
404
+ props: { headers, rows, ...props },
405
+ }
406
+ }
407
+
408
+ describe('basic tables', () => {
409
+ it('renders table with ASCII borders', () => {
410
+ const node = tableNode(['Name', 'Age'], [['Alice', '30']])
411
+ const result = renderASCII(node)
412
+
413
+ expect(isASCIIOnly(result)).toBe(true)
414
+ expect(result).toContain('+')
415
+ expect(result).toContain('-')
416
+ expect(result).toContain('|')
417
+ })
418
+
419
+ it('separates header from body with horizontal line', () => {
420
+ const node = tableNode(['Name', 'Age'], [['Alice', '30']])
421
+ const result = renderASCII(node)
422
+ const lines = result.split('\n').filter((l) => l.trim())
423
+
424
+ // Should have header separator line with + at intersections
425
+ const separatorLines = lines.filter((l) => l.match(/^\+[-+]+\+$/))
426
+ expect(separatorLines.length).toBeGreaterThanOrEqual(2) // Top border + header separator
427
+ })
428
+
429
+ it('aligns columns properly', () => {
430
+ const node = tableNode(['Name', 'Age'], [
431
+ ['Alice', '30'],
432
+ ['Bob', '25'],
433
+ ])
434
+ const result = renderASCII(node)
435
+ const lines = result.split('\n').filter((l) => l.includes('|'))
436
+
437
+ // All content lines should have same number of | characters
438
+ const pipeCount = lines[0].split('|').length
439
+ lines.forEach((line) => {
440
+ expect(line.split('|').length).toBe(pipeCount)
441
+ })
442
+ })
443
+
444
+ it('handles empty table', () => {
445
+ const node = tableNode([], [])
446
+ const result = renderASCII(node)
447
+
448
+ // Should still output something valid (maybe just borders or empty string)
449
+ expect(typeof result).toBe('string')
450
+ if (result.length > 0) {
451
+ expect(isASCIIOnly(result)).toBe(true)
452
+ }
453
+ })
454
+
455
+ it('handles table with only headers', () => {
456
+ const node = tableNode(['Name', 'Age', 'City'], [])
457
+ const result = renderASCII(node)
458
+
459
+ expect(result).toContain('Name')
460
+ expect(result).toContain('Age')
461
+ expect(result).toContain('City')
462
+ })
463
+ })
464
+
465
+ describe('table cell content', () => {
466
+ it('truncates long cell content when necessary', () => {
467
+ const longContent = 'A'.repeat(100)
468
+ const node = tableNode(['Header'], [[longContent]])
469
+ const context = createTestContext({ width: 40 })
470
+ const result = renderASCII(node, context)
471
+
472
+ // Lines should not exceed context width
473
+ result.split('\n').forEach((line) => {
474
+ expect(line.length).toBeLessThanOrEqual(40)
475
+ })
476
+ })
477
+
478
+ it('pads shorter cells to match column width', () => {
479
+ const node = tableNode(['Name'], [['A'], ['Alice']])
480
+ const result = renderASCII(node)
481
+ const lines = result.split('\n').filter((l) => l.includes('|'))
482
+
483
+ // Content lines should all be same width
484
+ const widths = lines.map((l) => l.trimEnd().length)
485
+ const allSame = widths.every((w) => w === widths[0])
486
+ expect(allSame).toBe(true)
487
+ })
488
+
489
+ it('handles multi-line cell content', () => {
490
+ const node = tableNode(['Description'], [['Line 1\nLine 2']])
491
+ const result = renderASCII(node)
492
+
493
+ // Should either show both lines or truncate gracefully
494
+ expect(result).toContain('Line')
495
+ expect(isASCIIOnly(result)).toBe(true)
496
+ })
497
+ })
498
+
499
+ describe('table intersections', () => {
500
+ it('uses + for all corner intersections', () => {
501
+ const node = tableNode(['A', 'B'], [['1', '2']])
502
+ const result = renderASCII(node)
503
+ const lines = result.split('\n').filter((l) => l.includes('+'))
504
+
505
+ // All + should be at intersection points
506
+ lines.forEach((line) => {
507
+ // Corners and intersections use +
508
+ expect(line).toMatch(/\+/)
509
+ })
510
+ })
511
+
512
+ it('uses + for column separators in border rows', () => {
513
+ const node = tableNode(['A', 'B', 'C'], [['1', '2', '3']])
514
+ const result = renderASCII(node)
515
+ const borderLines = result.split('\n').filter((l) => l.match(/^[\+\-]+$/))
516
+
517
+ // Border lines should have + at column boundaries
518
+ borderLines.forEach((line) => {
519
+ const plusCount = (line.match(/\+/g) || []).length
520
+ expect(plusCount).toBeGreaterThanOrEqual(2) // At least start and end
521
+ })
522
+ })
523
+ })
524
+ })
525
+
526
+ // ============================================================================
527
+ // List and Bullet Point Tests
528
+ // ============================================================================
529
+
530
+ describe('ASCII List Rendering', () => {
531
+ /**
532
+ * Create a list node
533
+ */
534
+ function listNode(
535
+ items: string[],
536
+ props: Record<string, unknown> = {}
537
+ ): UINode {
538
+ return {
539
+ type: 'list',
540
+ props: { items, ...props },
541
+ }
542
+ }
543
+
544
+ describe('unordered lists', () => {
545
+ it('uses * for bullet points', () => {
546
+ const node = listNode(['Item 1', 'Item 2'], { ordered: false })
547
+ const result = renderASCII(node)
548
+
549
+ expect(result).toContain('* Item 1')
550
+ expect(result).toContain('* Item 2')
551
+ })
552
+
553
+ it('alternatively uses - for bullet points', () => {
554
+ const node = listNode(['Item 1'], { ordered: false, bullet: '-' })
555
+ const result = renderASCII(node)
556
+
557
+ expect(result).toContain('- Item 1')
558
+ })
559
+
560
+ it('never uses unicode bullets', () => {
561
+ const node = listNode(['Item 1', 'Item 2'])
562
+ const result = renderASCII(node)
563
+
564
+ expect(result).not.toContain('\u2022') // Unicode bullet
565
+ expect(result).not.toContain('\u25CF') // Black circle
566
+ expect(result).not.toContain('\u25CB') // White circle
567
+ expect(result).not.toContain('\u25A0') // Black square
568
+ expect(isASCIIOnly(result)).toBe(true)
569
+ })
570
+
571
+ it('indents list items consistently', () => {
572
+ const node = listNode(['First', 'Second', 'Third'])
573
+ const result = renderASCII(node)
574
+ const lines = result.split('\n').filter((l) => l.trim())
575
+
576
+ // All bullets should start at same column
577
+ const bulletIndents = lines.map((l) => l.search(/[*\-]/))
578
+ const allSame = bulletIndents.every((i) => i === bulletIndents[0])
579
+ expect(allSame).toBe(true)
580
+ })
581
+ })
582
+
583
+ describe('ordered lists', () => {
584
+ it('uses numbers with . or ) for ordered lists', () => {
585
+ const node = listNode(['First', 'Second'], { ordered: true })
586
+ const result = renderASCII(node)
587
+
588
+ expect(result).toMatch(/1[.\)]\s*First/)
589
+ expect(result).toMatch(/2[.\)]\s*Second/)
590
+ })
591
+
592
+ it('aligns numbers properly for multi-digit lists', () => {
593
+ const items = Array.from({ length: 12 }, (_, i) => `Item ${i + 1}`)
594
+ const node = listNode(items, { ordered: true })
595
+ const result = renderASCII(node)
596
+ const lines = result.split('\n').filter((l) => l.trim())
597
+
598
+ // Items 1-9 and 10-12 should have content starting at same column
599
+ const contentStarts = lines.map((l) => l.search(/Item/))
600
+ const allSame = contentStarts.every((s) => s === contentStarts[0])
601
+ expect(allSame).toBe(true)
602
+ })
603
+ })
604
+
605
+ describe('nested lists', () => {
606
+ it('indents nested list items', () => {
607
+ const innerList = listNode(['Nested 1', 'Nested 2'])
608
+ const node: UINode = {
609
+ type: 'list',
610
+ props: { items: ['Parent'] },
611
+ children: [innerList],
612
+ }
613
+ const result = renderASCII(node)
614
+
615
+ // Nested items should be indented more
616
+ const lines = result.split('\n').filter((l) => l.includes('Nested'))
617
+ const parentLine = result.split('\n').find((l) => l.includes('Parent'))
618
+
619
+ if (lines.length > 0 && parentLine) {
620
+ const nestedIndent = lines[0].search(/\S/)
621
+ const parentIndent = parentLine.search(/\S/)
622
+ expect(nestedIndent).toBeGreaterThan(parentIndent)
623
+ }
624
+ })
625
+
626
+ it('uses consistent bullet style for each level', () => {
627
+ const innerList = listNode(['Sub-item'])
628
+ const node: UINode = {
629
+ type: 'list',
630
+ props: { items: ['Top item'] },
631
+ children: [innerList],
632
+ }
633
+ const result = renderASCII(node)
634
+
635
+ expect(isASCIIOnly(result)).toBe(true)
636
+ })
637
+ })
638
+
639
+ describe('empty and edge cases', () => {
640
+ it('handles empty list', () => {
641
+ const node = listNode([])
642
+ const result = renderASCII(node)
643
+
644
+ expect(typeof result).toBe('string')
645
+ })
646
+
647
+ it('handles single item list', () => {
648
+ const node = listNode(['Only item'])
649
+ const result = renderASCII(node)
650
+
651
+ expect(result).toContain('Only item')
652
+ })
653
+
654
+ it('handles items with special characters', () => {
655
+ const node = listNode(['Item with * asterisk', 'Item with - dash'])
656
+ const result = renderASCII(node)
657
+
658
+ expect(result).toContain('asterisk')
659
+ expect(result).toContain('dash')
660
+ expect(isASCIIOnly(result)).toBe(true)
661
+ })
662
+ })
663
+ })
664
+
665
+ // ============================================================================
666
+ // Progress Bar Tests
667
+ // ============================================================================
668
+
669
+ describe('ASCII Progress Bar Rendering', () => {
670
+ /**
671
+ * Create a progress bar node
672
+ */
673
+ function progressNode(
674
+ value: number,
675
+ props: Record<string, unknown> = {}
676
+ ): UINode {
677
+ return {
678
+ type: 'progress',
679
+ props: { value, ...props },
680
+ }
681
+ }
682
+
683
+ describe('progress bar format', () => {
684
+ it('renders with [ and ] brackets', () => {
685
+ const node = progressNode(50)
686
+ const result = renderASCII(node)
687
+
688
+ expect(result).toContain('[')
689
+ expect(result).toContain(']')
690
+ })
691
+
692
+ it('uses = for filled portion', () => {
693
+ const node = progressNode(50, { width: 12 })
694
+ const result = renderASCII(node)
695
+
696
+ expect(result).toContain('=')
697
+ })
698
+
699
+ it('uses space for unfilled portion', () => {
700
+ const node = progressNode(50, { width: 12 })
701
+ const result = renderASCII(node)
702
+
703
+ // Should have format like [===== ]
704
+ expect(result).toMatch(/\[=+\s+\]/)
705
+ })
706
+
707
+ it('renders 0% progress as empty bar', () => {
708
+ const node = progressNode(0, { width: 12 })
709
+ const result = renderASCII(node)
710
+
711
+ // Should be [ ] - all spaces
712
+ expect(result).toMatch(/\[\s+\]/)
713
+ expect(result).not.toContain('=')
714
+ })
715
+
716
+ it('renders 100% progress as full bar', () => {
717
+ const node = progressNode(100, { width: 12 })
718
+ const result = renderASCII(node)
719
+
720
+ // Should be [==========] - all equals
721
+ expect(result).toMatch(/\[=+\]/)
722
+ expect(result).not.toMatch(/\[=+\s/)
723
+ })
724
+
725
+ it('only uses ASCII characters', () => {
726
+ const node = progressNode(75)
727
+ const result = renderASCII(node)
728
+
729
+ expect(isASCIIOnly(result)).toBe(true)
730
+ })
731
+ })
732
+
733
+ describe('progress bar sizing', () => {
734
+ it('respects width prop', () => {
735
+ const node = progressNode(50, { width: 20 })
736
+ const result = renderASCII(node)
737
+
738
+ // Bar should be approximately 20 chars including brackets
739
+ expect(result.length).toBeGreaterThanOrEqual(20)
740
+ })
741
+
742
+ it('scales filled portion to percentage', () => {
743
+ const node25 = progressNode(25, { width: 12 })
744
+ const node75 = progressNode(75, { width: 12 })
745
+
746
+ const result25 = renderASCII(node25)
747
+ const result75 = renderASCII(node75)
748
+
749
+ const equals25 = (result25.match(/=/g) || []).length
750
+ const equals75 = (result75.match(/=/g) || []).length
751
+
752
+ expect(equals75).toBeGreaterThan(equals25)
753
+ })
754
+ })
755
+
756
+ describe('progress bar labels', () => {
757
+ it('can show percentage label', () => {
758
+ const node = progressNode(50, { showLabel: true })
759
+ const result = renderASCII(node)
760
+
761
+ expect(result).toContain('50%')
762
+ })
763
+
764
+ it('can show custom label', () => {
765
+ const node = progressNode(50, { label: 'Loading...' })
766
+ const result = renderASCII(node)
767
+
768
+ expect(result).toContain('Loading...')
769
+ })
770
+ })
771
+
772
+ describe('edge cases', () => {
773
+ it('clamps value below 0 to 0', () => {
774
+ const node = progressNode(-10)
775
+ const result = renderASCII(node)
776
+
777
+ expect(result).not.toContain('=')
778
+ expect(isASCIIOnly(result)).toBe(true)
779
+ })
780
+
781
+ it('clamps value above 100 to 100', () => {
782
+ const node = progressNode(150, { width: 10 })
783
+ const result = renderASCII(node)
784
+
785
+ // Should be completely filled
786
+ expect(result).toMatch(/\[=+\]/)
787
+ })
788
+
789
+ it('handles decimal percentages', () => {
790
+ const node = progressNode(33.33)
791
+ const result = renderASCII(node)
792
+
793
+ expect(isASCIIOnly(result)).toBe(true)
794
+ })
795
+ })
796
+ })
797
+
798
+ // ============================================================================
799
+ // Spinner Tests
800
+ // ============================================================================
801
+
802
+ describe('ASCII Spinner Rendering', () => {
803
+ /**
804
+ * Create a spinner node
805
+ */
806
+ function spinnerNode(props: Record<string, unknown> = {}): UINode {
807
+ return {
808
+ type: 'spinner',
809
+ props,
810
+ }
811
+ }
812
+
813
+ describe('spinner frames', () => {
814
+ it('uses ASCII characters for spinner frames', () => {
815
+ const node = spinnerNode()
816
+ const result = renderASCII(node)
817
+
818
+ expect(isASCIIOnly(result)).toBe(true)
819
+ })
820
+
821
+ it('never uses unicode spinner characters', () => {
822
+ const node = spinnerNode()
823
+ const result = renderASCII(node)
824
+
825
+ // Common unicode spinners
826
+ expect(result).not.toContain('\u280B') // Braille
827
+ expect(result).not.toContain('\u25DC') // Arc
828
+ expect(result).not.toContain('\u2588') // Block
829
+ })
830
+
831
+ it('uses common ASCII spinner patterns', () => {
832
+ const node = spinnerNode()
833
+ const result = renderASCII(node)
834
+
835
+ // Common ASCII spinner chars: | / - \ or . o O
836
+ expect(result).toMatch(/[|/\-\\\.oO]/)
837
+ })
838
+ })
839
+
840
+ describe('spinner with label', () => {
841
+ it('displays label text', () => {
842
+ const node = spinnerNode({ label: 'Loading...' })
843
+ const result = renderASCII(node)
844
+
845
+ expect(result).toContain('Loading...')
846
+ })
847
+
848
+ it('positions spinner before label', () => {
849
+ const node = spinnerNode({ label: 'Working' })
850
+ const result = renderASCII(node)
851
+
852
+ const spinnerPos = result.search(/[|/\-\\\.oO]/)
853
+ const labelPos = result.indexOf('Working')
854
+
855
+ expect(spinnerPos).toBeLessThan(labelPos)
856
+ })
857
+ })
858
+ })
859
+
860
+ // ============================================================================
861
+ // Text Wrapping Tests
862
+ // ============================================================================
863
+
864
+ describe('ASCII Text Wrapping', () => {
865
+ describe('word wrapping', () => {
866
+ it('wraps text at context width', () => {
867
+ const longText = 'This is a very long line of text that should wrap'
868
+ const node: UINode = textNode(longText)
869
+ const context = createTestContext({ width: 20 })
870
+ const result = renderASCII(node, context)
871
+ const lines = result.split('\n')
872
+
873
+ lines.forEach((line) => {
874
+ expect(line.length).toBeLessThanOrEqual(20)
875
+ })
876
+ })
877
+
878
+ it('preserves words when wrapping', () => {
879
+ const text = 'Hello World Testing'
880
+ const node: UINode = textNode(text)
881
+ const context = createTestContext({ width: 12 })
882
+ const result = renderASCII(node, context)
883
+
884
+ // Words should not be split mid-word if avoidable
885
+ expect(result).toContain('Hello')
886
+ expect(result).toContain('World')
887
+ expect(result).toContain('Testing')
888
+ })
889
+
890
+ it('handles very long words by breaking them', () => {
891
+ const longWord = 'Supercalifragilisticexpialidocious'
892
+ const node: UINode = textNode(longWord)
893
+ const context = createTestContext({ width: 10 })
894
+ const result = renderASCII(node, context)
895
+
896
+ // Each line should respect max width
897
+ result.split('\n').forEach((line) => {
898
+ expect(line.length).toBeLessThanOrEqual(10)
899
+ })
900
+ })
901
+ })
902
+
903
+ describe('whitespace handling', () => {
904
+ it('preserves single spaces between words', () => {
905
+ const node: UINode = textNode('Hello World')
906
+ const result = renderASCII(node)
907
+
908
+ expect(result).toContain('Hello World')
909
+ })
910
+
911
+ it('collapses multiple spaces', () => {
912
+ const node: UINode = textNode('Hello World')
913
+ const result = renderASCII(node)
914
+
915
+ expect(result).not.toContain(' ')
916
+ })
917
+
918
+ it('handles leading whitespace', () => {
919
+ const node: UINode = textNode(' Indented')
920
+ const result = renderASCII(node)
921
+
922
+ expect(typeof result).toBe('string')
923
+ expect(isASCIIOnly(result)).toBe(true)
924
+ })
925
+
926
+ it('handles trailing whitespace', () => {
927
+ const node: UINode = textNode('Text ')
928
+ const result = renderASCII(node)
929
+
930
+ expect(result.includes('Text')).toBe(true)
931
+ })
932
+ })
933
+
934
+ describe('newline handling', () => {
935
+ it('respects explicit newlines', () => {
936
+ const node: UINode = textNode('Line 1\nLine 2')
937
+ const result = renderASCII(node)
938
+ const lines = result.split('\n')
939
+
940
+ expect(lines.length).toBeGreaterThanOrEqual(2)
941
+ expect(result).toContain('Line 1')
942
+ expect(result).toContain('Line 2')
943
+ })
944
+
945
+ it('handles multiple consecutive newlines', () => {
946
+ const node: UINode = textNode('Line 1\n\n\nLine 2')
947
+ const result = renderASCII(node)
948
+
949
+ expect(result).toContain('Line 1')
950
+ expect(result).toContain('Line 2')
951
+ })
952
+ })
953
+ })
954
+
955
+ // ============================================================================
956
+ // Component Type Coverage Tests
957
+ // ============================================================================
958
+
959
+ describe('ASCII Renderer Component Coverage', () => {
960
+ describe('text component', () => {
961
+ it('renders plain text', () => {
962
+ const node: UINode = textNode('Hello')
963
+ const result = renderASCII(node)
964
+
965
+ expect(result).toContain('Hello')
966
+ expect(isASCIIOnly(result)).toBe(true)
967
+ })
968
+
969
+ it('ignores color props', () => {
970
+ const node: UINode = textNode('Colored', { color: 'red' })
971
+ const result = renderASCII(node)
972
+
973
+ expect(result).toContain('Colored')
974
+ expect(containsANSI(result)).toBe(false)
975
+ })
976
+
977
+ it('ignores bold/italic/underline', () => {
978
+ const node: UINode = textNode('Styled', {
979
+ bold: true,
980
+ italic: true,
981
+ underline: true,
982
+ })
983
+ const result = renderASCII(node)
984
+
985
+ expect(result).toContain('Styled')
986
+ expect(containsANSI(result)).toBe(false)
987
+ })
988
+ })
989
+
990
+ describe('panel component', () => {
991
+ it('renders panel with title', () => {
992
+ const node: UINode = {
993
+ type: 'panel',
994
+ props: { title: 'My Panel' },
995
+ children: [textNode('Content')],
996
+ }
997
+ const result = renderASCII(node)
998
+
999
+ expect(result).toContain('My Panel')
1000
+ expect(result).toContain('Content')
1001
+ expect(isASCIIOnly(result)).toBe(true)
1002
+ })
1003
+
1004
+ it('uses ASCII borders for panel', () => {
1005
+ const node: UINode = {
1006
+ type: 'panel',
1007
+ props: { title: 'Panel' },
1008
+ children: [textNode('Text')],
1009
+ }
1010
+ const result = renderASCII(node)
1011
+
1012
+ expect(result).toContain('+')
1013
+ expect(result).toContain('-')
1014
+ expect(result).toContain('|')
1015
+ })
1016
+ })
1017
+
1018
+ describe('card component', () => {
1019
+ it('renders card with content', () => {
1020
+ const node: UINode = {
1021
+ type: 'card',
1022
+ props: {},
1023
+ children: [textNode('Card content')],
1024
+ }
1025
+ const result = renderASCII(node)
1026
+
1027
+ expect(result).toContain('Card content')
1028
+ expect(isASCIIOnly(result)).toBe(true)
1029
+ })
1030
+ })
1031
+
1032
+ describe('badge component', () => {
1033
+ it('renders badge text', () => {
1034
+ const node: UINode = {
1035
+ type: 'badge',
1036
+ props: { label: 'NEW' },
1037
+ }
1038
+ const result = renderASCII(node)
1039
+
1040
+ expect(result).toContain('NEW')
1041
+ expect(isASCIIOnly(result)).toBe(true)
1042
+ })
1043
+
1044
+ it('ignores badge variant colors', () => {
1045
+ const node: UINode = {
1046
+ type: 'badge',
1047
+ props: { label: 'Error', variant: 'error' },
1048
+ }
1049
+ const result = renderASCII(node)
1050
+
1051
+ expect(result).toContain('Error')
1052
+ expect(containsANSI(result)).toBe(false)
1053
+ })
1054
+ })
1055
+
1056
+ describe('button component', () => {
1057
+ it('renders button with ASCII brackets', () => {
1058
+ const node: UINode = {
1059
+ type: 'button',
1060
+ props: { label: 'Click Me' },
1061
+ }
1062
+ const result = renderASCII(node)
1063
+
1064
+ expect(result).toContain('Click Me')
1065
+ // Button might be rendered with [ ] brackets
1066
+ expect(result).toMatch(/[\[\]<>]?Click Me[\[\]<>]?/)
1067
+ expect(isASCIIOnly(result)).toBe(true)
1068
+ })
1069
+ })
1070
+
1071
+ describe('input component', () => {
1072
+ it('renders input field with ASCII borders', () => {
1073
+ const node: UINode = {
1074
+ type: 'input',
1075
+ props: { placeholder: 'Enter text...', value: '' },
1076
+ }
1077
+ const result = renderASCII(node)
1078
+
1079
+ expect(isASCIIOnly(result)).toBe(true)
1080
+ })
1081
+
1082
+ it('shows input value', () => {
1083
+ const node: UINode = {
1084
+ type: 'input',
1085
+ props: { value: 'User input' },
1086
+ }
1087
+ const result = renderASCII(node)
1088
+
1089
+ expect(result).toContain('User input')
1090
+ })
1091
+ })
1092
+
1093
+ describe('select component', () => {
1094
+ it('renders select with options', () => {
1095
+ const node: UINode = {
1096
+ type: 'select',
1097
+ props: {
1098
+ options: [
1099
+ { label: 'Option 1', value: '1' },
1100
+ { label: 'Option 2', value: '2' },
1101
+ ],
1102
+ value: '1',
1103
+ },
1104
+ }
1105
+ const result = renderASCII(node)
1106
+
1107
+ expect(result).toContain('Option')
1108
+ expect(isASCIIOnly(result)).toBe(true)
1109
+ })
1110
+ })
1111
+
1112
+ describe('dialog component', () => {
1113
+ it('renders dialog with ASCII border', () => {
1114
+ const node: UINode = {
1115
+ type: 'dialog',
1116
+ props: { title: 'Alert', open: true },
1117
+ children: [textNode('Dialog content')],
1118
+ }
1119
+ const result = renderASCII(node)
1120
+
1121
+ expect(result).toContain('Alert')
1122
+ expect(result).toContain('Dialog content')
1123
+ expect(result).toContain('+')
1124
+ expect(isASCIIOnly(result)).toBe(true)
1125
+ })
1126
+ })
1127
+
1128
+ describe('breadcrumb component', () => {
1129
+ it('renders breadcrumb with ASCII separators', () => {
1130
+ const node: UINode = {
1131
+ type: 'breadcrumb',
1132
+ props: {
1133
+ items: [
1134
+ { label: 'Home', href: '/' },
1135
+ { label: 'Products', href: '/products' },
1136
+ { label: 'Item', href: '/products/item' },
1137
+ ],
1138
+ },
1139
+ }
1140
+ const result = renderASCII(node)
1141
+
1142
+ expect(result).toContain('Home')
1143
+ expect(result).toContain('Products')
1144
+ expect(result).toContain('Item')
1145
+ // Should use ASCII separator like > or /
1146
+ expect(result).toMatch(/[>\/]/)
1147
+ expect(isASCIIOnly(result)).toBe(true)
1148
+ })
1149
+ })
1150
+
1151
+ describe('sidebar component', () => {
1152
+ it('renders sidebar with ASCII structure', () => {
1153
+ const node: UINode = {
1154
+ type: 'sidebar',
1155
+ props: {
1156
+ items: [
1157
+ { label: 'Dashboard', icon: 'home' },
1158
+ { label: 'Settings', icon: 'gear' },
1159
+ ],
1160
+ },
1161
+ }
1162
+ const result = renderASCII(node)
1163
+
1164
+ expect(result).toContain('Dashboard')
1165
+ expect(result).toContain('Settings')
1166
+ expect(isASCIIOnly(result)).toBe(true)
1167
+ })
1168
+ })
1169
+ })
1170
+
1171
+ // ============================================================================
1172
+ // Edge Cases and Error Handling Tests
1173
+ // ============================================================================
1174
+
1175
+ describe('ASCII Renderer Edge Cases', () => {
1176
+ describe('null and undefined handling', () => {
1177
+ it('handles empty text content', () => {
1178
+ const node: UINode = textNode('')
1179
+ const result = renderASCII(node)
1180
+
1181
+ expect(typeof result).toBe('string')
1182
+ })
1183
+
1184
+ it('handles node with no children', () => {
1185
+ const node: UINode = boxNode([])
1186
+ const result = renderASCII(node)
1187
+
1188
+ expect(typeof result).toBe('string')
1189
+ expect(isASCIIOnly(result)).toBe(true)
1190
+ })
1191
+
1192
+ it('handles deeply nested empty structures', () => {
1193
+ const node: UINode = boxNode([boxNode([boxNode([])])])
1194
+ const result = renderASCII(node)
1195
+
1196
+ expect(typeof result).toBe('string')
1197
+ })
1198
+ })
1199
+
1200
+ describe('special characters', () => {
1201
+ it('escapes or handles < and > characters', () => {
1202
+ const node: UINode = textNode('1 < 2 > 0')
1203
+ const result = renderASCII(node)
1204
+
1205
+ expect(result).toContain('1')
1206
+ expect(result).toContain('2')
1207
+ expect(result).toContain('0')
1208
+ expect(isASCIIOnly(result)).toBe(true)
1209
+ })
1210
+
1211
+ it('handles ampersand', () => {
1212
+ const node: UINode = textNode('Fish & Chips')
1213
+ const result = renderASCII(node)
1214
+
1215
+ expect(result).toContain('&')
1216
+ expect(isASCIIOnly(result)).toBe(true)
1217
+ })
1218
+
1219
+ it('handles quotes', () => {
1220
+ const node: UINode = textNode('He said "Hello"')
1221
+ const result = renderASCII(node)
1222
+
1223
+ expect(result).toContain('"')
1224
+ expect(isASCIIOnly(result)).toBe(true)
1225
+ })
1226
+
1227
+ it('handles backslashes', () => {
1228
+ const node: UINode = textNode('path\\to\\file')
1229
+ const result = renderASCII(node)
1230
+
1231
+ expect(result).toContain('\\')
1232
+ expect(isASCIIOnly(result)).toBe(true)
1233
+ })
1234
+ })
1235
+
1236
+ describe('extreme dimensions', () => {
1237
+ it('handles very narrow width', () => {
1238
+ const node: UINode = boxNode([textNode('Test')])
1239
+ const context = createTestContext({ width: 5 })
1240
+ const result = renderASCII(node, context)
1241
+
1242
+ expect(typeof result).toBe('string')
1243
+ })
1244
+
1245
+ it('handles very wide width', () => {
1246
+ const node: UINode = boxNode([textNode('Test')])
1247
+ const context = createTestContext({ width: 500 })
1248
+ const result = renderASCII(node, context)
1249
+
1250
+ expect(typeof result).toBe('string')
1251
+ expect(isASCIIOnly(result)).toBe(true)
1252
+ })
1253
+
1254
+ it('handles zero width gracefully', () => {
1255
+ const node: UINode = textNode('Test')
1256
+ const context = createTestContext({ width: 0 })
1257
+ const result = renderASCII(node, context)
1258
+
1259
+ expect(typeof result).toBe('string')
1260
+ })
1261
+ })
1262
+
1263
+ describe('unknown node types', () => {
1264
+ it('handles unknown node type gracefully', () => {
1265
+ const node: UINode = {
1266
+ type: 'unknown-component',
1267
+ props: { content: 'Test' },
1268
+ }
1269
+ const result = renderASCII(node)
1270
+
1271
+ // Should either render something or return empty string
1272
+ expect(typeof result).toBe('string')
1273
+ })
1274
+
1275
+ it('renders children of unknown nodes', () => {
1276
+ const node: UINode = {
1277
+ type: 'custom-wrapper',
1278
+ props: {},
1279
+ children: [textNode('Child content')],
1280
+ }
1281
+ const result = renderASCII(node)
1282
+
1283
+ // Should at least render the text child
1284
+ expect(result).toContain('Child content')
1285
+ })
1286
+ })
1287
+
1288
+ describe('performance edge cases', () => {
1289
+ it('handles many children efficiently', () => {
1290
+ const children = Array.from({ length: 100 }, (_, i) =>
1291
+ textNode(`Item ${i}`)
1292
+ )
1293
+ const node: UINode = boxNode(children)
1294
+ const result = renderASCII(node)
1295
+
1296
+ expect(result).toContain('Item 0')
1297
+ expect(result).toContain('Item 99')
1298
+ })
1299
+
1300
+ it('handles deep nesting', () => {
1301
+ let node: UINode = textNode('Deep')
1302
+ for (let i = 0; i < 20; i++) {
1303
+ node = boxNode([node])
1304
+ }
1305
+ const result = renderASCII(node)
1306
+
1307
+ expect(result).toContain('Deep')
1308
+ expect(isASCIIOnly(result)).toBe(true)
1309
+ })
1310
+ })
1311
+ })
1312
+
1313
+ // ============================================================================
1314
+ // Context Handling Tests
1315
+ // ============================================================================
1316
+
1317
+ describe('ASCII Renderer Context Handling', () => {
1318
+ describe('default context', () => {
1319
+ it('uses default width when context not provided', () => {
1320
+ const node: UINode = textNode('Test')
1321
+ const result = renderASCII(node)
1322
+
1323
+ expect(typeof result).toBe('string')
1324
+ })
1325
+
1326
+ it('uses default tier as ascii', () => {
1327
+ const node: UINode = boxNode([textNode('Test')])
1328
+ const result = renderASCII(node)
1329
+
1330
+ // Output should be ASCII regardless
1331
+ expect(isASCIIOnly(result)).toBe(true)
1332
+ })
1333
+ })
1334
+
1335
+ describe('context width', () => {
1336
+ it('respects context width for layout', () => {
1337
+ const node: UINode = boxNode([textNode('Test')])
1338
+ const narrowContext = createTestContext({ width: 20 })
1339
+ const wideContext = createTestContext({ width: 80 })
1340
+
1341
+ const narrowResult = renderASCII(node, narrowContext)
1342
+ const wideResult = renderASCII(node, wideContext)
1343
+
1344
+ // Both should be valid ASCII
1345
+ expect(isASCIIOnly(narrowResult)).toBe(true)
1346
+ expect(isASCIIOnly(wideResult)).toBe(true)
1347
+ })
1348
+ })
1349
+
1350
+ describe('depth tracking', () => {
1351
+ it('increases depth for nested components', () => {
1352
+ const node: UINode = boxNode([boxNode([textNode('Nested')])])
1353
+ const context = createTestContext({ depth: 0 })
1354
+ const result = renderASCII(node, context)
1355
+
1356
+ // Nested box should be indented or otherwise differentiated
1357
+ expect(result).toContain('Nested')
1358
+ })
1359
+ })
1360
+ })