@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.
- package/README.md +48 -0
- package/dist/__tests__/ansi.test.d.ts +1 -0
- package/dist/__tests__/ansi.test.js +520 -0
- package/dist/__tests__/chat-view.test.d.ts +4 -0
- package/dist/__tests__/chat-view.test.js +480 -0
- package/dist/__tests__/drawing.test.d.ts +4 -0
- package/dist/__tests__/drawing.test.js +426 -0
- package/dist/__tests__/input.test.d.ts +5 -0
- package/dist/__tests__/input.test.js +911 -0
- package/dist/__tests__/layout.test.d.ts +4 -0
- package/dist/__tests__/layout.test.js +689 -0
- package/dist/__tests__/pixel.test.d.ts +1 -0
- package/dist/__tests__/pixel.test.js +674 -0
- package/dist/__tests__/render.test.d.ts +1 -0
- package/dist/__tests__/render.test.js +400 -0
- package/dist/__tests__/styled.test.d.ts +4 -0
- package/dist/__tests__/styled.test.js +149 -0
- package/dist/__tests__/widgets.test.d.ts +5 -0
- package/dist/__tests__/widgets.test.js +924 -0
- package/dist/ansi/esc.d.ts +61 -0
- package/dist/ansi/esc.js +85 -0
- package/dist/ansi/output.d.ts +66 -0
- package/dist/ansi/output.js +192 -0
- package/dist/ansi/strip.d.ts +16 -0
- package/dist/ansi/strip.js +74 -0
- package/dist/app.d.ts +68 -0
- package/dist/app.js +297 -0
- package/dist/drawing/clip.d.ts +23 -0
- package/dist/drawing/clip.js +67 -0
- package/dist/drawing/context.d.ts +77 -0
- package/dist/drawing/context.js +275 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +63 -0
- package/dist/input/escape-matcher.d.ts +27 -0
- package/dist/input/escape-matcher.js +253 -0
- package/dist/input/events.d.ts +49 -0
- package/dist/input/events.js +17 -0
- package/dist/input/index.d.ts +15 -0
- package/dist/input/index.js +14 -0
- package/dist/input/matcher.d.ts +23 -0
- package/dist/input/matcher.js +14 -0
- package/dist/input/mouse-matcher.d.ts +27 -0
- package/dist/input/mouse-matcher.js +142 -0
- package/dist/input/paste-matcher.d.ts +23 -0
- package/dist/input/paste-matcher.js +104 -0
- package/dist/input/processor.d.ts +51 -0
- package/dist/input/processor.js +145 -0
- package/dist/input/raw-mode.d.ts +13 -0
- package/dist/input/raw-mode.js +24 -0
- package/dist/input/text-matcher.d.ts +14 -0
- package/dist/input/text-matcher.js +32 -0
- package/dist/layout/box.d.ts +33 -0
- package/dist/layout/box.js +92 -0
- package/dist/layout/column.d.ts +21 -0
- package/dist/layout/column.js +90 -0
- package/dist/layout/control.d.ts +73 -0
- package/dist/layout/control.js +215 -0
- package/dist/layout/row.d.ts +21 -0
- package/dist/layout/row.js +95 -0
- package/dist/layout/stack.d.ts +18 -0
- package/dist/layout/stack.js +64 -0
- package/dist/layout/types.d.ts +27 -0
- package/dist/layout/types.js +4 -0
- package/dist/pixel/background.d.ts +16 -0
- package/dist/pixel/background.js +16 -0
- package/dist/pixel/box-pattern.d.ts +38 -0
- package/dist/pixel/box-pattern.js +57 -0
- package/dist/pixel/buffer.d.ts +25 -0
- package/dist/pixel/buffer.js +51 -0
- package/dist/pixel/color.d.ts +48 -0
- package/dist/pixel/color.js +92 -0
- package/dist/pixel/foreground.d.ts +31 -0
- package/dist/pixel/foreground.js +64 -0
- package/dist/pixel/pixel.d.ts +21 -0
- package/dist/pixel/pixel.js +38 -0
- package/dist/pixel/symbol.d.ts +38 -0
- package/dist/pixel/symbol.js +192 -0
- package/dist/render/regions.d.ts +54 -0
- package/dist/render/regions.js +102 -0
- package/dist/render/render-target.d.ts +42 -0
- package/dist/render/render-target.js +118 -0
- package/dist/styled.d.ts +113 -0
- package/dist/styled.js +176 -0
- package/dist/widgets/border.d.ts +34 -0
- package/dist/widgets/border.js +121 -0
- package/dist/widgets/chat-view.d.ts +239 -0
- package/dist/widgets/chat-view.js +993 -0
- package/dist/widgets/interview.d.ts +87 -0
- package/dist/widgets/interview.js +187 -0
- package/dist/widgets/markdown.d.ts +87 -0
- package/dist/widgets/markdown.js +611 -0
- package/dist/widgets/panel.d.ts +19 -0
- package/dist/widgets/panel.js +35 -0
- package/dist/widgets/scroll-view.d.ts +43 -0
- package/dist/widgets/scroll-view.js +182 -0
- package/dist/widgets/styled-text.d.ts +38 -0
- package/dist/widgets/styled-text.js +183 -0
- package/dist/widgets/syntax.d.ts +37 -0
- package/dist/widgets/syntax.js +670 -0
- package/dist/widgets/text-input.d.ts +121 -0
- package/dist/widgets/text-input.js +618 -0
- package/dist/widgets/text.d.ts +34 -0
- package/dist/widgets/text.js +168 -0
- package/package.json +45 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the ChatView widget.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { DrawingContext } from "../drawing/context.js";
|
|
6
|
+
import { keyEvent, mouseEvent } from "../input/events.js";
|
|
7
|
+
import { PixelBuffer } from "../pixel/buffer.js";
|
|
8
|
+
import { ChatView } from "../widgets/chat-view.js";
|
|
9
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
10
|
+
function createRenderTarget(width = 60, height = 20) {
|
|
11
|
+
const buffer = new PixelBuffer(width, height);
|
|
12
|
+
const ctx = new DrawingContext(buffer);
|
|
13
|
+
return { buffer, ctx };
|
|
14
|
+
}
|
|
15
|
+
function rowText(buffer, y, x0 = 0, x1) {
|
|
16
|
+
const end = x1 ?? buffer.width;
|
|
17
|
+
let s = "";
|
|
18
|
+
for (let x = x0; x < end; x++) {
|
|
19
|
+
s += buffer.get(x, y).foreground.symbol.text;
|
|
20
|
+
}
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
/** Full layout + render a ChatView at the given dimensions. */
|
|
24
|
+
function layoutAndRender(chat, width = 60, height = 20) {
|
|
25
|
+
const { buffer, ctx } = createRenderTarget(width, height);
|
|
26
|
+
const constraint = {
|
|
27
|
+
minWidth: 0,
|
|
28
|
+
minHeight: 0,
|
|
29
|
+
maxWidth: width,
|
|
30
|
+
maxHeight: height,
|
|
31
|
+
};
|
|
32
|
+
chat.measure(constraint);
|
|
33
|
+
chat.arrange({ x: 0, y: 0, width, height });
|
|
34
|
+
chat.render(ctx);
|
|
35
|
+
return { buffer, ctx };
|
|
36
|
+
}
|
|
37
|
+
/** Create a key event for a printable character. */
|
|
38
|
+
function charKey(ch) {
|
|
39
|
+
return keyEvent(ch, ch);
|
|
40
|
+
}
|
|
41
|
+
/** Create a key event for a special key (non-printable). */
|
|
42
|
+
function specialKey(key) {
|
|
43
|
+
return keyEvent(key);
|
|
44
|
+
}
|
|
45
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
46
|
+
describe("ChatView", () => {
|
|
47
|
+
describe("construction", () => {
|
|
48
|
+
it("creates with default options", () => {
|
|
49
|
+
const chat = new ChatView();
|
|
50
|
+
expect(chat).toBeDefined();
|
|
51
|
+
expect(chat.inputValue).toBe("");
|
|
52
|
+
expect(chat.feedLineCount).toBe(0);
|
|
53
|
+
expect(chat.banner).toBe("");
|
|
54
|
+
});
|
|
55
|
+
it("creates with custom options", () => {
|
|
56
|
+
const chat = new ChatView({
|
|
57
|
+
banner: "Welcome!",
|
|
58
|
+
prompt: "> ",
|
|
59
|
+
placeholder: "Type here...",
|
|
60
|
+
});
|
|
61
|
+
expect(chat.banner).toBe("Welcome!");
|
|
62
|
+
expect(chat.prompt).toBe("> ");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("banner", () => {
|
|
66
|
+
it("renders banner text at top", () => {
|
|
67
|
+
const chat = new ChatView({ banner: "Hello" });
|
|
68
|
+
const { buffer } = layoutAndRender(chat, 40, 10);
|
|
69
|
+
const row0 = rowText(buffer, 0, 0, 5);
|
|
70
|
+
expect(row0).toBe("Hello");
|
|
71
|
+
});
|
|
72
|
+
it("hides banner when empty", () => {
|
|
73
|
+
const chat = new ChatView({ banner: "" });
|
|
74
|
+
layoutAndRender(chat, 40, 10);
|
|
75
|
+
expect(chat.banner).toBe("");
|
|
76
|
+
});
|
|
77
|
+
it("can update banner text", () => {
|
|
78
|
+
const chat = new ChatView({ banner: "Old" });
|
|
79
|
+
chat.banner = "New Title";
|
|
80
|
+
expect(chat.banner).toBe("New Title");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe("feed", () => {
|
|
84
|
+
it("starts empty", () => {
|
|
85
|
+
const chat = new ChatView();
|
|
86
|
+
expect(chat.feedLineCount).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
it("appends lines", () => {
|
|
89
|
+
const chat = new ChatView();
|
|
90
|
+
chat.appendToFeed("line 1");
|
|
91
|
+
chat.appendToFeed("line 2");
|
|
92
|
+
expect(chat.feedLineCount).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
it("appends multiple lines at once", () => {
|
|
95
|
+
const chat = new ChatView();
|
|
96
|
+
chat.appendLines(["a", "b", "c"]);
|
|
97
|
+
expect(chat.feedLineCount).toBe(3);
|
|
98
|
+
});
|
|
99
|
+
it("renders feed lines in the feed area", () => {
|
|
100
|
+
const chat = new ChatView({ banner: "B" });
|
|
101
|
+
chat.appendToFeed("Hello feed");
|
|
102
|
+
const { buffer } = layoutAndRender(chat, 40, 10);
|
|
103
|
+
// Row 0: banner "B"
|
|
104
|
+
// Row 1: separator
|
|
105
|
+
// Row 2+: feed area — should contain "Hello feed"
|
|
106
|
+
const feedRow = rowText(buffer, 2, 0, 10);
|
|
107
|
+
expect(feedRow).toBe("Hello feed");
|
|
108
|
+
});
|
|
109
|
+
it("clears feed lines", () => {
|
|
110
|
+
const chat = new ChatView();
|
|
111
|
+
chat.appendToFeed("msg1");
|
|
112
|
+
chat.appendToFeed("msg2");
|
|
113
|
+
expect(chat.feedLineCount).toBe(2);
|
|
114
|
+
chat.clear();
|
|
115
|
+
expect(chat.feedLineCount).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
it("scrolling adjusts feed offset", () => {
|
|
118
|
+
const chat = new ChatView();
|
|
119
|
+
for (let i = 0; i < 30; i++) {
|
|
120
|
+
chat.appendToFeed(`line ${i}`);
|
|
121
|
+
}
|
|
122
|
+
chat.scrollFeed(-5);
|
|
123
|
+
// Should not crash
|
|
124
|
+
layoutAndRender(chat, 40, 10);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe("input", () => {
|
|
128
|
+
it("handles key input", () => {
|
|
129
|
+
const chat = new ChatView();
|
|
130
|
+
const submitted = vi.fn();
|
|
131
|
+
chat.on("submit", submitted);
|
|
132
|
+
// Type "hello"
|
|
133
|
+
for (const ch of "hello") {
|
|
134
|
+
chat.handleInput(charKey(ch));
|
|
135
|
+
}
|
|
136
|
+
expect(chat.inputValue).toBe("hello");
|
|
137
|
+
// Press enter
|
|
138
|
+
chat.handleInput(specialKey("enter"));
|
|
139
|
+
expect(submitted).toHaveBeenCalledWith("hello");
|
|
140
|
+
});
|
|
141
|
+
it("emits change on input", () => {
|
|
142
|
+
const chat = new ChatView();
|
|
143
|
+
const changed = vi.fn();
|
|
144
|
+
chat.on("change", changed);
|
|
145
|
+
chat.handleInput(charKey("a"));
|
|
146
|
+
expect(changed).toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
it("can set input value programmatically", () => {
|
|
149
|
+
const chat = new ChatView();
|
|
150
|
+
chat.inputValue = "/help";
|
|
151
|
+
expect(chat.inputValue).toBe("/help");
|
|
152
|
+
});
|
|
153
|
+
it("renders the input line above separator and footer", () => {
|
|
154
|
+
const chat = new ChatView({ prompt: "> " });
|
|
155
|
+
chat.inputValue = "test";
|
|
156
|
+
const { buffer } = layoutAndRender(chat, 40, 10);
|
|
157
|
+
// Input at row 7, separator at row 8, footer at row 9
|
|
158
|
+
const inputRow = rowText(buffer, 7, 0, 6);
|
|
159
|
+
expect(inputRow).toBe("> test");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe("progress", () => {
|
|
163
|
+
it("shows progress message above input", () => {
|
|
164
|
+
const chat = new ChatView();
|
|
165
|
+
chat.setProgress("Loading...");
|
|
166
|
+
const { buffer } = layoutAndRender(chat, 40, 10);
|
|
167
|
+
// Find "Loading..." somewhere in the buffer
|
|
168
|
+
let found = false;
|
|
169
|
+
for (let y = 0; y < 10; y++) {
|
|
170
|
+
const row = rowText(buffer, y, 0, 10);
|
|
171
|
+
if (row.startsWith("Loading...")) {
|
|
172
|
+
found = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
expect(found).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
it("hides progress when set to null", () => {
|
|
179
|
+
const chat = new ChatView();
|
|
180
|
+
chat.setProgress("Loading...");
|
|
181
|
+
chat.setProgress(null);
|
|
182
|
+
// Should not crash during render
|
|
183
|
+
layoutAndRender(chat, 40, 10);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe("dropdown", () => {
|
|
187
|
+
it("starts with no dropdown", () => {
|
|
188
|
+
const chat = new ChatView();
|
|
189
|
+
expect(chat.dropdownItems).toHaveLength(0);
|
|
190
|
+
expect(chat.dropdownIndex).toBe(-1);
|
|
191
|
+
});
|
|
192
|
+
it("shows dropdown items", () => {
|
|
193
|
+
const chat = new ChatView();
|
|
194
|
+
chat.showDropdown([
|
|
195
|
+
{ label: "/help", description: "Show help", completion: "/help " },
|
|
196
|
+
{
|
|
197
|
+
label: "/status",
|
|
198
|
+
description: "Show status",
|
|
199
|
+
completion: "/status ",
|
|
200
|
+
},
|
|
201
|
+
]);
|
|
202
|
+
expect(chat.dropdownItems).toHaveLength(2);
|
|
203
|
+
expect(chat.dropdownIndex).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
it("navigates dropdown with up/down", () => {
|
|
206
|
+
const chat = new ChatView();
|
|
207
|
+
chat.showDropdown([
|
|
208
|
+
{ label: "/help", description: "desc", completion: "/help " },
|
|
209
|
+
{ label: "/status", description: "desc", completion: "/status " },
|
|
210
|
+
{ label: "/quit", description: "desc", completion: "/quit " },
|
|
211
|
+
]);
|
|
212
|
+
expect(chat.dropdownIndex).toBe(0);
|
|
213
|
+
chat.dropdownDown();
|
|
214
|
+
expect(chat.dropdownIndex).toBe(1);
|
|
215
|
+
chat.dropdownDown();
|
|
216
|
+
expect(chat.dropdownIndex).toBe(2);
|
|
217
|
+
chat.dropdownDown(); // at end, stays
|
|
218
|
+
expect(chat.dropdownIndex).toBe(2);
|
|
219
|
+
chat.dropdownUp();
|
|
220
|
+
expect(chat.dropdownIndex).toBe(1);
|
|
221
|
+
chat.dropdownUp();
|
|
222
|
+
expect(chat.dropdownIndex).toBe(0);
|
|
223
|
+
chat.dropdownUp(); // at top, stays
|
|
224
|
+
expect(chat.dropdownIndex).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
it("accepts dropdown item", () => {
|
|
227
|
+
const chat = new ChatView();
|
|
228
|
+
chat.showDropdown([
|
|
229
|
+
{ label: "/help", description: "desc", completion: "/help " },
|
|
230
|
+
]);
|
|
231
|
+
const item = chat.acceptDropdownItem();
|
|
232
|
+
expect(item).not.toBeNull();
|
|
233
|
+
expect(item.label).toBe("/help");
|
|
234
|
+
expect(chat.inputValue).toBe("/help ");
|
|
235
|
+
expect(chat.dropdownItems).toHaveLength(0);
|
|
236
|
+
});
|
|
237
|
+
it("hides dropdown on escape", () => {
|
|
238
|
+
const chat = new ChatView();
|
|
239
|
+
chat.showDropdown([
|
|
240
|
+
{ label: "/help", description: "desc", completion: "/help " },
|
|
241
|
+
]);
|
|
242
|
+
chat.handleInput(specialKey("escape"));
|
|
243
|
+
expect(chat.dropdownItems).toHaveLength(0);
|
|
244
|
+
});
|
|
245
|
+
it("accepts dropdown on enter when item selected", () => {
|
|
246
|
+
const chat = new ChatView();
|
|
247
|
+
chat.showDropdown([
|
|
248
|
+
{ label: "/help", description: "desc", completion: "/help " },
|
|
249
|
+
]);
|
|
250
|
+
chat.handleInput(specialKey("enter"));
|
|
251
|
+
expect(chat.inputValue).toBe("/help ");
|
|
252
|
+
});
|
|
253
|
+
it("navigates dropdown via keyboard events", () => {
|
|
254
|
+
const chat = new ChatView();
|
|
255
|
+
chat.showDropdown([
|
|
256
|
+
{ label: "/a", description: "", completion: "/a " },
|
|
257
|
+
{ label: "/b", description: "", completion: "/b " },
|
|
258
|
+
]);
|
|
259
|
+
chat.handleInput(specialKey("down"));
|
|
260
|
+
expect(chat.dropdownIndex).toBe(1);
|
|
261
|
+
chat.handleInput(specialKey("up"));
|
|
262
|
+
expect(chat.dropdownIndex).toBe(0);
|
|
263
|
+
});
|
|
264
|
+
it("renders dropdown items below input", () => {
|
|
265
|
+
const chat = new ChatView();
|
|
266
|
+
chat.showDropdown([
|
|
267
|
+
{ label: "/help", description: "Show help", completion: "/help " },
|
|
268
|
+
]);
|
|
269
|
+
const { buffer } = layoutAndRender(chat, 40, 12);
|
|
270
|
+
// Find dropdown text — check the last row (should be right after input)
|
|
271
|
+
let foundRow = -1;
|
|
272
|
+
for (let y = 0; y < 12; y++) {
|
|
273
|
+
const row = rowText(buffer, y, 0, 30);
|
|
274
|
+
if (row.includes("/help")) {
|
|
275
|
+
foundRow = y;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
expect(foundRow).toBeGreaterThanOrEqual(0);
|
|
280
|
+
});
|
|
281
|
+
it("renders dropdown items at correct position with banner", () => {
|
|
282
|
+
const chat = new ChatView({ banner: "Banner\nLine 2" });
|
|
283
|
+
chat.appendToFeed("msg1");
|
|
284
|
+
chat.showDropdown([
|
|
285
|
+
{ label: "/help", description: "Show help", completion: "/help " },
|
|
286
|
+
{ label: "/status", description: "Status", completion: "/status " },
|
|
287
|
+
]);
|
|
288
|
+
const H = 15;
|
|
289
|
+
const { buffer } = layoutAndRender(chat, 50, H);
|
|
290
|
+
// Dump all rows for debugging
|
|
291
|
+
const rows = [];
|
|
292
|
+
for (let y = 0; y < H; y++) {
|
|
293
|
+
rows.push(rowText(buffer, y, 0, 50).replace(/ +$/, ""));
|
|
294
|
+
}
|
|
295
|
+
// Input should be at H - 3 (2 dropdown rows below)
|
|
296
|
+
// Dropdown should be at H - 2 and H - 1
|
|
297
|
+
const helpRow = rows.findIndex((r) => r.includes("/help"));
|
|
298
|
+
const statusRow = rows.findIndex((r) => r.includes("/status"));
|
|
299
|
+
expect(helpRow).toBeGreaterThanOrEqual(0);
|
|
300
|
+
expect(statusRow).toBeGreaterThanOrEqual(0);
|
|
301
|
+
// Dropdown should be after input (last 2 rows)
|
|
302
|
+
expect(helpRow).toBeGreaterThan(rows.findIndex((r) => r.includes("❯") || r.includes("> ")));
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
describe("mouse scrolling", () => {
|
|
306
|
+
it("scrolls feed on wheel up", () => {
|
|
307
|
+
const chat = new ChatView();
|
|
308
|
+
for (let i = 0; i < 30; i++)
|
|
309
|
+
chat.appendToFeed(`line ${i}`);
|
|
310
|
+
const result = chat.handleInput(mouseEvent(0, 0, "none", "wheelup"));
|
|
311
|
+
expect(result).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
it("scrolls feed on wheel down", () => {
|
|
314
|
+
const chat = new ChatView();
|
|
315
|
+
for (let i = 0; i < 30; i++)
|
|
316
|
+
chat.appendToFeed(`line ${i}`);
|
|
317
|
+
const result = chat.handleInput(mouseEvent(0, 0, "none", "wheeldown"));
|
|
318
|
+
expect(result).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
describe("page scrolling", () => {
|
|
322
|
+
it("scrolls feed on pageup", () => {
|
|
323
|
+
const chat = new ChatView();
|
|
324
|
+
for (let i = 0; i < 30; i++)
|
|
325
|
+
chat.appendToFeed(`line ${i}`);
|
|
326
|
+
const result = chat.handleInput(specialKey("pageup"));
|
|
327
|
+
expect(result).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
it("scrolls feed on pagedown", () => {
|
|
330
|
+
const chat = new ChatView();
|
|
331
|
+
for (let i = 0; i < 30; i++)
|
|
332
|
+
chat.appendToFeed(`line ${i}`);
|
|
333
|
+
const result = chat.handleInput(specialKey("pagedown"));
|
|
334
|
+
expect(result).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
describe("resize / double buffer", () => {
|
|
338
|
+
it("renders cleanly at different sizes", () => {
|
|
339
|
+
const chat = new ChatView({ banner: "Banner" });
|
|
340
|
+
chat.appendToFeed("message 1");
|
|
341
|
+
chat.appendToFeed("message 2");
|
|
342
|
+
// Render at one size
|
|
343
|
+
layoutAndRender(chat, 60, 20);
|
|
344
|
+
// Re-render at a different size (simulates resize)
|
|
345
|
+
const { buffer } = layoutAndRender(chat, 40, 15);
|
|
346
|
+
// Should still render banner
|
|
347
|
+
const row0 = rowText(buffer, 0, 0, 6);
|
|
348
|
+
expect(row0).toBe("Banner");
|
|
349
|
+
});
|
|
350
|
+
it("renders at minimum viable size", () => {
|
|
351
|
+
const chat = new ChatView();
|
|
352
|
+
// Even at 10x3, should not crash
|
|
353
|
+
layoutAndRender(chat, 10, 3);
|
|
354
|
+
});
|
|
355
|
+
it("handles very small terminal gracefully", () => {
|
|
356
|
+
const chat = new ChatView();
|
|
357
|
+
const { ctx } = createRenderTarget(2, 2);
|
|
358
|
+
chat.measure({ minWidth: 0, minHeight: 0, maxWidth: 2, maxHeight: 2 });
|
|
359
|
+
chat.arrange({ x: 0, y: 0, width: 2, height: 2 });
|
|
360
|
+
// Should not throw
|
|
361
|
+
chat.render(ctx);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
describe("dropdown after external showDropdown + re-render", () => {
|
|
365
|
+
it("dropdown persists across multiple render passes", () => {
|
|
366
|
+
const chat = new ChatView({ banner: "B", prompt: "> " });
|
|
367
|
+
const W = 50, H = 15;
|
|
368
|
+
// First render — no dropdown
|
|
369
|
+
let { buffer, ctx } = layoutAndRender(chat, W, H);
|
|
370
|
+
// Simulate: user types, CLI shows dropdown, then forces refresh
|
|
371
|
+
chat.showDropdown([
|
|
372
|
+
{ label: "/help", description: "Show help", completion: "/help " },
|
|
373
|
+
{ label: "/quit", description: "Quit", completion: "/quit " },
|
|
374
|
+
]);
|
|
375
|
+
// Second render (simulates app.refresh())
|
|
376
|
+
({ buffer, ctx } = layoutAndRender(chat, W, H));
|
|
377
|
+
// Dropdown should be in the last 2 rows
|
|
378
|
+
const helpRow = rowText(buffer, H - 2, 0, 30);
|
|
379
|
+
const quitRow = rowText(buffer, H - 1, 0, 30);
|
|
380
|
+
expect(helpRow).toContain("/help");
|
|
381
|
+
expect(quitRow).toContain("/quit");
|
|
382
|
+
});
|
|
383
|
+
it("dropdown appears when set during change event handler", () => {
|
|
384
|
+
const chat = new ChatView({ banner: "B", prompt: "> " });
|
|
385
|
+
const W = 50, H = 15;
|
|
386
|
+
// Wire up a change handler that shows dropdown (like the CLI does)
|
|
387
|
+
chat.on("change", (text) => {
|
|
388
|
+
if (text.startsWith("/") && text.length >= 2) {
|
|
389
|
+
chat.showDropdown([
|
|
390
|
+
{ label: "/help", description: "Show help", completion: "/help " },
|
|
391
|
+
]);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
chat.hideDropdown();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
// Do initial render
|
|
398
|
+
let { buffer, ctx } = layoutAndRender(chat, W, H);
|
|
399
|
+
// Simulate typing "/" then "h" — exactly as App would
|
|
400
|
+
chat.handleInput(charKey("/"));
|
|
401
|
+
// After "/", no dropdown (length < 2)
|
|
402
|
+
({ buffer } = layoutAndRender(chat, W, H));
|
|
403
|
+
let hasDropdown = false;
|
|
404
|
+
for (let y = 0; y < H; y++) {
|
|
405
|
+
if (rowText(buffer, y, 0, 30).includes("/help")) {
|
|
406
|
+
hasDropdown = true;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
expect(hasDropdown).toBe(false);
|
|
411
|
+
// Type "h" — should trigger dropdown
|
|
412
|
+
chat.handleInput(charKey("h"));
|
|
413
|
+
// Re-render (simulates App's scheduled render after input)
|
|
414
|
+
({ buffer } = layoutAndRender(chat, W, H));
|
|
415
|
+
hasDropdown = false;
|
|
416
|
+
let dropdownRow = -1;
|
|
417
|
+
for (let y = 0; y < H; y++) {
|
|
418
|
+
const row = rowText(buffer, y, 0, 40);
|
|
419
|
+
if (row.includes("/help")) {
|
|
420
|
+
hasDropdown = true;
|
|
421
|
+
dropdownRow = y;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
expect(hasDropdown).toBe(true);
|
|
426
|
+
// Dropdown should be after the input line (near bottom)
|
|
427
|
+
expect(dropdownRow).toBeGreaterThanOrEqual(H - 3);
|
|
428
|
+
});
|
|
429
|
+
it("dropdown survives re-render after invalidation", () => {
|
|
430
|
+
const chat = new ChatView({ prompt: "> " });
|
|
431
|
+
const W = 40, H = 10;
|
|
432
|
+
// Show dropdown
|
|
433
|
+
chat.showDropdown([
|
|
434
|
+
{ label: "/test", description: "Test cmd", completion: "/test " },
|
|
435
|
+
]);
|
|
436
|
+
// Render once
|
|
437
|
+
layoutAndRender(chat, W, H);
|
|
438
|
+
// Simulate TextInput invalidation (what happens after insert)
|
|
439
|
+
chat.input.setValue("x");
|
|
440
|
+
// Re-render (like App._renderFrame after setImmediate)
|
|
441
|
+
const { buffer } = layoutAndRender(chat, W, H);
|
|
442
|
+
// Dropdown should still be there
|
|
443
|
+
let found = false;
|
|
444
|
+
for (let y = 0; y < H; y++) {
|
|
445
|
+
if (rowText(buffer, y, 0, 30).includes("/test")) {
|
|
446
|
+
found = true;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
expect(found).toBe(true);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
describe("full render cycle", () => {
|
|
454
|
+
it("renders banner + separator + feed + separator + input", () => {
|
|
455
|
+
const chat = new ChatView({
|
|
456
|
+
banner: "Test Chat",
|
|
457
|
+
prompt: "> ",
|
|
458
|
+
});
|
|
459
|
+
chat.appendToFeed("Hello world");
|
|
460
|
+
const { buffer } = layoutAndRender(chat, 40, 8);
|
|
461
|
+
// Row 0: "Test Chat"
|
|
462
|
+
expect(rowText(buffer, 0, 0, 9)).toBe("Test Chat");
|
|
463
|
+
// Row 1: separator (repeated char)
|
|
464
|
+
const sep = rowText(buffer, 1, 0, 3);
|
|
465
|
+
expect(sep).toBe("───");
|
|
466
|
+
// Feed should contain "Hello world" somewhere in rows 2-5
|
|
467
|
+
let feedFound = false;
|
|
468
|
+
for (let y = 2; y < 6; y++) {
|
|
469
|
+
if (rowText(buffer, y, 0, 11) === "Hello world") {
|
|
470
|
+
feedFound = true;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
expect(feedFound).toBe(true);
|
|
475
|
+
// Input at row 5, separator at row 6, footer at row 7
|
|
476
|
+
const inputRow = rowText(buffer, 5, 0, 2);
|
|
477
|
+
expect(inputRow).toBe("> ");
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
});
|