@teammates/consolonia 0.2.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 (104) hide show
  1. package/README.md +48 -0
  2. package/dist/__tests__/ansi.test.d.ts +1 -0
  3. package/dist/__tests__/ansi.test.js +520 -0
  4. package/dist/__tests__/chat-view.test.d.ts +4 -0
  5. package/dist/__tests__/chat-view.test.js +480 -0
  6. package/dist/__tests__/drawing.test.d.ts +4 -0
  7. package/dist/__tests__/drawing.test.js +426 -0
  8. package/dist/__tests__/input.test.d.ts +5 -0
  9. package/dist/__tests__/input.test.js +911 -0
  10. package/dist/__tests__/layout.test.d.ts +4 -0
  11. package/dist/__tests__/layout.test.js +689 -0
  12. package/dist/__tests__/pixel.test.d.ts +1 -0
  13. package/dist/__tests__/pixel.test.js +674 -0
  14. package/dist/__tests__/render.test.d.ts +1 -0
  15. package/dist/__tests__/render.test.js +400 -0
  16. package/dist/__tests__/styled.test.d.ts +4 -0
  17. package/dist/__tests__/styled.test.js +149 -0
  18. package/dist/__tests__/widgets.test.d.ts +5 -0
  19. package/dist/__tests__/widgets.test.js +924 -0
  20. package/dist/ansi/esc.d.ts +61 -0
  21. package/dist/ansi/esc.js +85 -0
  22. package/dist/ansi/output.d.ts +66 -0
  23. package/dist/ansi/output.js +192 -0
  24. package/dist/ansi/strip.d.ts +16 -0
  25. package/dist/ansi/strip.js +74 -0
  26. package/dist/app.d.ts +68 -0
  27. package/dist/app.js +297 -0
  28. package/dist/drawing/clip.d.ts +23 -0
  29. package/dist/drawing/clip.js +67 -0
  30. package/dist/drawing/context.d.ts +77 -0
  31. package/dist/drawing/context.js +275 -0
  32. package/dist/index.d.ts +48 -0
  33. package/dist/index.js +63 -0
  34. package/dist/input/escape-matcher.d.ts +27 -0
  35. package/dist/input/escape-matcher.js +253 -0
  36. package/dist/input/events.d.ts +49 -0
  37. package/dist/input/events.js +17 -0
  38. package/dist/input/index.d.ts +15 -0
  39. package/dist/input/index.js +14 -0
  40. package/dist/input/matcher.d.ts +23 -0
  41. package/dist/input/matcher.js +14 -0
  42. package/dist/input/mouse-matcher.d.ts +27 -0
  43. package/dist/input/mouse-matcher.js +142 -0
  44. package/dist/input/paste-matcher.d.ts +23 -0
  45. package/dist/input/paste-matcher.js +104 -0
  46. package/dist/input/processor.d.ts +51 -0
  47. package/dist/input/processor.js +145 -0
  48. package/dist/input/raw-mode.d.ts +13 -0
  49. package/dist/input/raw-mode.js +24 -0
  50. package/dist/input/text-matcher.d.ts +14 -0
  51. package/dist/input/text-matcher.js +32 -0
  52. package/dist/layout/box.d.ts +33 -0
  53. package/dist/layout/box.js +92 -0
  54. package/dist/layout/column.d.ts +21 -0
  55. package/dist/layout/column.js +90 -0
  56. package/dist/layout/control.d.ts +73 -0
  57. package/dist/layout/control.js +215 -0
  58. package/dist/layout/row.d.ts +21 -0
  59. package/dist/layout/row.js +95 -0
  60. package/dist/layout/stack.d.ts +18 -0
  61. package/dist/layout/stack.js +64 -0
  62. package/dist/layout/types.d.ts +27 -0
  63. package/dist/layout/types.js +4 -0
  64. package/dist/pixel/background.d.ts +16 -0
  65. package/dist/pixel/background.js +16 -0
  66. package/dist/pixel/box-pattern.d.ts +38 -0
  67. package/dist/pixel/box-pattern.js +57 -0
  68. package/dist/pixel/buffer.d.ts +25 -0
  69. package/dist/pixel/buffer.js +51 -0
  70. package/dist/pixel/color.d.ts +48 -0
  71. package/dist/pixel/color.js +92 -0
  72. package/dist/pixel/foreground.d.ts +31 -0
  73. package/dist/pixel/foreground.js +64 -0
  74. package/dist/pixel/pixel.d.ts +21 -0
  75. package/dist/pixel/pixel.js +38 -0
  76. package/dist/pixel/symbol.d.ts +38 -0
  77. package/dist/pixel/symbol.js +192 -0
  78. package/dist/render/regions.d.ts +54 -0
  79. package/dist/render/regions.js +102 -0
  80. package/dist/render/render-target.d.ts +42 -0
  81. package/dist/render/render-target.js +118 -0
  82. package/dist/styled.d.ts +113 -0
  83. package/dist/styled.js +176 -0
  84. package/dist/widgets/border.d.ts +34 -0
  85. package/dist/widgets/border.js +121 -0
  86. package/dist/widgets/chat-view.d.ts +239 -0
  87. package/dist/widgets/chat-view.js +993 -0
  88. package/dist/widgets/interview.d.ts +87 -0
  89. package/dist/widgets/interview.js +187 -0
  90. package/dist/widgets/markdown.d.ts +87 -0
  91. package/dist/widgets/markdown.js +611 -0
  92. package/dist/widgets/panel.d.ts +19 -0
  93. package/dist/widgets/panel.js +35 -0
  94. package/dist/widgets/scroll-view.d.ts +43 -0
  95. package/dist/widgets/scroll-view.js +182 -0
  96. package/dist/widgets/styled-text.d.ts +38 -0
  97. package/dist/widgets/styled-text.js +183 -0
  98. package/dist/widgets/syntax.d.ts +37 -0
  99. package/dist/widgets/syntax.js +670 -0
  100. package/dist/widgets/text-input.d.ts +121 -0
  101. package/dist/widgets/text-input.js +618 -0
  102. package/dist/widgets/text.d.ts +34 -0
  103. package/dist/widgets/text.js +168 -0
  104. package/package.json +45 -0
