@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,674 @@
1
+ import { describe, expect, it } from "vitest";
2
+ // ── background.ts ───────────────────────────────────────────────────
3
+ import { background, blendBackground, EMPTY_BACKGROUND, } from "../pixel/background.js";
4
+ // ── box-pattern.ts ──────────────────────────────────────────────────
5
+ import { BOX_CHARS, BOX_NONE, boxChar, DOWN, LEFT, mergeBoxPatterns, RIGHT, UP, } from "../pixel/box-pattern.js";
6
+ // ── buffer.ts ───────────────────────────────────────────────────────
7
+ import { PixelBuffer } from "../pixel/buffer.js";
8
+ // ── color.ts ────────────────────────────────────────────────────────
9
+ import { BLACK, BLUE, CYAN, color, colorBlend, colorBrighten, colorShade, DARK_GRAY, GRAY, GREEN, LIGHT_GRAY, MAGENTA, RED, TRANSPARENT, WHITE, YELLOW, } from "../pixel/color.js";
10
+ // ── foreground.ts ───────────────────────────────────────────────────
11
+ import { blendForeground, EMPTY_FOREGROUND, foreground, } from "../pixel/foreground.js";
12
+ // ── pixel.ts ────────────────────────────────────────────────────────
13
+ import { blendPixel, PIXEL_EMPTY, PIXEL_SPACE, pixel } from "../pixel/pixel.js";
14
+ // ── symbol.ts ───────────────────────────────────────────────────────
15
+ import { charWidth, EMPTY_SYMBOL, sym } from "../pixel/symbol.js";
16
+ // ═══════════════════════════════════════════════════════════════════
17
+ // COLOR
18
+ // ═══════════════════════════════════════════════════════════════════
19
+ describe("color", () => {
20
+ describe("color() factory", () => {
21
+ it("creates an RGBA color with explicit alpha", () => {
22
+ const c = color(10, 20, 30, 128);
23
+ expect(c).toEqual({ r: 10, g: 20, b: 30, a: 128 });
24
+ });
25
+ it("defaults alpha to 255 when omitted", () => {
26
+ const c = color(100, 150, 200);
27
+ expect(c).toEqual({ r: 100, g: 150, b: 200, a: 255 });
28
+ });
29
+ it("allows zero values for all channels", () => {
30
+ expect(color(0, 0, 0, 0)).toEqual(TRANSPARENT);
31
+ });
32
+ });
33
+ describe("color constants", () => {
34
+ it("TRANSPARENT has all zeros", () => {
35
+ expect(TRANSPARENT).toEqual({ r: 0, g: 0, b: 0, a: 0 });
36
+ });
37
+ it("BLACK is opaque black", () => {
38
+ expect(BLACK).toEqual({ r: 0, g: 0, b: 0, a: 255 });
39
+ });
40
+ it("WHITE is opaque white", () => {
41
+ expect(WHITE).toEqual({ r: 255, g: 255, b: 255, a: 255 });
42
+ });
43
+ it("primary colors have correct channels", () => {
44
+ expect(RED).toEqual({ r: 255, g: 0, b: 0, a: 255 });
45
+ expect(GREEN).toEqual({ r: 0, g: 255, b: 0, a: 255 });
46
+ expect(BLUE).toEqual({ r: 0, g: 0, b: 255, a: 255 });
47
+ });
48
+ it("secondary colors have correct channels", () => {
49
+ expect(YELLOW).toEqual({ r: 255, g: 255, b: 0, a: 255 });
50
+ expect(CYAN).toEqual({ r: 0, g: 255, b: 255, a: 255 });
51
+ expect(MAGENTA).toEqual({ r: 255, g: 0, b: 255, a: 255 });
52
+ });
53
+ it("gray variants have correct values", () => {
54
+ expect(GRAY).toEqual({ r: 128, g: 128, b: 128, a: 255 });
55
+ expect(DARK_GRAY).toEqual({ r: 64, g: 64, b: 64, a: 255 });
56
+ expect(LIGHT_GRAY).toEqual({ r: 192, g: 192, b: 192, a: 255 });
57
+ });
58
+ });
59
+ describe("colorBlend", () => {
60
+ it("fully opaque source replaces target entirely", () => {
61
+ expect(colorBlend(BLACK, RED)).toEqual(RED);
62
+ expect(colorBlend(WHITE, BLUE)).toEqual(BLUE);
63
+ });
64
+ it("fully transparent source returns target unchanged", () => {
65
+ expect(colorBlend(RED, TRANSPARENT)).toEqual(RED);
66
+ expect(colorBlend(BLUE, TRANSPARENT)).toEqual(BLUE);
67
+ });
68
+ it("transparent target returns source", () => {
69
+ expect(colorBlend(TRANSPARENT, RED)).toEqual(RED);
70
+ });
71
+ it("both transparent returns target (transparent)", () => {
72
+ expect(colorBlend(TRANSPARENT, TRANSPARENT)).toEqual(TRANSPARENT);
73
+ });
74
+ it("50% alpha source blends correctly over opaque target", () => {
75
+ const halfRed = color(255, 0, 0, 128);
76
+ const result = colorBlend(WHITE, halfRed);
77
+ // Porter-Duff over: sa=128/255~0.502, ta=1.0
78
+ // outA = 0.502 + 1*(1-0.502) = 1.0
79
+ // outR = (255*0.502 + 255*1*0.498) / 1.0 = 255
80
+ // outG = (0*0.502 + 255*1*0.498) / 1.0 ~ 127
81
+ // outB = (0*0.502 + 255*1*0.498) / 1.0 ~ 127
82
+ expect(result.a).toBe(255);
83
+ expect(result.r).toBe(255);
84
+ expect(result.g).toBeGreaterThanOrEqual(125);
85
+ expect(result.g).toBeLessThanOrEqual(129);
86
+ expect(result.b).toBeGreaterThanOrEqual(125);
87
+ expect(result.b).toBeLessThanOrEqual(129);
88
+ });
89
+ it("50% alpha source over opaque black", () => {
90
+ const halfGreen = color(0, 255, 0, 128);
91
+ const result = colorBlend(BLACK, halfGreen);
92
+ // outA = 1.0
93
+ // outR = 0, outG = 128ish, outB = 0
94
+ expect(result.a).toBe(255);
95
+ expect(result.r).toBe(0);
96
+ expect(result.g).toBeGreaterThanOrEqual(126);
97
+ expect(result.g).toBeLessThanOrEqual(130);
98
+ expect(result.b).toBe(0);
99
+ });
100
+ it("two semi-transparent colors blend together", () => {
101
+ const a = color(255, 0, 0, 128);
102
+ const b = color(0, 0, 255, 128);
103
+ const result = colorBlend(b, a);
104
+ // Both semi-transparent; result should be partially transparent
105
+ expect(result.a).toBeGreaterThan(128);
106
+ expect(result.a).toBeLessThanOrEqual(255);
107
+ expect(result.r).toBeGreaterThan(0);
108
+ expect(result.b).toBeGreaterThan(0);
109
+ });
110
+ });
111
+ describe("colorBrighten", () => {
112
+ it("factor 0 returns the original color", () => {
113
+ const result = colorBrighten(RED, 0);
114
+ expect(result).toEqual(RED);
115
+ });
116
+ it("factor 1 returns white (preserving alpha)", () => {
117
+ const result = colorBrighten(BLACK, 1);
118
+ expect(result).toEqual({ r: 255, g: 255, b: 255, a: 255 });
119
+ });
120
+ it("factor 0.5 brightens toward white", () => {
121
+ const result = colorBrighten(BLACK, 0.5);
122
+ expect(result.r).toBe(128);
123
+ expect(result.g).toBe(128);
124
+ expect(result.b).toBe(128);
125
+ expect(result.a).toBe(255);
126
+ });
127
+ it("preserves alpha channel", () => {
128
+ const c = color(100, 100, 100, 64);
129
+ const result = colorBrighten(c, 0.5);
130
+ expect(result.a).toBe(64);
131
+ });
132
+ it("clamps factor above 1 to 1", () => {
133
+ const result = colorBrighten(BLACK, 5);
134
+ expect(result).toEqual({ r: 255, g: 255, b: 255, a: 255 });
135
+ });
136
+ it("clamps factor below 0 to 0", () => {
137
+ const result = colorBrighten(RED, -1);
138
+ expect(result).toEqual(RED);
139
+ });
140
+ });
141
+ describe("colorShade", () => {
142
+ it("factor 0 returns the original color", () => {
143
+ const result = colorShade(WHITE, 0);
144
+ expect(result).toEqual(WHITE);
145
+ });
146
+ it("factor 1 returns black (preserving alpha)", () => {
147
+ const result = colorShade(WHITE, 1);
148
+ expect(result).toEqual({ r: 0, g: 0, b: 0, a: 255 });
149
+ });
150
+ it("factor 0.5 darkens by half", () => {
151
+ const result = colorShade(WHITE, 0.5);
152
+ expect(result.r).toBe(128);
153
+ expect(result.g).toBe(128);
154
+ expect(result.b).toBe(128);
155
+ });
156
+ it("preserves alpha channel", () => {
157
+ const c = color(200, 200, 200, 100);
158
+ const result = colorShade(c, 0.5);
159
+ expect(result.a).toBe(100);
160
+ });
161
+ it("clamps factor above 1 to 1", () => {
162
+ const result = colorShade(WHITE, 5);
163
+ expect(result).toEqual({ r: 0, g: 0, b: 0, a: 255 });
164
+ });
165
+ it("clamps factor below 0 to 0", () => {
166
+ const result = colorShade(WHITE, -1);
167
+ expect(result).toEqual(WHITE);
168
+ });
169
+ });
170
+ });
171
+ // ═══════════════════════════════════════════════════════════════════
172
+ // BOX PATTERN
173
+ // ═══════════════════════════════════════════════════════════════════
174
+ describe("box-pattern", () => {
175
+ describe("direction constants", () => {
176
+ it("has correct bit values", () => {
177
+ expect(UP).toBe(0x1);
178
+ expect(RIGHT).toBe(0x2);
179
+ expect(DOWN).toBe(0x4);
180
+ expect(LEFT).toBe(0x8);
181
+ });
182
+ it("BOX_NONE is zero", () => {
183
+ expect(BOX_NONE).toBe(0);
184
+ });
185
+ });
186
+ describe("BOX_CHARS lookup table", () => {
187
+ it("has exactly 16 entries", () => {
188
+ expect(BOX_CHARS).toHaveLength(16);
189
+ });
190
+ it("index 0 (none) is a space", () => {
191
+ expect(BOX_CHARS[0]).toBe(" ");
192
+ });
193
+ it("UP (0x1) and UP+DOWN (0x5) are vertical bar", () => {
194
+ expect(BOX_CHARS[UP]).toBe("\u2502"); // │
195
+ expect(BOX_CHARS[UP | DOWN]).toBe("\u2502"); // │
196
+ });
197
+ it("RIGHT (0x2), LEFT (0x8), and RIGHT+LEFT (0xA) are horizontal bar", () => {
198
+ expect(BOX_CHARS[RIGHT]).toBe("\u2500"); // ─
199
+ expect(BOX_CHARS[LEFT]).toBe("\u2500"); // ─
200
+ expect(BOX_CHARS[RIGHT | LEFT]).toBe("\u2500"); // ─
201
+ });
202
+ it("corners are correct", () => {
203
+ expect(BOX_CHARS[UP | RIGHT]).toBe("\u2514"); // └
204
+ expect(BOX_CHARS[DOWN | RIGHT]).toBe("\u250C"); // ┌
205
+ expect(BOX_CHARS[UP | LEFT]).toBe("\u2518"); // ┘
206
+ expect(BOX_CHARS[DOWN | LEFT]).toBe("\u2510"); // ┐
207
+ });
208
+ it("T-junctions are correct", () => {
209
+ expect(BOX_CHARS[UP | DOWN | RIGHT]).toBe("\u251C"); // ├
210
+ expect(BOX_CHARS[UP | DOWN | LEFT]).toBe("\u2524"); // ┤
211
+ expect(BOX_CHARS[DOWN | RIGHT | LEFT]).toBe("\u252C"); // ┬
212
+ expect(BOX_CHARS[UP | RIGHT | LEFT]).toBe("\u2534"); // ┴
213
+ });
214
+ it("all four directions is a cross", () => {
215
+ expect(BOX_CHARS[UP | RIGHT | DOWN | LEFT]).toBe("\u253C"); // ┼
216
+ });
217
+ });
218
+ describe("boxChar()", () => {
219
+ it("returns the correct character for each pattern", () => {
220
+ expect(boxChar(0)).toBe(" ");
221
+ expect(boxChar(UP | DOWN)).toBe("\u2502"); // │
222
+ expect(boxChar(LEFT | RIGHT)).toBe("\u2500"); // ─
223
+ expect(boxChar(UP | RIGHT | DOWN | LEFT)).toBe("\u253C"); // ┼
224
+ });
225
+ it("masks to 4 bits, ignoring higher bits", () => {
226
+ // Pattern 0x13 = 0x10 | 0x3 => should mask to 0x3 = UP|RIGHT = └
227
+ expect(boxChar(0x13)).toBe(boxChar(UP | RIGHT));
228
+ });
229
+ });
230
+ describe("mergeBoxPatterns()", () => {
231
+ it("ORs two patterns together", () => {
232
+ expect(mergeBoxPatterns(UP, DOWN)).toBe(UP | DOWN);
233
+ expect(mergeBoxPatterns(LEFT, RIGHT)).toBe(LEFT | RIGHT);
234
+ });
235
+ it("horizontal + vertical = cross", () => {
236
+ const horiz = LEFT | RIGHT;
237
+ const vert = UP | DOWN;
238
+ const merged = mergeBoxPatterns(horiz, vert);
239
+ expect(merged).toBe(UP | RIGHT | DOWN | LEFT);
240
+ expect(boxChar(merged)).toBe("\u253C"); // ┼
241
+ });
242
+ it("merging with BOX_NONE is identity", () => {
243
+ expect(mergeBoxPatterns(UP | RIGHT, BOX_NONE)).toBe(UP | RIGHT);
244
+ expect(mergeBoxPatterns(BOX_NONE, DOWN | LEFT)).toBe(DOWN | LEFT);
245
+ });
246
+ it("merging a pattern with itself is idempotent", () => {
247
+ const p = UP | RIGHT | DOWN;
248
+ expect(mergeBoxPatterns(p, p)).toBe(p);
249
+ });
250
+ it("masks result to 4 bits", () => {
251
+ expect(mergeBoxPatterns(0xff, 0x00)).toBe(0x0f);
252
+ });
253
+ });
254
+ });
255
+ // ═══════════════════════════════════════════════════════════════════
256
+ // SYMBOL
257
+ // ═══════════════════════════════════════════════════════════════════
258
+ describe("symbol", () => {
259
+ describe("charWidth()", () => {
260
+ it("ASCII characters are width 1", () => {
261
+ expect(charWidth("A".codePointAt(0))).toBe(1);
262
+ expect(charWidth(" ".codePointAt(0))).toBe(1);
263
+ expect(charWidth("~".codePointAt(0))).toBe(1);
264
+ expect(charWidth("0".codePointAt(0))).toBe(1);
265
+ });
266
+ it("CJK Unified Ideographs are width 2", () => {
267
+ // U+4E00 (first CJK Unified Ideograph)
268
+ expect(charWidth(0x4e00)).toBe(2);
269
+ // U+9FFF (last CJK Unified Ideograph)
270
+ expect(charWidth(0x9fff)).toBe(2);
271
+ });
272
+ it("Hiragana characters are width 2", () => {
273
+ // U+3042 = あ
274
+ expect(charWidth(0x3042)).toBe(2);
275
+ });
276
+ it("Katakana characters are width 2", () => {
277
+ // U+30A2 = ア
278
+ expect(charWidth(0x30a2)).toBe(2);
279
+ });
280
+ it("Hangul syllables are width 2", () => {
281
+ // U+AC00 = 가
282
+ expect(charWidth(0xac00)).toBe(2);
283
+ });
284
+ it("Fullwidth forms are width 2", () => {
285
+ // U+FF01 = ! (fullwidth exclamation mark)
286
+ expect(charWidth(0xff01)).toBe(2);
287
+ });
288
+ it("CJK Extension B characters are width 2", () => {
289
+ expect(charWidth(0x20000)).toBe(2);
290
+ });
291
+ it("Latin Extended characters are width 1", () => {
292
+ // U+00E9 = é
293
+ expect(charWidth(0x00e9)).toBe(1);
294
+ });
295
+ it("Emoji in SMP range are width 2", () => {
296
+ // U+1F600 = grinning face (in emoji range 0x1f000-0x1faff)
297
+ expect(charWidth(0x1f600)).toBe(2);
298
+ });
299
+ });
300
+ describe("sym() factory", () => {
301
+ it("creates a symbol with auto-detected width 1 for ASCII", () => {
302
+ const s = sym("A");
303
+ expect(s.text).toBe("A");
304
+ expect(s.width).toBe(1);
305
+ expect(s.pattern).toBe(0);
306
+ });
307
+ it("creates a symbol with auto-detected width 2 for CJK", () => {
308
+ const s = sym("\u4E00"); // 一
309
+ expect(s.text).toBe("\u4E00");
310
+ expect(s.width).toBe(2);
311
+ expect(s.pattern).toBe(0);
312
+ });
313
+ it("creates a symbol with a box pattern", () => {
314
+ const s = sym("\u2500", LEFT | RIGHT);
315
+ expect(s.text).toBe("\u2500");
316
+ expect(s.width).toBe(1); // box patterns force width 1
317
+ expect(s.pattern).toBe(LEFT | RIGHT);
318
+ });
319
+ it("forces width 1 when pattern is non-zero regardless of char", () => {
320
+ // Even though 一 is CJK wide, a box pattern overrides to width 1
321
+ const s = sym("\u4E00", UP);
322
+ expect(s.width).toBe(1);
323
+ });
324
+ });
325
+ describe("EMPTY_SYMBOL constant", () => {
326
+ it("is a space with width 1 and no pattern", () => {
327
+ expect(EMPTY_SYMBOL).toEqual({ text: " ", width: 1, pattern: 0 });
328
+ });
329
+ });
330
+ });
331
+ // ═══════════════════════════════════════════════════════════════════
332
+ // FOREGROUND
333
+ // ═══════════════════════════════════════════════════════════════════
334
+ describe("foreground", () => {
335
+ describe("foreground() factory", () => {
336
+ it("creates a foreground with defaults", () => {
337
+ const fg = foreground();
338
+ expect(fg.symbol).toBe(EMPTY_SYMBOL);
339
+ expect(fg.color).toBe(TRANSPARENT);
340
+ expect(fg.bold).toBe(false);
341
+ expect(fg.italic).toBe(false);
342
+ expect(fg.underline).toBe(false);
343
+ expect(fg.strikethrough).toBe(false);
344
+ });
345
+ it("accepts a symbol and color", () => {
346
+ const s = sym("X");
347
+ const fg = foreground(s, RED);
348
+ expect(fg.symbol).toBe(s);
349
+ expect(fg.color).toBe(RED);
350
+ });
351
+ it("accepts style options", () => {
352
+ const fg = foreground(sym("B"), WHITE, {
353
+ bold: true,
354
+ italic: true,
355
+ underline: true,
356
+ strikethrough: true,
357
+ });
358
+ expect(fg.bold).toBe(true);
359
+ expect(fg.italic).toBe(true);
360
+ expect(fg.underline).toBe(true);
361
+ expect(fg.strikethrough).toBe(true);
362
+ });
363
+ it("partial style options default unset flags to false", () => {
364
+ const fg = foreground(sym("X"), WHITE, { bold: true });
365
+ expect(fg.bold).toBe(true);
366
+ expect(fg.italic).toBe(false);
367
+ expect(fg.underline).toBe(false);
368
+ expect(fg.strikethrough).toBe(false);
369
+ });
370
+ });
371
+ describe("EMPTY_FOREGROUND constant", () => {
372
+ it("has empty symbol, transparent color, no styles", () => {
373
+ expect(EMPTY_FOREGROUND.symbol).toEqual(EMPTY_SYMBOL);
374
+ expect(EMPTY_FOREGROUND.color).toEqual(TRANSPARENT);
375
+ expect(EMPTY_FOREGROUND.bold).toBe(false);
376
+ expect(EMPTY_FOREGROUND.italic).toBe(false);
377
+ expect(EMPTY_FOREGROUND.underline).toBe(false);
378
+ expect(EMPTY_FOREGROUND.strikethrough).toBe(false);
379
+ });
380
+ });
381
+ describe("blendForeground()", () => {
382
+ it("transparent space above returns below unchanged", () => {
383
+ const below = foreground(sym("A"), RED, { bold: true });
384
+ const above = foreground(EMPTY_SYMBOL, TRANSPARENT);
385
+ expect(blendForeground(above, below)).toBe(below);
386
+ });
387
+ it("opaque character above replaces below symbol", () => {
388
+ const below = foreground(sym("A"), RED);
389
+ const above = foreground(sym("B"), BLUE);
390
+ const result = blendForeground(above, below);
391
+ expect(result.symbol.text).toBe("B");
392
+ });
393
+ it("opaque space above with non-transparent color lets below symbol show through", () => {
394
+ const below = foreground(sym("A"), RED);
395
+ const above = foreground(EMPTY_SYMBOL, WHITE);
396
+ const result = blendForeground(above, below);
397
+ // Space with opaque color: below symbol shows through, colors blend
398
+ expect(result.symbol.text).toBe("A");
399
+ });
400
+ it("merges box patterns when both have them", () => {
401
+ const below = foreground(sym(boxChar(LEFT | RIGHT), LEFT | RIGHT), RED);
402
+ const above = foreground(sym(boxChar(UP | DOWN), UP | DOWN), BLUE);
403
+ const result = blendForeground(above, below);
404
+ expect(result.symbol.pattern).toBe(UP | RIGHT | DOWN | LEFT);
405
+ expect(result.symbol.text).toBe("\u253C"); // ┼
406
+ });
407
+ it("merges style flags via OR when both have box patterns", () => {
408
+ const below = foreground(sym(boxChar(LEFT | RIGHT), LEFT | RIGHT), RED, {
409
+ bold: true,
410
+ });
411
+ const above = foreground(sym(boxChar(UP | DOWN), UP | DOWN), BLUE, {
412
+ italic: true,
413
+ });
414
+ const result = blendForeground(above, below);
415
+ expect(result.bold).toBe(true);
416
+ expect(result.italic).toBe(true);
417
+ });
418
+ it("non-space above takes its own style flags", () => {
419
+ const below = foreground(sym("A"), RED, { bold: true, underline: true });
420
+ const above = foreground(sym("B"), BLUE, { italic: true });
421
+ const result = blendForeground(above, below);
422
+ expect(result.bold).toBe(false);
423
+ expect(result.italic).toBe(true);
424
+ expect(result.underline).toBe(false);
425
+ });
426
+ it("space above (with opaque color) takes below's style flags", () => {
427
+ const below = foreground(sym("A"), RED, { bold: true, underline: true });
428
+ const above = foreground(EMPTY_SYMBOL, WHITE);
429
+ const result = blendForeground(above, below);
430
+ expect(result.bold).toBe(true);
431
+ expect(result.underline).toBe(true);
432
+ });
433
+ });
434
+ });
435
+ // ═══════════════════════════════════════════════════════════════════
436
+ // BACKGROUND
437
+ // ═══════════════════════════════════════════════════════════════════
438
+ describe("background", () => {
439
+ describe("background() factory", () => {
440
+ it("defaults to transparent", () => {
441
+ const bg = background();
442
+ expect(bg.color).toEqual(TRANSPARENT);
443
+ });
444
+ it("accepts a color", () => {
445
+ const bg = background(RED);
446
+ expect(bg.color).toEqual(RED);
447
+ });
448
+ });
449
+ describe("EMPTY_BACKGROUND constant", () => {
450
+ it("has transparent color", () => {
451
+ expect(EMPTY_BACKGROUND.color).toEqual(TRANSPARENT);
452
+ });
453
+ });
454
+ describe("blendBackground()", () => {
455
+ it("opaque above replaces below", () => {
456
+ const result = blendBackground(background(RED), background(BLUE));
457
+ expect(result.color).toEqual(RED);
458
+ });
459
+ it("transparent above returns below", () => {
460
+ const result = blendBackground(background(TRANSPARENT), background(GREEN));
461
+ expect(result.color).toEqual(GREEN);
462
+ });
463
+ it("semi-transparent above blends with below", () => {
464
+ const above = background(color(255, 0, 0, 128));
465
+ const below = background(BLACK);
466
+ const result = blendBackground(above, below);
467
+ expect(result.color.a).toBe(255);
468
+ expect(result.color.r).toBeGreaterThan(100);
469
+ expect(result.color.g).toBe(0);
470
+ expect(result.color.b).toBe(0);
471
+ });
472
+ });
473
+ });
474
+ // ═══════════════════════════════════════════════════════════════════
475
+ // PIXEL
476
+ // ═══════════════════════════════════════════════════════════════════
477
+ describe("pixel", () => {
478
+ describe("pixel() factory", () => {
479
+ it("creates a pixel with defaults", () => {
480
+ const p = pixel();
481
+ expect(p.foreground).toBe(EMPTY_FOREGROUND);
482
+ expect(p.background).toBe(EMPTY_BACKGROUND);
483
+ });
484
+ it("accepts foreground and background", () => {
485
+ const fg = foreground(sym("X"), RED);
486
+ const bg = background(BLUE);
487
+ const p = pixel(fg, bg);
488
+ expect(p.foreground).toBe(fg);
489
+ expect(p.background).toBe(bg);
490
+ });
491
+ });
492
+ describe("PIXEL_EMPTY constant", () => {
493
+ it("has empty foreground and background", () => {
494
+ expect(PIXEL_EMPTY.foreground).toEqual(EMPTY_FOREGROUND);
495
+ expect(PIXEL_EMPTY.background).toEqual(EMPTY_BACKGROUND);
496
+ });
497
+ });
498
+ describe("PIXEL_SPACE constant", () => {
499
+ it("has space symbol and transparent colors", () => {
500
+ expect(PIXEL_SPACE.foreground.symbol).toEqual(EMPTY_SYMBOL);
501
+ expect(PIXEL_SPACE.foreground.color).toEqual(TRANSPARENT);
502
+ expect(PIXEL_SPACE.background.color).toEqual(TRANSPARENT);
503
+ });
504
+ it("has no style flags set", () => {
505
+ expect(PIXEL_SPACE.foreground.bold).toBe(false);
506
+ expect(PIXEL_SPACE.foreground.italic).toBe(false);
507
+ expect(PIXEL_SPACE.foreground.underline).toBe(false);
508
+ expect(PIXEL_SPACE.foreground.strikethrough).toBe(false);
509
+ });
510
+ });
511
+ describe("blendPixel()", () => {
512
+ it("composites foreground and background independently", () => {
513
+ const below = pixel(foreground(sym("A"), RED), background(BLUE));
514
+ const above = pixel(foreground(sym("B"), GREEN), background(color(255, 0, 0, 128)));
515
+ const result = blendPixel(above, below);
516
+ // Foreground: above has 'B', which should replace 'A'
517
+ expect(result.foreground.symbol.text).toBe("B");
518
+ // Background: semi-transparent red over opaque blue => blended
519
+ expect(result.background.color.a).toBe(255);
520
+ });
521
+ it("transparent pixel over content returns content", () => {
522
+ const below = pixel(foreground(sym("X"), WHITE), background(RED));
523
+ const result = blendPixel(PIXEL_EMPTY, below);
524
+ // PIXEL_EMPTY fg is transparent space, so below shows through
525
+ expect(result.foreground.symbol.text).toBe("X");
526
+ expect(result.background.color).toEqual(RED);
527
+ });
528
+ it("content pixel over empty replaces empty", () => {
529
+ const above = pixel(foreground(sym("Z"), BLUE), background(GREEN));
530
+ const result = blendPixel(above, PIXEL_EMPTY);
531
+ expect(result.foreground.symbol.text).toBe("Z");
532
+ expect(result.background.color).toEqual(GREEN);
533
+ });
534
+ });
535
+ });
536
+ // ═══════════════════════════════════════════════════════════════════
537
+ // BUFFER
538
+ // ═══════════════════════════════════════════════════════════════════
539
+ describe("PixelBuffer", () => {
540
+ describe("constructor", () => {
541
+ it("creates a buffer with correct dimensions", () => {
542
+ const buf = new PixelBuffer(10, 5);
543
+ expect(buf.width).toBe(10);
544
+ expect(buf.height).toBe(5);
545
+ });
546
+ it("initializes all cells to PIXEL_SPACE", () => {
547
+ const buf = new PixelBuffer(3, 3);
548
+ for (let y = 0; y < 3; y++) {
549
+ for (let x = 0; x < 3; x++) {
550
+ expect(buf.get(x, y)).toBe(PIXEL_SPACE);
551
+ }
552
+ }
553
+ });
554
+ });
555
+ describe("get/set round-trip", () => {
556
+ it("retrieves a previously set pixel", () => {
557
+ const buf = new PixelBuffer(5, 5);
558
+ const p = pixel(foreground(sym("Q"), RED), background(BLUE));
559
+ buf.set(2, 3, p);
560
+ expect(buf.get(2, 3)).toBe(p);
561
+ });
562
+ it("setting a pixel does not affect other cells", () => {
563
+ const buf = new PixelBuffer(5, 5);
564
+ const p = pixel(foreground(sym("Q"), RED), background(BLUE));
565
+ buf.set(2, 3, p);
566
+ expect(buf.get(0, 0)).toBe(PIXEL_SPACE);
567
+ expect(buf.get(4, 4)).toBe(PIXEL_SPACE);
568
+ expect(buf.get(2, 2)).toBe(PIXEL_SPACE);
569
+ expect(buf.get(3, 3)).toBe(PIXEL_SPACE);
570
+ });
571
+ it("can overwrite a cell", () => {
572
+ const buf = new PixelBuffer(5, 5);
573
+ const p1 = pixel(foreground(sym("A"), RED));
574
+ const p2 = pixel(foreground(sym("B"), BLUE));
575
+ buf.set(1, 1, p1);
576
+ buf.set(1, 1, p2);
577
+ expect(buf.get(1, 1)).toBe(p2);
578
+ });
579
+ });
580
+ describe("out of bounds access", () => {
581
+ it("get returns PIXEL_SPACE for negative coordinates", () => {
582
+ const buf = new PixelBuffer(5, 5);
583
+ expect(buf.get(-1, 0)).toBe(PIXEL_SPACE);
584
+ expect(buf.get(0, -1)).toBe(PIXEL_SPACE);
585
+ expect(buf.get(-1, -1)).toBe(PIXEL_SPACE);
586
+ });
587
+ it("get returns PIXEL_SPACE for coordinates beyond bounds", () => {
588
+ const buf = new PixelBuffer(5, 5);
589
+ expect(buf.get(5, 0)).toBe(PIXEL_SPACE);
590
+ expect(buf.get(0, 5)).toBe(PIXEL_SPACE);
591
+ expect(buf.get(100, 100)).toBe(PIXEL_SPACE);
592
+ });
593
+ it("set silently ignores out-of-bounds writes", () => {
594
+ const buf = new PixelBuffer(5, 5);
595
+ const p = pixel(foreground(sym("X"), RED));
596
+ // These should not throw
597
+ buf.set(-1, 0, p);
598
+ buf.set(0, -1, p);
599
+ buf.set(5, 0, p);
600
+ buf.set(0, 5, p);
601
+ buf.set(100, 100, p);
602
+ // Buffer should remain unchanged
603
+ for (let y = 0; y < 5; y++) {
604
+ for (let x = 0; x < 5; x++) {
605
+ expect(buf.get(x, y)).toBe(PIXEL_SPACE);
606
+ }
607
+ }
608
+ });
609
+ });
610
+ describe("fill()", () => {
611
+ it("fills a rectangular region", () => {
612
+ const buf = new PixelBuffer(5, 5);
613
+ const p = pixel(foreground(sym("F"), GREEN), background(RED));
614
+ buf.fill({ x: 1, y: 1, width: 3, height: 2 }, p);
615
+ // Inside the rect
616
+ for (let y = 1; y <= 2; y++) {
617
+ for (let x = 1; x <= 3; x++) {
618
+ expect(buf.get(x, y)).toBe(p);
619
+ }
620
+ }
621
+ // Outside the rect
622
+ expect(buf.get(0, 0)).toBe(PIXEL_SPACE);
623
+ expect(buf.get(4, 4)).toBe(PIXEL_SPACE);
624
+ expect(buf.get(0, 1)).toBe(PIXEL_SPACE);
625
+ expect(buf.get(4, 1)).toBe(PIXEL_SPACE);
626
+ });
627
+ it("clips to buffer bounds when rect extends beyond", () => {
628
+ const buf = new PixelBuffer(3, 3);
629
+ const p = pixel(foreground(sym("C"), BLUE));
630
+ buf.fill({ x: -1, y: -1, width: 5, height: 5 }, p);
631
+ // All cells should be filled since the clipped rect covers the entire buffer
632
+ for (let y = 0; y < 3; y++) {
633
+ for (let x = 0; x < 3; x++) {
634
+ expect(buf.get(x, y)).toBe(p);
635
+ }
636
+ }
637
+ });
638
+ it("handles rect fully outside the buffer (no-op)", () => {
639
+ const buf = new PixelBuffer(3, 3);
640
+ const p = pixel(foreground(sym("X"), RED));
641
+ buf.fill({ x: 10, y: 10, width: 5, height: 5 }, p);
642
+ for (let y = 0; y < 3; y++) {
643
+ for (let x = 0; x < 3; x++) {
644
+ expect(buf.get(x, y)).toBe(PIXEL_SPACE);
645
+ }
646
+ }
647
+ });
648
+ it("handles zero-size rect (no-op)", () => {
649
+ const buf = new PixelBuffer(3, 3);
650
+ const p = pixel(foreground(sym("X"), RED));
651
+ buf.fill({ x: 0, y: 0, width: 0, height: 0 }, p);
652
+ expect(buf.get(0, 0)).toBe(PIXEL_SPACE);
653
+ });
654
+ });
655
+ describe("clear()", () => {
656
+ it("resets all cells to PIXEL_SPACE", () => {
657
+ const buf = new PixelBuffer(3, 3);
658
+ const p = pixel(foreground(sym("D"), MAGENTA));
659
+ // Fill buffer with content
660
+ for (let y = 0; y < 3; y++) {
661
+ for (let x = 0; x < 3; x++) {
662
+ buf.set(x, y, p);
663
+ }
664
+ }
665
+ buf.clear();
666
+ // All cells should be PIXEL_SPACE again
667
+ for (let y = 0; y < 3; y++) {
668
+ for (let x = 0; x < 3; x++) {
669
+ expect(buf.get(x, y)).toBe(PIXEL_SPACE);
670
+ }
671
+ }
672
+ });
673
+ });
674
+ });
@@ -0,0 +1 @@
1
+ export {};