-
-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathform_test.cpp
More file actions
711 lines (627 loc) · 29.2 KB
/
Copy pathform_test.cpp
File metadata and controls
711 lines (627 loc) · 29.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
// Tests for the form component layer: the shared state model, the one key
// map, and the pure projection onto maya::Panel::Config.
//
// The properties worth pinning are the ones that were bugs in the five
// hand-rolled pickers this replaces:
//
// * exactly one mode owns the keyboard (a keystroke can never leak from a
// text field into global navigation),
// * Esc from a dropdown is a true CANCEL (the value is untouched until
// commit),
// * a Secret's plaintext never reaches the rendering layer at all — not
// "is masked", but structurally absent from what maya is handed.
#include <doctest/doctest.h>
#include <maya/print.hpp>
#include "agentty/runtime/panel/form.hpp"
#include "agentty/runtime/panel/form_keys.hpp"
#include "agentty/runtime/view/form_panel.hpp"
using namespace agentty::form;
namespace keys = agentty::form::keys;
namespace {
maya::KeyEvent key(maya::SpecialKey k) {
maya::KeyEvent ev;
ev.key = k;
return ev;
}
maya::KeyEvent chr(char32_t c, bool ctrl = false) {
maya::KeyEvent ev;
ev.key = maya::CharKey{c};
ev.mods.ctrl = ctrl;
return ev;
}
Form demo() {
return Builder{"Demo"}
.choice("backend", "Backend", {"Auto", "Ollama", "Custom"}, {"auto", "ollama", "openai"}, "auto")
.text("host", "Host", "127.0.0.1")
.number("port", "Port", 11434, 1, 65535)
.slider("weight", "Weight", 0.65, 0.0, 1.0, 0.05)
.secret("key", "API key", "sk-super-secret")
.action("test", "Test connection")
.build();
}
// Drive one key through the router + reducer, as the pane does.
keys::Applied press(Form& f, const maya::KeyEvent& ev) {
auto a = keys::translate(f, ev);
if (!a) return {};
return keys::apply(f, *a);
}
} // namespace
TEST_CASE("form: builder produces addressable rows") {
auto f = demo();
CHECK(f.fields.size() == 6);
REQUIRE(f.find("host") != nullptr);
CHECK(f.find("host")->label == "Host");
CHECK(f.find("nope") == nullptr);
// Choice selects by stored id, not display label.
CHECK(std::get<field::Choice>(f.find("backend")->value).id() == "auto");
}
TEST_CASE("form: exactly one mode owns the keyboard") {
auto f = demo();
CHECK(std::holds_alternative<focus::Browsing>(f.focus));
// Enter on a text row engages editing.
f.cursor = 1;
press(f, key(maya::SpecialKey::Enter));
CHECK(f.editing());
CHECK_FALSE(f.choosing());
// While editing, 'j' is a character — NOT a move. This is the leak the
// hand-rolled inputs kept springing.
const int before = f.cursor;
press(f, chr(U'j'));
CHECK(f.cursor == before);
CHECK(std::get<field::Text>(f.find("host")->value).value.find('j') != std::string::npos);
// Esc leaves the field but not the form.
auto r = press(f, key(maya::SpecialKey::Escape));
CHECK_FALSE(r.close);
CHECK_FALSE(f.editing());
// Esc again closes.
r = press(f, key(maya::SpecialKey::Escape));
CHECK(r.close);
}
TEST_CASE("form: dropdown opens, commits, and cancels non-destructively") {
auto f = demo();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
CHECK(f.choosing());
// Opening must not touch the value.
CHECK(std::get<field::Choice>(f.find("backend")->value).id() == "auto");
press(f, key(maya::SpecialKey::Down));
// Still untouched — the highlight moved, not the field.
CHECK(std::get<field::Choice>(f.find("backend")->value).id() == "auto");
// Esc is a TRUE cancel.
press(f, key(maya::SpecialKey::Escape));
CHECK_FALSE(f.choosing());
CHECK(std::get<field::Choice>(f.find("backend")->value).id() == "auto");
// Reopen, move, commit.
press(f, key(maya::SpecialKey::Enter));
press(f, key(maya::SpecialKey::Down));
auto r = press(f, key(maya::SpecialKey::Enter));
CHECK(r.changed);
CHECK_FALSE(f.choosing());
CHECK(std::get<field::Choice>(f.find("backend")->value).id() == "ollama");
CHECK(f.dirty);
}
TEST_CASE("form: an open dropdown swallows stray keys") {
auto f = demo();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
REQUIRE(f.choosing());
// A key the menu doesn't use must NOT fall through to the form beneath.
const int cursor_before = f.cursor;
press(f, key(maya::SpecialKey::Right));
CHECK(f.cursor == cursor_before);
CHECK(f.choosing());
}
TEST_CASE("form: a dropdown never filters — j/k always navigate") {
// The dropdown holds an ENUM. It deliberately has no query state, so j/k
// are unconditionally navigation and can never be swallowed as filter
// text. A candidate set big enough to need searching is a `Pick` row.
auto f = demo();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
REQUIRE(f.choosing());
press(f, key(maya::SpecialKey::Down));
CHECK(f.dropdown()->highlighted == 1);
press(f, key(maya::SpecialKey::Up));
CHECK(f.dropdown()->highlighted == 0);
// Even a long list navigates rather than filters — the option count does
// not change the interaction model.
std::vector<std::string> many;
for (int i = 0; i < 12; ++i) many.push_back("model-" + std::to_string(i));
auto g = Builder{"Long"}.choice("m", "Model", many).build();
press(g, key(maya::SpecialKey::Enter));
REQUIRE(g.choosing());
press(g, key(maya::SpecialKey::Down));
CHECK(g.dropdown()->highlighted == 1);
}
TEST_CASE("form: a Pick row hands off instead of opening a dropdown") {
// The escape hatch that keeps Choice honest: large/dynamic candidate sets
// open a real picker, and the form never learns about them.
auto f = Builder{"Hand-off"}
.pick("model", "Model", "claude-opus-4-5")
.build();
f.cursor = 0;
const auto r = press(f, key(maya::SpecialKey::Enter));
CHECK(r.hand_off);
CHECK_FALSE(f.choosing()); // no inline list
CHECK_FALSE(f.editing()); // and not an edit either
CHECK_FALSE(r.changed);
}
TEST_CASE("form: dropdown commit resolves through the same list the view shows") {
// The reducer and the view resolve options with the same function, so the
// highlighted row and the committed value cannot diverge.
std::vector<std::string> many;
for (int i = 0; i < 12; ++i) many.push_back("model-" + std::to_string(i));
auto f = Builder{"Long"}.choice("m", "Model", many).build();
press(f, key(maya::SpecialKey::Enter));
for (int i = 0; i < 3; ++i) press(f, key(maya::SpecialKey::Down));
const auto& c = std::get<field::Choice>(f.find("m")->value);
REQUIRE(visible_options(c).labels.size() == 12);
press(f, key(maya::SpecialKey::Enter));
CHECK(std::get<field::Choice>(f.find("m")->value).label() == "model-3");
}
TEST_CASE("form: a letter on a number row is a true no-op") {
// Reported as "typing an alpha char misbehaves everywhere".
//
// A Number row takes digits only, but type-to-edit entered the session
// ANYWAY: focus flipped to Editing, edit_dirty went true, and
// number_insert then dropped the character. The row was left live and
// "changed" with nothing on screen to show it -- so arrows started moving
// a caret instead of the row cursor, Esc committed an edit that never
// happened, and the pane read as having swallowed the key.
//
// A rejected character must leave EVERY axis untouched, which is what
// makes it a no-op rather than a half-entered session.
auto f = demo();
f.cursor = 2; // port, a Number row
const auto before = std::get<field::Number>(f.find("port")->value).value;
press(f, chr(U'x'));
CHECK_FALSE(f.editing()); // no session started
CHECK_FALSE(f.dirty); // nothing marked changed
CHECK(std::get<field::Number>(f.find("port")->value).value == before);
// And a digit still works right after, so the guard did not wedge the row.
press(f, chr(U'7'));
CHECK(f.editing());
CHECK(std::get<field::Number>(f.find("port")->value).value == 7);
// Mid-session too: a letter among digits is ignored rather than ending or
// corrupting the edit.
press(f, chr(U'z'));
CHECK(std::get<field::Number>(f.find("port")->value).value == 7);
press(f, chr(U'9'));
CHECK(std::get<field::Number>(f.find("port")->value).value == 79);
}
TEST_CASE("form: the first digit typed REPLACES, it does not append") {
// Reported as "input doesn't work on all fields sometimes", and this is
// the mechanism.
//
// Appending made a Number row behave like no other settings field: the
// value on screen when you start typing is the SAVED one, so extending it
// is never what the keystroke meant. Typing 8 into a row showing 4096 gave
// 40968.
//
// On a SMALL range it was worse than surprising, it was dead input: a row
// with max 8 showing 3, type 5 -> 3*10+5 = 35 -> clamps to 8. The row is
// now at max, so every later digit also clamps to 8 and NOTHING the user
// types moves it. No caret, no error, no way to tell why.
auto f = demo();
f.cursor = 2; // port, 11434, range 1-65535
// Type-to-edit: the first printable starts the session.
press(f, chr(U'8'));
CHECK(std::get<field::Number>(f.find("port")->value).value == 8);
// And the SECOND digit still appends -- 4-0-9-6 must type 4096, so this is
// a fresh-vs-continuing distinction rather than "replace always".
press(f, chr(U'0'));
CHECK(std::get<field::Number>(f.find("port")->value).value == 80);
press(f, chr(U'8'));
CHECK(std::get<field::Number>(f.find("port")->value).value == 808);
}
TEST_CASE("form: Enter-then-type replaces too") {
// The other way into an edit session must behave identically, or the
// field's response depends on how you opened it -- which is its own kind
// of "sometimes input doesn't work".
auto f = demo();
f.cursor = 2;
press(f, key(maya::SpecialKey::Enter));
press(f, chr(U'7'));
CHECK(std::get<field::Number>(f.find("port")->value).value == 7);
}
TEST_CASE("form: a small-range number row never goes deaf to typing") {
// The exact shape of the reported bug, on the row that showed it: the
// sandbox pane's "Secret scan depth" is 0-8 and ships at 3.
//
// Every value in range must be reachable by typing it. Under the old
// append rule the row latched at max after one keystroke and stayed there.
auto f = Builder{"Depth"}.number("depth", "Secret scan depth", 3, 0, 8).build();
f.cursor = 0;
press(f, chr(U'5'));
CHECK(std::get<field::Number>(f.find("depth")->value).value == 5);
// Leave and come back, then type a different digit. The row must answer
// again rather than being stuck at whatever it latched to.
press(f, key(maya::SpecialKey::Escape));
press(f, chr(U'2'));
CHECK(std::get<field::Number>(f.find("depth")->value).value == 2);
// Including 0, which append could never produce from a non-zero start.
press(f, key(maya::SpecialKey::Escape));
press(f, chr(U'0'));
CHECK(std::get<field::Number>(f.find("depth")->value).value == 0);
}
TEST_CASE("form: number and slider clamp on every mutation") {
auto f = demo();
f.cursor = 2; // port
press(f, key(maya::SpecialKey::Enter));
for (char32_t c : {U'9', U'9', U'9', U'9', U'9', U'9'}) press(f, chr(c));
CHECK(std::get<field::Number>(f.find("port")->value).value == 65535);
press(f, key(maya::SpecialKey::Escape));
f.cursor = 3; // weight slider
for (int i = 0; i < 20; ++i) press(f, key(maya::SpecialKey::Right));
CHECK(std::get<field::Slider>(f.find("weight")->value).value == doctest::Approx(1.0));
for (int i = 0; i < 40; ++i) press(f, key(maya::SpecialKey::Left));
CHECK(std::get<field::Slider>(f.find("weight")->value).value == doctest::Approx(0.0));
}
TEST_CASE("form: slider steps stay on the grid") {
// Repeated ±step must not accumulate float drift into 0.6500000000000001.
auto f = demo();
f.cursor = 3;
for (int i = 0; i < 5; ++i) press(f, key(maya::SpecialKey::Right));
for (int i = 0; i < 5; ++i) press(f, key(maya::SpecialKey::Left));
CHECK(std::get<field::Slider>(f.find("weight")->value).value == doctest::Approx(0.65));
}
TEST_CASE("form: locked rows refuse edits") {
auto f = Builder{"Locked"}
.text("host", "Host", "127.0.0.1")
.lock("env: AGENTTY_EMBED_ENDPOINT")
.build();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
CHECK_FALSE(f.editing()); // never engages
press(f, key(maya::SpecialKey::Right));
CHECK(std::get<field::Text>(f.find("host")->value).value == "127.0.0.1");
CHECK_FALSE(f.dirty);
}
TEST_CASE("form: an action row reports rather than mutating") {
auto f = demo();
f.cursor = 5; // "Test connection"
auto r = press(f, key(maya::SpecialKey::Enter));
CHECK(r.fired);
CHECK_FALSE(r.changed);
CHECK_FALSE(f.editing());
}
TEST_CASE("form: no save chord — Esc commits; ^C stays ambient") {
auto f = demo();
// ^S is NOT a form key: leaving a field commits it, and Esc leaves.
// A second key for the same act is a second thing to keep in sync.
CHECK_FALSE(keys::translate(f, chr(U's', /*ctrl=*/true)).has_value());
// ^C must NOT be claimed — the app quits from anywhere.
CHECK_FALSE(keys::translate(f, chr(U'c', true)).has_value());
}
// ── The projection ───────────────────────────────────────────────────────
TEST_CASE("form view: a Secret's plaintext never reaches the widget") {
// Not "is masked" — structurally absent. maya::panel::Secret has no field
// that could hold it, so no present or future render path can leak it.
auto f = demo();
const auto cfg = agentty::ui::form_config(f, maya::Color::blue());
bool saw_secret = false;
for (const auto& row : cfg.items) {
if (const auto* s = std::get_if<maya::panel::Secret>(&row.control)) {
saw_secret = true;
CHECK(s->filled == 15); // "sk-super-secret"
}
// No control anywhere carries the plaintext.
if (const auto* t = std::get_if<maya::panel::Text>(&row.control))
CHECK(t->value.find("sk-") == std::string::npos);
}
CHECK(saw_secret);
}
TEST_CASE("form view: menu is present only while choosing") {
auto f = demo();
f.cursor = 0;
CHECK_FALSE(agentty::ui::form_config(f, maya::Color::blue()).menu.has_value());
press(f, key(maya::SpecialKey::Enter));
const auto cfg = agentty::ui::form_config(f, maya::Color::blue());
REQUIRE(cfg.menu.has_value());
CHECK(cfg.menu_row == 0);
CHECK(cfg.menu->options.size() == 3);
CHECK(cfg.menu->options[0] == "Auto");
}
TEST_CASE("form view: the widget renders every control kind") {
// A smoke test that the maya widget builds without throwing for each
// control, including with an open menu (the z-stacked float path).
auto f = demo();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
auto cfg = agentty::ui::form_config(f, maya::Color::blue());
cfg.items[1].origin = "settings.json";
cfg.items[2].error = "port must be 1-65535";
maya::Element el = maya::Panel{std::move(cfg)}.build();
CHECK(true); // built without throwing
}
TEST_CASE("form view: one setting is one row") {
// Regression: every row with `help` painted a second, dim sub-line, so a
// pane's height was 2x its setting count. The RAG pane has 22 settings
// across 5 group headers -- over 50 rows in a 14-row viewport, which
// turned a settings list into a wall of prose you had to scroll to reach
// a single toggle.
//
// Help belongs to the row the cursor is ON: that is the only row it can
// be describing, and it costs one line instead of N.
auto f = Builder{"Helpy"}
.toggle("a", "Alpha", true, "the first thing")
.toggle("b", "Beta", false, "the second thing")
.toggle("c", "Gamma", true, "the third thing")
.build();
const auto height = [&](const Form& form) {
auto cfg = agentty::ui::form_config(form, maya::Color::blue());
cfg.viewport_h = 100; // no clamping: measure the content
const auto out = maya::render_to_string(
maya::Panel{std::move(cfg)}.build(), 80);
int n = 0;
for (char ch : out) if (ch == '\n') ++n;
return n;
};
f.cursor = 0;
const int h0 = height(f);
f.cursor = 1;
const int h1 = height(f);
// Moving the cursor must not change the pane's height: exactly one help
// line exists either way. Before the fix all three were always painted.
CHECK(h0 == h1);
// And the help shown is the one under the cursor, not all of them.
const auto text_at = [&](int cursor) {
f.cursor = cursor;
auto cfg = agentty::ui::form_config(f, maya::Color::blue());
cfg.viewport_h = 100;
return maya::render_to_string(maya::Panel{std::move(cfg)}.build(), 80);
};
const std::string on_first = text_at(0);
const std::string on_second = text_at(1);
CHECK(on_first.find("the first thing") != std::string::npos);
CHECK(on_first.find("the second thing") == std::string::npos);
CHECK(on_second.find("the second thing") != std::string::npos);
CHECK(on_second.find("the first thing") == std::string::npos);
}
TEST_CASE("form view: headers cost one row, not two") {
// Regression: every section header emitted a blank spacer line above
// itself. At one per group that was 5 wasted rows of a 14-row viewport in
// the Retrieval pane -- in a list you scroll, a row that says nothing is
// a row that hides one that does. A header is already distinct (bold,
// dim, no value column), so the blank bought nothing.
//
// Headers render UPPER-CASE with a trailing rule, so they cannot be
// mistaken for a locked row (which is what "Endpoint auto-detected"
// looked like when both were merely dim).
auto f = Builder{"Grouped"}
.header("Sources")
.toggle("a", "Alpha", true)
.header("Pipeline")
.toggle("b", "Beta", false)
.build();
auto cfg = agentty::ui::form_config(f, maya::Color::blue());
cfg.viewport_h = 100;
const auto out = maya::render_to_string(
maya::Panel{std::move(cfg)}.build(), 80);
// No blank line directly above either header.
const auto no_blank_before = [&](const std::string& label) {
const auto at = out.find(label);
REQUIRE(at != std::string::npos);
const auto line_start = out.rfind('\n', at);
REQUIRE(line_start != std::string::npos);
const auto prev_start = out.rfind('\n', line_start - 1);
const std::string prev = out.substr(
prev_start + 1, line_start - prev_start - 1);
// The previous line must carry content, not just frame + spaces.
return prev.find_first_not_of(" \u2502") != std::string::npos;
};
CHECK(no_blank_before("PIPELINE"));
// And the header is visually a header: caps plus a rule to the edge.
CHECK(out.find("SOURCES") != std::string::npos);
CHECK(out.find("\xe2\x94\x80\xe2\x94\x80") != std::string::npos);
}
TEST_CASE("form view: the current row is tinted, the others are not") {
// With help gone from unfocused rows, a single accent cell at the far
// left was the only focus signal -- easy to lose when your eye is on the
// value column at the far right. The current row now carries a subtle
// wash across its whole span.
//
// The wash must be a real colour, not ANSI `black`: black IS the
// terminal background on most dark themes, so the first attempt at this
// was invisible by construction.
//
// It is a theme SLOT now (panel::Theme::row_bg = Surface), so pin a
// known theme rather than a literal. Under `native` there would be no
// wash to assert at all, and correctly so: native states no surface
// colour, because inventing one punches a hole in someone's terminal.
static const maya::Theme kProbe = [] {
maya::Theme t = maya::theme::native;
t.surface = maya::Color::rgb(35, 38, 52);
return t;
}();
maya::theme::set_live(kProbe);
auto f = Builder{"Tinted"}
.toggle("a", "Alpha", true)
.toggle("b", "Beta", false)
.build();
f.cursor = 0;
auto cfg = agentty::ui::form_config(f, maya::Color::blue());
cfg.viewport_h = 100;
const auto ansi = maya::render_to_string_ansi(
maya::Panel{std::move(cfg)}.build(), 80);
const auto line_with = [&](const std::string& label) {
const auto at = ansi.find(label);
REQUIRE(at != std::string::npos);
const auto from = ansi.rfind('\n', at);
const auto to = ansi.find('\n', at);
return ansi.substr(from + 1, to - from - 1);
};
const std::string wash = "48;2;35;38;52";
CHECK(line_with("Alpha").find(wash) != std::string::npos);
CHECK(line_with("Beta").find(wash) == std::string::npos);
maya::theme::set_live(maya::theme::native);
}
TEST_CASE("form view: the panel is FRAMED like every other picker") {
// Regression: Form::build() originally returned a bare vstack with no
// border, padding or min-width. A frameless overlay paints transparently
// over the thread, which on screen reads as "the key did nothing" / "the
// picker closed" — the pane was open and invisible. A form is a
// picker-family overlay and must sit in the same frame.
auto f = demo();
const auto out = maya::render_to_string(
maya::Panel{agentty::ui::form_config(f, maya::Color::blue())}.build(), 80);
// Rounded border corners, exactly as Picker draws them.
CHECK(out.find("\xe2\x95\xad") != std::string::npos); // ╭
CHECK(out.find("\xe2\x95\xb0") != std::string::npos); // ╰
// The title rides the top border.
CHECK(out.find("Demo") != std::string::npos);
// And the rows are actually painted.
CHECK(out.find("Backend") != std::string::npos);
CHECK(out.find("Host") != std::string::npos);
}
TEST_CASE("form view: an open dropdown paints its options over the panel") {
// The float must actually reach the screen — a menu that is state-only
// but never painted is the same invisible-overlay failure one level down.
auto f = demo();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
REQUIRE(f.choosing());
const auto out = maya::render_to_string(
maya::Panel{agentty::ui::form_config(f, maya::Color::blue())}.build(), 80);
// Every backend option is on screen, including ones that are not the
// current selection (i.e. this is the list, not the closed control).
CHECK(out.find("Ollama") != std::string::npos);
CHECK(out.find("Custom") != std::string::npos);
}
TEST_CASE("form view: the option list is INLINE, inside the panel") {
// The list was once a z-stacked float, which had to invent an opaque
// background, a width, a flip and a clamp — and every one of those was a
// bug (bleed-through, overflow, a theme-coloured slab). It now expands in
// the panel's own flow, so the only thing to assert is that each option
// line stays INSIDE the panel border.
auto f = demo();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
REQUIRE(f.choosing());
const auto out = maya::render_to_string(
maya::Panel{agentty::ui::form_config(f, maya::Color::blue())}.build(), 80);
std::size_t start = 0;
int checked = 0;
while (start <= out.size()) {
const std::size_t nl = out.find('\n', start);
const std::string line = out.substr(start, nl - start);
// These strings only ever appear as option rows (the closed control
// shows "Custom endpoint", and while open it shows only the chevron).
if (line.find("Ollama") != std::string::npos
|| line.find("llama.cpp server") != std::string::npos) {
// Exactly two vertical bars: the panel's left and right border.
// A third would mean a box crept back in; fewer would mean the
// line escaped the frame.
int bars = 0;
for (std::size_t i = 0;
(i = line.find("\xe2\x94\x82", i)) != std::string::npos; i += 3)
++bars;
CHECK(bars == 2);
++checked;
}
if (nl == std::string::npos) break;
start = nl + 1;
}
CHECK(checked > 0); // we actually inspected a row
}
TEST_CASE("form view: a Secret is never painted in the clear") {
// The end-to-end version of the type-level guarantee: render the whole
// widget and confirm the plaintext appears nowhere in the output.
auto f = demo();
const auto out = maya::render_to_string(
maya::Panel{agentty::ui::form_config(f, maya::Color::blue())}.build(), 80);
CHECK(out.find("sk-super-secret") == std::string::npos);
CHECK(out.find("sk-") == std::string::npos);
CHECK(out.find("*") != std::string::npos); // masked instead
}
TEST_CASE("form: an open pane ALWAYS answers Esc") {
// Regression (the "input stops getting through" freeze): the Retrieval
// overlay was created holding no form, filled in by a deferred Cmd. For at
// least one frame the pane owned the keyboard and could act on nothing —
// every key, INCLUDING the Esc that would have escaped it, was swallowed.
//
// The structural fix is that the form is built at construction and is not
// optional, so "open but inert" is unrepresentable. This pins the
// behavioural half: from every focus mode, Esc must eventually close.
auto f = demo();
// Browsing → closes immediately.
CHECK(press(f, key(maya::SpecialKey::Escape)).close);
// Editing → first Esc leaves the field, second closes.
f = demo();
f.cursor = 1;
press(f, key(maya::SpecialKey::Enter));
REQUIRE(f.editing());
CHECK_FALSE(press(f, key(maya::SpecialKey::Escape)).close);
CHECK(press(f, key(maya::SpecialKey::Escape)).close);
// Choosing → first Esc cancels the list, second closes.
f = demo();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
REQUIRE(f.choosing());
CHECK_FALSE(press(f, key(maya::SpecialKey::Escape)).close);
CHECK(press(f, key(maya::SpecialKey::Escape)).close);
}
TEST_CASE("form: an empty form still answers Esc") {
// The degenerate case the freeze actually hit: a form with no rows must
// not become a keyboard black hole.
Form empty;
auto a = keys::translate(empty, key(maya::SpecialKey::Escape));
REQUIRE(a.has_value());
CHECK(keys::apply(empty, *a).close);
}
TEST_CASE("form: the key router depends only on focus, not on the rows") {
// Regression (input lag, twice). `subscribe()` rebuilds its key handler
// EVERY FRAME. A router that needs the Form makes each frame deep-copy
// every row's strings on the input path — which is what made the settings
// pane feel laggy the moment it grew past a handful of rows.
//
// The flags overload is the contract: TWO bools decide the whole key
// map (editing, choosing — focus-shaped facts, not rows). If someone
// reintroduces a Form-shaped dependency, this stops compiling rather
// than quietly costing a copy per keystroke (or, worse, going stale
// mid-batch and dropping keys).
const auto esc = key(maya::SpecialKey::Escape);
const auto j = chr(U'j');
// Browsing: a printable is always TypeToEdit — the ROUTER stays
// cursor-independent (its founding contract: two bools, no rows), and
// apply() drops the intent when the focused row isn't text-like. A
// router that peeked at the row went stale mid-batch and silently ate
// keystrokes (↓ then a letter in one read).
{
auto a = keys::translate(/*editing=*/false, /*choosing=*/false, j);
REQUIRE(a.has_value());
CHECK(a->intent == keys::Intent::TypeToEdit);
CHECK(a->ch == U'j');
}
// Editing: the same key is text, never navigation.
{
auto a = keys::translate(/*editing=*/true, /*choosing=*/false, j);
REQUIRE(a.has_value());
CHECK(a->intent == keys::Intent::Insert);
CHECK(a->ch == U'j');
}
// Choosing: an open dropdown swallows printables (an enum list has no
// filter; leaking the key to the form under it would be a mis-target).
{
auto a = keys::translate(/*editing=*/false, /*choosing=*/true, j);
REQUIRE(a.has_value());
CHECK(a->intent == keys::Intent::None);
}
// And every mode answers Esc — the escape guarantee, at the router level.
for (auto [e, c] : {std::pair{false, false}, {true, false}, {false, true}}) {
auto a = keys::translate(e, c, esc);
REQUIRE(a.has_value());
CHECK(a->intent != keys::Intent::None);
}
}
TEST_CASE("form: caret is reported in characters, not bytes") {
// maya renders characters; the model stores bytes. A multi-byte value
// would draw its caret in the wrong column if this were confused.
auto f = Builder{"UTF"}.text("t", "T", "").build();
f.cursor = 0;
press(f, key(maya::SpecialKey::Enter));
press(f, chr(U'é'));
press(f, chr(U'漢'));
CHECK(std::get<field::Text>(f.find("t")->value).value.size() == 5); // bytes
CHECK(caret_chars(f.find("t")->value, true) == 2); // chars
}