@@ -0,0 +1,924 @@
1
+ /**
2
+ * Comprehensive unit tests for Phase 7 widgets:
3
+ * Text, Border, Panel, TextInput, ScrollView
4
+ */
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { DrawingContext } from "../drawing/context.js";
7
+ import { keyEvent, mouseEvent, pasteEvent } from "../input/events.js";
8
+ import { PixelBuffer } from "../pixel/buffer.js";
9
+ import { color, TRANSPARENT } from "../pixel/color.js";
10
+ import { Border } from "../widgets/border.js";
11
+ import { Panel } from "../widgets/panel.js";
12
+ import { ScrollView } from "../widgets/scroll-view.js";
13
+ import { Text } from "../widgets/text.js";
14
+ import { TextInput } from "../widgets/text-input.js";
15
+ // ── Helpers ──────────────────────────────────────────────────────────
16
+ /** Create an unconstrained constraint (large max). */
17
+ function unconstrainedConstraint(maxWidth = 80, maxHeight = 24) {
18
+ return { minWidth: 0, minHeight: 0, maxWidth, maxHeight };
19
+ }
20
+ /** Create a PixelBuffer and DrawingContext for rendering tests. */
21
+ function createRenderTarget(width = 40, height = 10) {
22
+ const buffer = new PixelBuffer(width, height);
23
+ const ctx = new DrawingContext(buffer);
24
+ return { buffer, ctx };
25
+ }
26
+ /** Helper: measure a control and arrange it at (0,0) with its desired size. */
27
+ function _layoutControl(ctrl, maxWidth = 80, maxHeight = 24) {
28
+ const size = ctrl.measure(unconstrainedConstraint(maxWidth, maxHeight));
29
+ ctrl.desiredSize = size;
30
+ ctrl.arrange({ x: 0, y: 0, width: size.width, height: size.height });
31
+ return size;
32
+ }
33
+ /** Read the character at (x, y) from the buffer. */
34
+ function charAtBuffer(buffer, x, y) {
35
+ return buffer.get(x, y).foreground.symbol.text;
36
+ }
37
+ /** Read a row of characters from the buffer as a string. */
38
+ function rowText(buffer, y, x0 = 0, x1) {
39
+ const end = x1 ?? buffer.width;
40
+ let s = "";
41
+ for (let x = x0; x < end; x++) {
42
+ s += charAtBuffer(buffer, x, y);
43
+ }
44
+ return s;
45
+ }
46
+ // ══════════════════════════════════════════════════════════════════════
47
+ // TEXT WIDGET
48
+ // ══════════════════════════════════════════════════════════════════════
49
+ describe("Text widget", () => {
50
+ describe("measure", () => {
51
+ it("returns correct size for single-line text", () => {
52
+ const t = new Text({ text: "Hello" });
53
+ const size = t.measure(unconstrainedConstraint());
54
+ expect(size.width).toBe(5);
55
+ expect(size.height).toBe(1);
56
+ });
57
+ it("returns correct size for multi-line text (split by \\n)", () => {
58
+ const t = new Text({ text: "abc\ndefgh\nij" });
59
+ const size = t.measure(unconstrainedConstraint());
60
+ expect(size.width).toBe(5); // "defgh" is the longest line
61
+ expect(size.height).toBe(3);
62
+ });
63
+ it("returns (0,0) for empty text", () => {
64
+ const t = new Text({ text: "" });
65
+ const size = t.measure(unconstrainedConstraint());
66
+ expect(size.width).toBe(0);
67
+ expect(size.height).toBe(0);
68
+ });
69
+ it("clamps width to maxWidth constraint", () => {
70
+ const t = new Text({ text: "Hello, world!" });
71
+ const size = t.measure(unconstrainedConstraint(5, 24));
72
+ expect(size.width).toBe(5);
73
+ });
74
+ it("clamps height to maxHeight constraint", () => {
75
+ const t = new Text({ text: "a\nb\nc\nd\ne" });
76
+ const size = t.measure(unconstrainedConstraint(80, 3));
77
+ expect(size.height).toBe(3);
78
+ });
79
+ });
80
+ describe("word wrap", () => {
81
+ it("breaks lines at spaces within maxWidth constraint", () => {
82
+ const t = new Text({ text: "hello world foo", wrap: true });
83
+ const size = t.measure(unconstrainedConstraint(10, 24));
84
+ // "hello" (5), "world foo" is 9 which fits in 10
85
+ expect(size.height).toBe(2);
86
+ expect(size.width).toBeLessThanOrEqual(10);
87
+ });
88
+ it("hard-breaks words longer than maxWidth", () => {
89
+ const t = new Text({ text: "abcdefghij", wrap: true });
90
+ const size = t.measure(unconstrainedConstraint(4, 24));
91
+ // "abcdefghij" (10 chars) wrapped at 4: "abcd", "efgh", "ij"
92
+ expect(size.height).toBe(3);
93
+ expect(size.width).toBeLessThanOrEqual(4);
94
+ });
95
+ it("wraps multiple words correctly", () => {
96
+ const t = new Text({ text: "aa bb cc dd", wrap: true });
97
+ const size = t.measure(unconstrainedConstraint(5, 24));
98
+ // "aa bb" fits in 5, "cc dd" fits in 5
99
+ expect(size.height).toBe(2);
100
+ });
101
+ it("preserves explicit newlines during wrapping", () => {
102
+ const t = new Text({ text: "ab\ncd", wrap: true });
103
+ const size = t.measure(unconstrainedConstraint(10, 24));
104
+ expect(size.height).toBe(2);
105
+ });
106
+ it("returns 0 size when maxWidth is 0", () => {
107
+ const t = new Text({ text: "hello", wrap: true });
108
+ const size = t.measure(unconstrainedConstraint(0, 24));
109
+ expect(size.width).toBe(0);
110
+ expect(size.height).toBe(0);
111
+ });
112
+ });
113
+ describe("alignment", () => {
114
+ it("left (default) aligns text at x=0 offset", () => {
115
+ const t = new Text({ text: "Hi", align: "left" });
116
+ const _size = t.measure(unconstrainedConstraint());
117
+ t.arrange({ x: 0, y: 0, width: 10, height: 1 });
118
+ const { buffer, ctx } = createRenderTarget(10, 1);
119
+ t.render(ctx);
120
+ expect(charAtBuffer(buffer, 0, 0)).toBe("H");
121
+ expect(charAtBuffer(buffer, 1, 0)).toBe("i");
122
+ });
123
+ it("center aligns text in the middle of available width", () => {
124
+ const t = new Text({ text: "Hi", align: "center" });
125
+ t.measure(unconstrainedConstraint());
126
+ t.arrange({ x: 0, y: 0, width: 10, height: 1 });
127
+ const { buffer, ctx } = createRenderTarget(10, 1);
128
+ t.render(ctx);
129
+ // "Hi" is 2 chars in 10 width => offset = floor((10 - 2) / 2) = 4
130
+ expect(charAtBuffer(buffer, 4, 0)).toBe("H");
131
+ expect(charAtBuffer(buffer, 5, 0)).toBe("i");
132
+ });
133
+ it("right aligns text at the end of available width", () => {
134
+ const t = new Text({ text: "Hi", align: "right" });
135
+ t.measure(unconstrainedConstraint());
136
+ t.arrange({ x: 0, y: 0, width: 10, height: 1 });
137
+ const { buffer, ctx } = createRenderTarget(10, 1);
138
+ t.render(ctx);
139
+ // "Hi" is 2 chars in 10 width => offset = 10 - 2 = 8
140
+ expect(charAtBuffer(buffer, 8, 0)).toBe("H");
141
+ expect(charAtBuffer(buffer, 9, 0)).toBe("i");
142
+ });
143
+ });
144
+ describe("invalidation", () => {
145
+ it("setting text property triggers invalidate (dirty becomes true)", () => {
146
+ const t = new Text({ text: "old" });
147
+ t.dirty = false; // reset dirty
148
+ t.text = "new";
149
+ expect(t.dirty).toBe(true);
150
+ });
151
+ it("does not invalidate if text is set to the same value", () => {
152
+ const t = new Text({ text: "same" });
153
+ t.dirty = false;
154
+ t.text = "same";
155
+ expect(t.dirty).toBe(false);
156
+ });
157
+ it("setting wrap triggers invalidate", () => {
158
+ const t = new Text({ text: "hi" });
159
+ t.dirty = false;
160
+ t.wrap = true;
161
+ expect(t.dirty).toBe(true);
162
+ });
163
+ it("setting align triggers invalidate", () => {
164
+ const t = new Text({ text: "hi" });
165
+ t.dirty = false;
166
+ t.align = "center";
167
+ expect(t.dirty).toBe(true);
168
+ });
169
+ it("setting style triggers invalidate", () => {
170
+ const t = new Text({ text: "hi" });
171
+ t.dirty = false;
172
+ t.style = { bold: true };
173
+ expect(t.dirty).toBe(true);
174
+ });
175
+ });
176
+ describe("render", () => {
177
+ it("draws text at correct position in buffer", () => {
178
+ const t = new Text({ text: "ABC" });
179
+ t.measure(unconstrainedConstraint());
180
+ t.arrange({ x: 2, y: 3, width: 3, height: 1 });
181
+ const { buffer, ctx } = createRenderTarget(10, 10);
182
+ t.render(ctx);
183
+ expect(charAtBuffer(buffer, 2, 3)).toBe("A");
184
+ expect(charAtBuffer(buffer, 3, 3)).toBe("B");
185
+ expect(charAtBuffer(buffer, 4, 3)).toBe("C");
186
+ // Other positions should remain space
187
+ expect(charAtBuffer(buffer, 1, 3)).toBe(" ");
188
+ expect(charAtBuffer(buffer, 5, 3)).toBe(" ");
189
+ });
190
+ it("renders multi-line text on consecutive rows", () => {
191
+ const t = new Text({ text: "AB\nCD" });
192
+ t.measure(unconstrainedConstraint());
193
+ t.arrange({ x: 0, y: 0, width: 2, height: 2 });
194
+ const { buffer, ctx } = createRenderTarget(10, 10);
195
+ t.render(ctx);
196
+ expect(charAtBuffer(buffer, 0, 0)).toBe("A");
197
+ expect(charAtBuffer(buffer, 1, 0)).toBe("B");
198
+ expect(charAtBuffer(buffer, 0, 1)).toBe("C");
199
+ expect(charAtBuffer(buffer, 1, 1)).toBe("D");
200
+ });
201
+ it("does not render if bounds are not set (null check)", () => {
202
+ const t = new Text({ text: "test" });
203
+ // Don't call measure/arrange — bounds remains default {0,0,0,0}
204
+ // but we override bounds to ensure the check
205
+ t.bounds = undefined;
206
+ const { buffer, ctx } = createRenderTarget(10, 1);
207
+ // Should not throw
208
+ t.render(ctx);
209
+ });
210
+ });
211
+ });
212
+ // ══════════════════════════════════════════════════════════════════════
213
+ // BORDER WIDGET
214
+ // ══════════════════════════════════════════════════════════════════════
215
+ describe("Border widget", () => {
216
+ describe("measure", () => {
217
+ it("adds 2 to child size (1 cell border on each side)", () => {
218
+ const child = new Text({ text: "Hi" });
219
+ child.desiredSize = { width: 2, height: 1 };
220
+ const border = new Border({ child });
221
+ const size = border.measure(unconstrainedConstraint());
222
+ expect(size.width).toBe(4); // 2 + 2
223
+ expect(size.height).toBe(3); // 1 + 2
224
+ });
225
+ it("returns 2x2 with no child (just corners)", () => {
226
+ const border = new Border();
227
+ const size = border.measure(unconstrainedConstraint());
228
+ expect(size.width).toBe(2);
229
+ expect(size.height).toBe(2);
230
+ });
231
+ });
232
+ describe("arrange", () => {
233
+ it("gives child the inner rect (inset by 1)", () => {
234
+ const child = new Text({ text: "X" });
235
+ const border = new Border({ child });
236
+ border.arrange({ x: 5, y: 3, width: 10, height: 8 });
237
+ expect(child.bounds).toEqual({
238
+ x: 6,
239
+ y: 4,
240
+ width: 8,
241
+ height: 6,
242
+ });
243
+ });
244
+ it("child rect width/height never go negative", () => {
245
+ const child = new Text({ text: "X" });
246
+ const border = new Border({ child });
247
+ border.arrange({ x: 0, y: 0, width: 1, height: 1 });
248
+ expect(child.bounds.width).toBe(0);
249
+ expect(child.bounds.height).toBe(0);
250
+ });
251
+ });
252
+ describe("render", () => {
253
+ it("draws box-drawing characters at edges", () => {
254
+ const border = new Border();
255
+ border.arrange({ x: 0, y: 0, width: 5, height: 3 });
256
+ border.bounds = { x: 0, y: 0, width: 5, height: 3 };
257
+ const { buffer, ctx } = createRenderTarget(10, 5);
258
+ border.render(ctx);
259
+ // Corners should be box-drawing characters (not spaces)
260
+ const topLeft = charAtBuffer(buffer, 0, 0);
261
+ const topRight = charAtBuffer(buffer, 4, 0);
262
+ const botLeft = charAtBuffer(buffer, 0, 2);
263
+ const botRight = charAtBuffer(buffer, 4, 2);
264
+ // These should be box-drawing chars, not regular spaces
265
+ expect(topLeft).not.toBe(" ");
266
+ expect(topRight).not.toBe(" ");
267
+ expect(botLeft).not.toBe(" ");
268
+ expect(botRight).not.toBe(" ");
269
+ // Top edge (between corners) should be horizontal box char
270
+ const topEdge = charAtBuffer(buffer, 2, 0);
271
+ expect(topEdge).not.toBe(" ");
272
+ // Left edge (between corners) should be vertical box char
273
+ const leftEdge = charAtBuffer(buffer, 0, 1);
274
+ expect(leftEdge).not.toBe(" ");
275
+ });
276
+ it("renders title in top border", () => {
277
+ const border = new Border({ title: "Test" });
278
+ border.arrange({ x: 0, y: 0, width: 20, height: 5 });
279
+ border.bounds = { x: 0, y: 0, width: 20, height: 5 };
280
+ const { buffer, ctx } = createRenderTarget(20, 5);
281
+ border.render(ctx);
282
+ // Title "Test" should appear in the top border row.
283
+ // Format is: corner, edge, then "┤ Test ├" starting at x=2
284
+ // So at x=2 we get "┤", x=3 " ", x=4..7 "Test", x=8 " ", x=9 "├"
285
+ const row0 = rowText(buffer, 0, 0, 20);
286
+ expect(row0).toContain("Test");
287
+ });
288
+ it("renders empty bordered box with no child", () => {
289
+ const border = new Border();
290
+ border.bounds = { x: 0, y: 0, width: 4, height: 3 };
291
+ const { buffer, ctx } = createRenderTarget(10, 5);
292
+ border.render(ctx);
293
+ // Interior (1,1) and (2,1) should still be space
294
+ expect(charAtBuffer(buffer, 1, 1)).toBe(" ");
295
+ expect(charAtBuffer(buffer, 2, 1)).toBe(" ");
296
+ // But border edges should be drawn
297
+ expect(charAtBuffer(buffer, 0, 0)).not.toBe(" ");
298
+ });
299
+ it("does not render title if box is too small", () => {
300
+ const border = new Border({ title: "VeryLongTitle" });
301
+ border.bounds = { x: 0, y: 0, width: 4, height: 3 };
302
+ const { buffer, ctx } = createRenderTarget(10, 5);
303
+ // Should not throw
304
+ border.render(ctx);
305
+ });
306
+ });
307
+ describe("child management", () => {
308
+ it("setting child removes old child and adds new one", () => {
309
+ const child1 = new Text({ text: "A" });
310
+ const child2 = new Text({ text: "B" });
311
+ const border = new Border({ child: child1 });
312
+ expect(border.children).toContain(child1);
313
+ border.child = child2;
314
+ expect(border.children).not.toContain(child1);
315
+ expect(border.children).toContain(child2);
316
+ });
317
+ it("setting child to null removes existing child", () => {
318
+ const child = new Text({ text: "A" });
319
+ const border = new Border({ child });
320
+ border.child = null;
321
+ expect(border.children.length).toBe(0);
322
+ expect(border.child).toBeNull();
323
+ });
324
+ });
325
+ });
326
+ // ══════════════════════════════════════════════════════════════════════
327
+ // PANEL WIDGET
328
+ // ══════════════════════════════════════════════════════════════════════
329
+ describe("Panel widget", () => {
330
+ it("extends Border", () => {
331
+ const panel = new Panel();
332
+ expect(panel).toBeInstanceOf(Border);
333
+ });
334
+ it("has background fill property with default TRANSPARENT", () => {
335
+ const panel = new Panel();
336
+ expect(panel.background).toEqual(TRANSPARENT);
337
+ });
338
+ it("accepts background color in constructor", () => {
339
+ const bg = color(100, 150, 200, 255);
340
+ const panel = new Panel({ background: bg });
341
+ expect(panel.background).toEqual(bg);
342
+ });
343
+ it("render fills interior with background color before drawing border", () => {
344
+ const bg = color(50, 100, 150, 255);
345
+ const panel = new Panel({ background: bg });
346
+ panel.bounds = { x: 0, y: 0, width: 5, height: 3 };
347
+ const { buffer, ctx } = createRenderTarget(10, 5);
348
+ // Spy on fillRect and drawBox order
349
+ const callOrder = [];
350
+ const origFillRect = ctx.fillRect.bind(ctx);
351
+ const origDrawBox = ctx.drawBox.bind(ctx);
352
+ vi.spyOn(ctx, "fillRect").mockImplementation((...args) => {
353
+ callOrder.push("fillRect");
354
+ return origFillRect(...args);
355
+ });
356
+ vi.spyOn(ctx, "drawBox").mockImplementation((...args) => {
357
+ callOrder.push("drawBox");
358
+ return origDrawBox(...args);
359
+ });
360
+ panel.render(ctx);
361
+ // fillRect should be called before drawBox
362
+ expect(callOrder.indexOf("fillRect")).toBeLessThan(callOrder.indexOf("drawBox"));
363
+ });
364
+ it("does not fill if background alpha is 0", () => {
365
+ const panel = new Panel({ background: TRANSPARENT });
366
+ panel.bounds = { x: 0, y: 0, width: 5, height: 3 };
367
+ const { buffer, ctx } = createRenderTarget(10, 5);
368
+ const fillSpy = vi.spyOn(ctx, "fillRect");
369
+ panel.render(ctx);
370
+ expect(fillSpy).not.toHaveBeenCalled();
371
+ });
372
+ it("setting background triggers invalidate", () => {
373
+ const panel = new Panel();
374
+ panel.dirty = false;
375
+ panel.background = color(255, 0, 0, 255);
376
+ expect(panel.dirty).toBe(true);
377
+ });
378
+ });
379
+ // ══════════════════════════════════════════════════════════════════════
380
+ // TEXTINPUT WIDGET
381
+ // ══════════════════════════════════════════════════════════════════════
382
+ describe("TextInput widget", () => {
383
+ describe("basic properties", () => {
384
+ it("focusable is true", () => {
385
+ const input = new TextInput();
386
+ expect(input.focusable).toBe(true);
387
+ });
388
+ it("starts with empty value and cursor at 0", () => {
389
+ const input = new TextInput();
390
+ expect(input.value).toBe("");
391
+ expect(input.cursor).toBe(0);
392
+ });
393
+ it("initializes with provided value and cursor at end", () => {
394
+ const input = new TextInput({ value: "hello" });
395
+ expect(input.value).toBe("hello");
396
+ expect(input.cursor).toBe(5);
397
+ });
398
+ });
399
+ describe("handleInput - printable characters", () => {
400
+ it("inserts printable char at cursor", () => {
401
+ const input = new TextInput();
402
+ input.handleInput(keyEvent("a", "a"));
403
+ expect(input.value).toBe("a");
404
+ expect(input.cursor).toBe(1);
405
+ });
406
+ it("inserts at current cursor position (middle of text)", () => {
407
+ const input = new TextInput({ value: "ac" });
408
+ input.cursor = 1;
409
+ input.handleInput(keyEvent("b", "b"));
410
+ expect(input.value).toBe("abc");
411
+ expect(input.cursor).toBe(2);
412
+ });
413
+ it("inserts multiple characters sequentially", () => {
414
+ const input = new TextInput();
415
+ input.handleInput(keyEvent("h", "h"));
416
+ input.handleInput(keyEvent("i", "i"));
417
+ expect(input.value).toBe("hi");
418
+ expect(input.cursor).toBe(2);
419
+ });
420
+ });
421
+ describe("handleInput - backspace", () => {
422
+ it("deletes character before cursor", () => {
423
+ const input = new TextInput({ value: "abc" });
424
+ // cursor starts at 3 (end)
425
+ input.handleInput(keyEvent("backspace"));
426
+ expect(input.value).toBe("ab");
427
+ expect(input.cursor).toBe(2);
428
+ });
429
+ it("does nothing when cursor is at start", () => {
430
+ const input = new TextInput({ value: "abc" });
431
+ input.cursor = 0;
432
+ input.handleInput(keyEvent("backspace"));
433
+ expect(input.value).toBe("abc");
434
+ expect(input.cursor).toBe(0);
435
+ });
436
+ it("deletes from middle of text", () => {
437
+ const input = new TextInput({ value: "abc" });
438
+ input.cursor = 2;
439
+ input.handleInput(keyEvent("backspace"));
440
+ expect(input.value).toBe("ac");
441
+ expect(input.cursor).toBe(1);
442
+ });
443
+ });
444
+ describe("handleInput - delete key", () => {
445
+ it("deletes character at cursor", () => {
446
+ const input = new TextInput({ value: "abc" });
447
+ input.cursor = 0;
448
+ input.handleInput(keyEvent("delete"));
449
+ expect(input.value).toBe("bc");
450
+ expect(input.cursor).toBe(0);
451
+ });
452
+ it("does nothing when cursor is at end", () => {
453
+ const input = new TextInput({ value: "abc" });
454
+ // cursor at 3 (end)
455
+ input.handleInput(keyEvent("delete"));
456
+ expect(input.value).toBe("abc");
457
+ expect(input.cursor).toBe(3);
458
+ });
459
+ it("deletes from middle of text", () => {
460
+ const input = new TextInput({ value: "abc" });
461
+ input.cursor = 1;
462
+ input.handleInput(keyEvent("delete"));
463
+ expect(input.value).toBe("ac");
464
+ expect(input.cursor).toBe(1);
465
+ });
466
+ });
467
+ describe("handleInput - cursor movement", () => {
468
+ it("left arrow moves cursor left", () => {
469
+ const input = new TextInput({ value: "abc" });
470
+ input.handleInput(keyEvent("left"));
471
+ expect(input.cursor).toBe(2);
472
+ });
473
+ it("left arrow does not go below 0", () => {
474
+ const input = new TextInput({ value: "abc" });
475
+ input.cursor = 0;
476
+ input.handleInput(keyEvent("left"));
477
+ expect(input.cursor).toBe(0);
478
+ });
479
+ it("right arrow moves cursor right", () => {
480
+ const input = new TextInput({ value: "abc" });
481
+ input.cursor = 0;
482
+ input.handleInput(keyEvent("right"));
483
+ expect(input.cursor).toBe(1);
484
+ });
485
+ it("right arrow does not go beyond text length", () => {
486
+ const input = new TextInput({ value: "abc" });
487
+ // cursor at 3 (end)
488
+ input.handleInput(keyEvent("right"));
489
+ expect(input.cursor).toBe(3);
490
+ });
491
+ it("home moves cursor to start", () => {
492
+ const input = new TextInput({ value: "abc" });
493
+ input.handleInput(keyEvent("home"));
494
+ expect(input.cursor).toBe(0);
495
+ });
496
+ it("end moves cursor to end", () => {
497
+ const input = new TextInput({ value: "abc" });
498
+ input.cursor = 0;
499
+ input.handleInput(keyEvent("end"));
500
+ expect(input.cursor).toBe(3);
501
+ });
502
+ });
503
+ describe("handleInput - enter (submit)", () => {
504
+ it("emits submit event with current value", () => {
505
+ const input = new TextInput({ value: "hello" });
506
+ const submitted = [];
507
+ input.on("submit", (val) => submitted.push(val));
508
+ input.handleInput(keyEvent("enter"));
509
+ expect(submitted).toEqual(["hello"]);
510
+ });
511
+ it("clears value after submit", () => {
512
+ const input = new TextInput({ value: "hello" });
513
+ input.handleInput(keyEvent("enter"));
514
+ expect(input.value).toBe("");
515
+ expect(input.cursor).toBe(0);
516
+ });
517
+ it("adds non-empty value to history", () => {
518
+ const input = new TextInput();
519
+ input.setValue("cmd1");
520
+ input.handleInput(keyEvent("enter"));
521
+ expect(input.history).toContain("cmd1");
522
+ });
523
+ it("does not add empty value to history", () => {
524
+ const input = new TextInput();
525
+ const initialLen = input.history.length;
526
+ input.handleInput(keyEvent("enter"));
527
+ expect(input.history.length).toBe(initialLen);
528
+ });
529
+ it("does not add duplicate of last history entry", () => {
530
+ const input = new TextInput({ history: ["cmd1"] });
531
+ input.setValue("cmd1");
532
+ input.handleInput(keyEvent("enter"));
533
+ expect(input.history.length).toBe(1);
534
+ });
535
+ });
536
+ describe("handleInput - escape (cancel)", () => {
537
+ it("emits cancel event", () => {
538
+ const input = new TextInput();
539
+ let cancelled = false;
540
+ input.on("cancel", () => {
541
+ cancelled = true;
542
+ });
543
+ input.handleInput(keyEvent("escape"));
544
+ expect(cancelled).toBe(true);
545
+ });
546
+ });
547
+ describe("handleInput - ctrl+u (clear line)", () => {
548
+ it("clears text before cursor", () => {
549
+ const input = new TextInput({ value: "abcdef" });
550
+ // cursor at 6 (end)
551
+ input.handleInput(keyEvent("u", "", false, true));
552
+ expect(input.value).toBe("");
553
+ expect(input.cursor).toBe(0);
554
+ });
555
+ it("clears only text before cursor when cursor is in middle", () => {
556
+ const input = new TextInput({ value: "abcdef" });
557
+ input.cursor = 3; // cursor at "d"
558
+ input.handleInput(keyEvent("u", "", false, true));
559
+ expect(input.value).toBe("def");
560
+ expect(input.cursor).toBe(0);
561
+ });
562
+ });
563
+ describe("history navigation", () => {
564
+ it("up arrow navigates to most recent history entry", () => {
565
+ const input = new TextInput({ history: ["first", "second", "third"] });
566
+ input.handleInput(keyEvent("up"));
567
+ expect(input.value).toBe("third");
568
+ });
569
+ it("multiple up arrows navigate backwards through history", () => {
570
+ const input = new TextInput({ history: ["first", "second", "third"] });
571
+ input.handleInput(keyEvent("up")); // third
572
+ input.handleInput(keyEvent("up")); // second
573
+ expect(input.value).toBe("second");
574
+ input.handleInput(keyEvent("up")); // first
575
+ expect(input.value).toBe("first");
576
+ });
577
+ it("up arrow at oldest entry stays at oldest", () => {
578
+ const input = new TextInput({ history: ["first", "second"] });
579
+ input.handleInput(keyEvent("up")); // second
580
+ input.handleInput(keyEvent("up")); // first
581
+ input.handleInput(keyEvent("up")); // still first
582
+ expect(input.value).toBe("first");
583
+ });
584
+ it("down arrow after up restores to later entries", () => {
585
+ const input = new TextInput({ history: ["first", "second", "third"] });
586
+ input.handleInput(keyEvent("up")); // third
587
+ input.handleInput(keyEvent("up")); // second
588
+ input.handleInput(keyEvent("down")); // third
589
+ expect(input.value).toBe("third");
590
+ });
591
+ it("down arrow past newest restores saved input", () => {
592
+ const input = new TextInput({ history: ["first", "second"] });
593
+ input.setValue("current");
594
+ input.handleInput(keyEvent("up")); // second (saves "current")
595
+ input.handleInput(keyEvent("down")); // restores "current"
596
+ expect(input.value).toBe("current");
597
+ });
598
+ it("up arrow with no history does nothing", () => {
599
+ const input = new TextInput({ value: "test" });
600
+ input.handleInput(keyEvent("up"));
601
+ expect(input.value).toBe("test");
602
+ });
603
+ it("cursor moves to end after history navigation", () => {
604
+ const input = new TextInput({ history: ["longcommand"] });
605
+ input.handleInput(keyEvent("up"));
606
+ expect(input.cursor).toBe("longcommand".length);
607
+ });
608
+ });
609
+ describe("paste event", () => {
610
+ it("inserts pasted text at cursor", () => {
611
+ const input = new TextInput({ value: "ac" });
612
+ input.cursor = 1;
613
+ input.handleInput(pasteEvent("bb"));
614
+ expect(input.value).toBe("abbc");
615
+ expect(input.cursor).toBe(3);
616
+ });
617
+ it("strips newlines from pasted text", () => {
618
+ const input = new TextInput();
619
+ input.handleInput(pasteEvent("line1\nline2\rline3"));
620
+ expect(input.value).toBe("line1line2line3");
621
+ });
622
+ it("emits paste event", () => {
623
+ const input = new TextInput();
624
+ let pasted = "";
625
+ input.on("paste", (text) => {
626
+ pasted = text;
627
+ });
628
+ input.handleInput(pasteEvent("data"));
629
+ expect(pasted).toBe("data");
630
+ });
631
+ it("handles empty paste (no change)", () => {
632
+ const input = new TextInput({ value: "test" });
633
+ input.handleInput(pasteEvent(""));
634
+ expect(input.value).toBe("test");
635
+ });
636
+ });
637
+ describe("clear()", () => {
638
+ it("resets value and cursor", () => {
639
+ const input = new TextInput({ value: "hello" });
640
+ input.clear();
641
+ expect(input.value).toBe("");
642
+ expect(input.cursor).toBe(0);
643
+ });
644
+ it("emits change event", () => {
645
+ const input = new TextInput({ value: "hello" });
646
+ let changed = false;
647
+ input.on("change", () => {
648
+ changed = true;
649
+ });
650
+ input.clear();
651
+ expect(changed).toBe(true);
652
+ });
653
+ });
654
+ describe("setValue()", () => {
655
+ it("sets value and moves cursor to end", () => {
656
+ const input = new TextInput();
657
+ input.setValue("world");
658
+ expect(input.value).toBe("world");
659
+ expect(input.cursor).toBe(5);
660
+ });
661
+ it("emits change event", () => {
662
+ const input = new TextInput();
663
+ let changedValue = "";
664
+ input.on("change", (v) => {
665
+ changedValue = v;
666
+ });
667
+ input.setValue("test");
668
+ expect(changedValue).toBe("test");
669
+ });
670
+ });
671
+ describe("cursor position validation", () => {
672
+ it("cursor stays valid after backspace at end", () => {
673
+ const input = new TextInput({ value: "ab" });
674
+ input.handleInput(keyEvent("backspace"));
675
+ expect(input.cursor).toBeLessThanOrEqual(input.value.length);
676
+ expect(input.cursor).toBeGreaterThanOrEqual(0);
677
+ });
678
+ it("cursor is clamped when value is set shorter", () => {
679
+ const input = new TextInput({ value: "abcdef" });
680
+ // cursor at 6
681
+ input.value = "ab";
682
+ expect(input.cursor).toBeLessThanOrEqual(2);
683
+ });
684
+ it("cursor setter clamps to valid range", () => {
685
+ const input = new TextInput({ value: "abc" });
686
+ input.cursor = 100;
687
+ expect(input.cursor).toBe(3);
688
+ input.cursor = -5;
689
+ expect(input.cursor).toBe(0);
690
+ });
691
+ });
692
+ describe("measure", () => {
693
+ it("always returns height 1", () => {
694
+ const input = new TextInput();
695
+ const size = input.measure(unconstrainedConstraint(50, 10));
696
+ expect(size.height).toBe(1);
697
+ });
698
+ it("takes full available width", () => {
699
+ const input = new TextInput();
700
+ const size = input.measure(unconstrainedConstraint(50, 10));
701
+ expect(size.width).toBe(50);
702
+ });
703
+ });
704
+ });
705
+ // ══════════════════════════════════════════════════════════════════════
706
+ // SCROLLVIEW WIDGET
707
+ // ══════════════════════════════════════════════════════════════════════
708
+ describe("ScrollView widget", () => {
709
+ /** Create a dummy child with a known desiredSize. */
710
+ function makeTallChild(height, width = 20) {
711
+ // Create lines of text to get the desired height
712
+ const lines = Array.from({ length: height }, (_, i) => `line ${i}`);
713
+ const child = new Text({ text: lines.join("\n") });
714
+ child.desiredSize = { width, height };
715
+ return child;
716
+ }
717
+ /**
718
+ * Helper: set up a ScrollView with proper bounds.
719
+ * ScrollView.arrange() does not call super.arrange(), so we need
720
+ * to explicitly set bounds on the ScrollView itself.
721
+ */
722
+ function setupScrollView(child, maxHeight, width = 20) {
723
+ const sv = new ScrollView({ child, maxHeight });
724
+ sv.measure(unconstrainedConstraint());
725
+ const rect = { x: 0, y: 0, width, height: maxHeight };
726
+ sv.bounds = rect;
727
+ sv.arrange(rect);
728
+ return sv;
729
+ }
730
+ describe("measure", () => {
731
+ it("clamps height to maxHeight", () => {
732
+ const child = makeTallChild(50);
733
+ const sv = new ScrollView({ child, maxHeight: 10 });
734
+ const size = sv.measure(unconstrainedConstraint(80, 100));
735
+ expect(size.height).toBe(10);
736
+ });
737
+ it("uses child height if less than maxHeight", () => {
738
+ const child = makeTallChild(5, 10);
739
+ const sv = new ScrollView({ child, maxHeight: 20 });
740
+ const size = sv.measure(unconstrainedConstraint(80, 100));
741
+ expect(size.height).toBe(5);
742
+ });
743
+ it("clamps height to constraint maxHeight", () => {
744
+ const child = makeTallChild(50, 10);
745
+ const sv = new ScrollView({ child, maxHeight: 100 });
746
+ const size = sv.measure(unconstrainedConstraint(80, 15));
747
+ expect(size.height).toBe(15);
748
+ });
749
+ it("returns (0,0) with no child", () => {
750
+ const sv = new ScrollView();
751
+ const size = sv.measure(unconstrainedConstraint());
752
+ expect(size.width).toBe(0);
753
+ expect(size.height).toBe(0);
754
+ });
755
+ });
756
+ describe("scrollOffset", () => {
757
+ it("starts at 0", () => {
758
+ const sv = new ScrollView();
759
+ expect(sv.scrollOffset).toBe(0);
760
+ });
761
+ it("can be set via the setter", () => {
762
+ const child = makeTallChild(50);
763
+ const sv = new ScrollView({ child, maxHeight: 10 });
764
+ sv.measure(unconstrainedConstraint());
765
+ sv.arrange({ x: 0, y: 0, width: 20, height: 10 });
766
+ sv.scrollOffset = 5;
767
+ expect(sv.scrollOffset).toBe(5);
768
+ });
769
+ it("clamping prevents negative offset", () => {
770
+ const child = makeTallChild(50);
771
+ const sv = new ScrollView({ child, maxHeight: 10 });
772
+ sv.measure(unconstrainedConstraint());
773
+ sv.arrange({ x: 0, y: 0, width: 20, height: 10 });
774
+ sv.scrollOffset = -5;
775
+ expect(sv.scrollOffset).toBe(0);
776
+ });
777
+ });
778
+ describe("wheel events", () => {
779
+ it("wheeldown increases scrollOffset", () => {
780
+ const sv = setupScrollView(makeTallChild(50), 10);
781
+ const handled = sv.handleInput(mouseEvent(0, 0, "none", "wheeldown"));
782
+ expect(handled).toBe(true);
783
+ expect(sv.scrollOffset).toBe(3);
784
+ });
785
+ it("wheelup decreases scrollOffset", () => {
786
+ const sv = setupScrollView(makeTallChild(50), 10);
787
+ sv.scrollOffset = 10;
788
+ sv.handleInput(mouseEvent(0, 0, "none", "wheelup"));
789
+ expect(sv.scrollOffset).toBe(7);
790
+ });
791
+ it("wheelup does not go below 0", () => {
792
+ const sv = setupScrollView(makeTallChild(50), 10);
793
+ sv.scrollOffset = 1;
794
+ sv.handleInput(mouseEvent(0, 0, "none", "wheelup"));
795
+ expect(sv.scrollOffset).toBe(0);
796
+ });
797
+ });
798
+ describe("scrollTo", () => {
799
+ it("scrolls down to make a y position visible", () => {
800
+ const sv = setupScrollView(makeTallChild(50), 10);
801
+ sv.scrollTo(15);
802
+ // y=15 should now be visible; scrollOffset should be 15 - 10 + 1 = 6
803
+ expect(sv.scrollOffset).toBe(6);
804
+ });
805
+ it("scrolls up to make a y position visible", () => {
806
+ const sv = setupScrollView(makeTallChild(50), 10);
807
+ sv.scrollOffset = 20;
808
+ sv.scrollTo(5);
809
+ expect(sv.scrollOffset).toBe(5);
810
+ });
811
+ it("does not change offset if position already visible", () => {
812
+ const sv = setupScrollView(makeTallChild(50), 10);
813
+ sv.scrollOffset = 5;
814
+ sv.scrollTo(7); // 7 is within [5, 15) range
815
+ expect(sv.scrollOffset).toBe(5);
816
+ });
817
+ });
818
+ describe("contentHeight", () => {
819
+ it("returns child's full height after measure", () => {
820
+ const child = makeTallChild(42);
821
+ const sv = new ScrollView({ child, maxHeight: 10 });
822
+ sv.measure(unconstrainedConstraint());
823
+ expect(sv.contentHeight).toBe(42);
824
+ });
825
+ it("returns 0 with no child", () => {
826
+ const sv = new ScrollView();
827
+ sv.measure(unconstrainedConstraint());
828
+ expect(sv.contentHeight).toBe(0);
829
+ });
830
+ });
831
+ describe("visibleRange", () => {
832
+ it("returns correct top/bottom", () => {
833
+ const sv = setupScrollView(makeTallChild(50), 10);
834
+ sv.scrollOffset = 5;
835
+ const range = sv.visibleRange;
836
+ expect(range.top).toBe(5);
837
+ expect(range.bottom).toBe(15); // 5 + 10
838
+ });
839
+ it("starts at 0 initially", () => {
840
+ const sv = setupScrollView(makeTallChild(50), 10);
841
+ const range = sv.visibleRange;
842
+ expect(range.top).toBe(0);
843
+ expect(range.bottom).toBe(10);
844
+ });
845
+ });
846
+ describe("arrange", () => {
847
+ it("gives child its full content height", () => {
848
+ const child = makeTallChild(50, 20);
849
+ const _sv = setupScrollView(child, 10);
850
+ expect(child.bounds.height).toBe(50);
851
+ expect(child.bounds.width).toBe(20);
852
+ });
853
+ it("offsets child y by negative scrollOffset", () => {
854
+ const child = makeTallChild(50, 20);
855
+ const sv = new ScrollView({ child, maxHeight: 10 });
856
+ sv.measure(unconstrainedConstraint());
857
+ sv.bounds = { x: 0, y: 0, width: 20, height: 10 };
858
+ sv.scrollOffset = 5;
859
+ sv.arrange({ x: 0, y: 0, width: 20, height: 10 });
860
+ expect(child.bounds.y).toBe(-5);
861
+ });
862
+ });
863
+ describe("child management", () => {
864
+ it("setting child resets scrollOffset to 0", () => {
865
+ const sv = setupScrollView(makeTallChild(50), 10);
866
+ sv.scrollOffset = 15;
867
+ const child2 = makeTallChild(30);
868
+ sv.child = child2;
869
+ expect(sv.scrollOffset).toBe(0);
870
+ });
871
+ it("setting child to null clears children list", () => {
872
+ const child = makeTallChild(20);
873
+ const sv = new ScrollView({ child });
874
+ sv.child = null;
875
+ expect(sv.children.length).toBe(0);
876
+ });
877
+ });
878
+ describe("keyboard scrolling", () => {
879
+ it("down arrow scrolls down by 1", () => {
880
+ const sv = setupScrollView(makeTallChild(50), 10);
881
+ sv.handleInput(keyEvent("down"));
882
+ expect(sv.scrollOffset).toBe(1);
883
+ });
884
+ it("up arrow scrolls up by 1", () => {
885
+ const sv = setupScrollView(makeTallChild(50), 10);
886
+ sv.scrollOffset = 5;
887
+ sv.handleInput(keyEvent("up"));
888
+ expect(sv.scrollOffset).toBe(4);
889
+ });
890
+ it("pagedown scrolls by visible height", () => {
891
+ const sv = setupScrollView(makeTallChild(50), 10);
892
+ sv.handleInput(keyEvent("pagedown"));
893
+ expect(sv.scrollOffset).toBe(10);
894
+ });
895
+ it("pageup scrolls up by visible height", () => {
896
+ const sv = setupScrollView(makeTallChild(50), 10);
897
+ sv.scrollOffset = 20;
898
+ sv.handleInput(keyEvent("pageup"));
899
+ expect(sv.scrollOffset).toBe(10);
900
+ });
901
+ });
902
+ describe("render", () => {
903
+ it("renders without errors", () => {
904
+ const child = new Text({ text: "Hello\nWorld" });
905
+ child.desiredSize = { width: 5, height: 2 };
906
+ const sv = new ScrollView({ child, maxHeight: 5 });
907
+ sv.measure(unconstrainedConstraint());
908
+ sv.arrange({ x: 0, y: 0, width: 20, height: 2 });
909
+ sv.bounds = { x: 0, y: 0, width: 20, height: 2 };
910
+ const { buffer, ctx } = createRenderTarget(20, 5);
911
+ // Should not throw
912
+ sv.render(ctx);
913
+ });
914
+ it("does not render without bounds", () => {
915
+ const child = new Text({ text: "Hi" });
916
+ child.desiredSize = { width: 2, height: 1 };
917
+ const sv = new ScrollView({ child });
918
+ sv.bounds = undefined;
919
+ const { ctx } = createRenderTarget(10, 5);
920
+ // Should not throw
921
+ sv.render(ctx);
922
+ });
923
+ });
924
+ });