Line data Source code
1 : /**
2 : * @file tests/unit/test_tui_app.c
3 : * @brief Unit tests for the TUI state machine (US-11 v2).
4 : */
5 :
6 : #include "test_helpers.h"
7 : #include "tui/app.h"
8 :
9 : #include <string.h>
10 :
11 27 : static DialogEntry mk_entry(DialogPeerKind kind, int64_t id,
12 : const char *title) {
13 27 : DialogEntry e = {0};
14 27 : e.kind = kind;
15 27 : e.peer_id = id;
16 27 : if (title) {
17 27 : strncpy(e.title, title, sizeof(e.title) - 1);
18 : }
19 27 : return e;
20 : }
21 :
22 8 : static void seed_dialogs(TuiApp *app, int n) {
23 : DialogEntry src[5];
24 8 : if (n > 5) n = 5;
25 35 : for (int i = 0; i < n; i++) {
26 27 : const char *title = (const char *[]){"Alice","Bob","Carol","Dan","Eve"}[i];
27 27 : src[i] = mk_entry(DIALOG_PEER_USER, 1000 + i, title);
28 : }
29 8 : dialog_pane_set_entries(&app->dialogs, src, n);
30 8 : }
31 :
32 : /* --- Init / resize --- */
33 :
34 1 : static void test_init_succeeds_on_valid_size(void) {
35 : TuiApp app;
36 1 : ASSERT(tui_app_init(&app, 24, 80) == 0, "init ok");
37 1 : ASSERT(app.rows == 24 && app.cols == 80, "size recorded");
38 1 : ASSERT(app.focus == TUI_FOCUS_DIALOGS, "start focus dialogs");
39 1 : ASSERT(pane_is_valid(&app.layout.dialogs), "dialogs pane valid");
40 1 : ASSERT(pane_is_valid(&app.layout.history), "history pane valid");
41 1 : ASSERT(pane_is_valid(&app.layout.status), "status pane valid");
42 : /* Viewport heights should match pane rows. */
43 1 : ASSERT(app.dialogs.lv.rows_visible == app.layout.dialogs.rows,
44 : "dialog viewport matches pane");
45 1 : ASSERT(app.history.lv.rows_visible == app.layout.history.rows,
46 : "history viewport matches pane");
47 1 : tui_app_free(&app);
48 : }
49 :
50 1 : static void test_init_rejects_too_small(void) {
51 : TuiApp app;
52 1 : ASSERT(tui_app_init(&app, 0, 80) != 0, "rows=0 rejected");
53 1 : ASSERT(tui_app_init(&app, 24, 0) != 0, "cols=0 rejected");
54 : }
55 :
56 1 : static void test_resize_recomputes_layout(void) {
57 : TuiApp app;
58 1 : ASSERT(tui_app_init(&app, 24, 80) == 0, "init");
59 1 : seed_dialogs(&app, 3);
60 1 : list_view_end(&app.dialogs.lv); /* dirty scroll */
61 1 : ASSERT(tui_app_resize(&app, 40, 120) == 0, "resize ok");
62 1 : ASSERT(app.rows == 40 && app.cols == 120, "new size");
63 1 : ASSERT(app.layout.status.cols == 120, "status widened");
64 1 : ASSERT(app.dialogs.lv.rows_visible == app.layout.dialogs.rows,
65 : "viewport follows resize");
66 1 : tui_app_free(&app);
67 : }
68 :
69 : /* --- Key handling --- */
70 :
71 1 : static void test_ctrl_c_quits(void) {
72 1 : TuiApp app; tui_app_init(&app, 24, 80);
73 1 : TuiEvent ev = tui_app_handle_key(&app, TERM_KEY_QUIT);
74 1 : ASSERT(ev == TUI_EVENT_QUIT, "ctrl-c returns QUIT");
75 1 : tui_app_free(&app);
76 : }
77 :
78 1 : static void test_q_char_quits(void) {
79 1 : TuiApp app; tui_app_init(&app, 24, 80);
80 1 : TuiEvent ev = tui_app_handle_char(&app, 'q');
81 1 : ASSERT(ev == TUI_EVENT_QUIT, "'q' returns QUIT");
82 1 : ev = tui_app_handle_char(&app, 'Q');
83 1 : ASSERT(ev == TUI_EVENT_QUIT, "'Q' returns QUIT");
84 1 : tui_app_free(&app);
85 : }
86 :
87 1 : static void test_left_right_switch_focus(void) {
88 1 : TuiApp app; tui_app_init(&app, 24, 80);
89 1 : seed_dialogs(&app, 3);
90 : /* Start in dialogs; RIGHT should switch to history. */
91 1 : TuiEvent ev = tui_app_handle_key(&app, TERM_KEY_RIGHT);
92 1 : ASSERT(ev == TUI_EVENT_REDRAW, "right triggers redraw");
93 1 : ASSERT(app.focus == TUI_FOCUS_HISTORY, "focus moved to history");
94 1 : ASSERT(app.status.mode == STATUS_MODE_HISTORY, "status mode sync");
95 : /* RIGHT again is a no-op. */
96 1 : ev = tui_app_handle_key(&app, TERM_KEY_RIGHT);
97 1 : ASSERT(ev == TUI_EVENT_NONE, "right is no-op when already in history");
98 1 : ev = tui_app_handle_key(&app, TERM_KEY_LEFT);
99 1 : ASSERT(ev == TUI_EVENT_REDRAW, "left switches back");
100 1 : ASSERT(app.focus == TUI_FOCUS_DIALOGS, "focus back on dialogs");
101 1 : tui_app_free(&app);
102 : }
103 :
104 1 : static void test_vim_keys_switch_focus(void) {
105 1 : TuiApp app; tui_app_init(&app, 24, 80);
106 1 : TuiEvent ev = tui_app_handle_char(&app, 'l');
107 1 : ASSERT(ev == TUI_EVENT_REDRAW, "'l' switches to history");
108 1 : ASSERT(app.focus == TUI_FOCUS_HISTORY, "focus history");
109 1 : ev = tui_app_handle_char(&app, 'h');
110 1 : ASSERT(ev == TUI_EVENT_REDRAW, "'h' switches back");
111 1 : ASSERT(app.focus == TUI_FOCUS_DIALOGS, "focus dialogs");
112 1 : tui_app_free(&app);
113 : }
114 :
115 1 : static void test_arrow_keys_navigate_focused_pane(void) {
116 1 : TuiApp app; tui_app_init(&app, 24, 80);
117 1 : seed_dialogs(&app, 5);
118 1 : int initial = app.dialogs.lv.selected;
119 1 : tui_app_handle_key(&app, TERM_KEY_NEXT_LINE);
120 1 : ASSERT(app.dialogs.lv.selected == initial + 1, "down moved dialog cursor");
121 1 : tui_app_handle_key(&app, TERM_KEY_PREV_LINE);
122 1 : ASSERT(app.dialogs.lv.selected == initial, "up reversed");
123 1 : tui_app_handle_key(&app, TERM_KEY_END);
124 1 : ASSERT(app.dialogs.lv.selected == 4, "end to last");
125 1 : tui_app_handle_key(&app, TERM_KEY_HOME);
126 1 : ASSERT(app.dialogs.lv.selected == 0, "home to first");
127 1 : tui_app_free(&app);
128 : }
129 :
130 1 : static void test_jk_chars_navigate(void) {
131 1 : TuiApp app; tui_app_init(&app, 24, 80);
132 1 : seed_dialogs(&app, 3);
133 1 : tui_app_handle_char(&app, 'j');
134 1 : ASSERT(app.dialogs.lv.selected == 1, "'j' is down");
135 1 : tui_app_handle_char(&app, 'k');
136 1 : ASSERT(app.dialogs.lv.selected == 0, "'k' is up");
137 1 : tui_app_free(&app);
138 : }
139 :
140 1 : static void test_navigation_targets_focused_pane_only(void) {
141 1 : TuiApp app; tui_app_init(&app, 24, 80);
142 1 : seed_dialogs(&app, 5);
143 1 : tui_app_handle_key(&app, TERM_KEY_RIGHT); /* focus history */
144 1 : tui_app_handle_key(&app, TERM_KEY_NEXT_LINE);
145 1 : ASSERT(app.dialogs.lv.selected == 0, "dialogs cursor not moved");
146 1 : tui_app_free(&app);
147 : }
148 :
149 1 : static void test_enter_on_dialog_requests_load_and_shifts_focus(void) {
150 1 : TuiApp app; tui_app_init(&app, 24, 80);
151 1 : seed_dialogs(&app, 3);
152 1 : TuiEvent ev = tui_app_handle_key(&app, TERM_KEY_ENTER);
153 1 : ASSERT(ev == TUI_EVENT_OPEN_DIALOG, "enter requests open");
154 1 : ASSERT(app.focus == TUI_FOCUS_HISTORY, "focus jumped to history");
155 1 : ASSERT(app.status.mode == STATUS_MODE_HISTORY, "status follows");
156 1 : tui_app_free(&app);
157 : }
158 :
159 1 : static void test_enter_with_no_dialog_is_noop(void) {
160 1 : TuiApp app; tui_app_init(&app, 24, 80);
161 1 : TuiEvent ev = tui_app_handle_key(&app, TERM_KEY_ENTER);
162 1 : ASSERT(ev == TUI_EVENT_NONE, "enter on empty list is no-op");
163 1 : ASSERT(app.focus == TUI_FOCUS_DIALOGS, "focus unchanged");
164 1 : tui_app_free(&app);
165 : }
166 :
167 1 : static void test_esc_also_quits(void) {
168 1 : TuiApp app; tui_app_init(&app, 24, 80);
169 1 : TuiEvent ev = tui_app_handle_key(&app, TERM_KEY_ESC);
170 1 : ASSERT(ev == TUI_EVENT_QUIT, "esc quits");
171 1 : tui_app_free(&app);
172 : }
173 :
174 : /* --- Paint --- */
175 :
176 1 : static void test_paint_renders_all_three_panes(void) {
177 1 : TuiApp app; tui_app_init(&app, 10, 50);
178 1 : seed_dialogs(&app, 3);
179 1 : tui_app_paint(&app);
180 : /* Something should be drawn in each pane — check a known cell in each. */
181 1 : int dialog_row = app.layout.dialogs.row;
182 1 : ASSERT(app.screen.back[dialog_row * 50 + 0].cp == 'u',
183 : "dialog pane painted");
184 : /* History pane should show the "(select a dialog)" hint on peer_loaded=0. */
185 1 : int hist_mid = app.layout.history.row
186 1 : + app.layout.history.rows / 2;
187 1 : int hit = 0;
188 3 : for (int c = 0; c < app.layout.history.cols; c++) {
189 3 : if (app.screen.back[hist_mid * 50 + app.layout.history.col + c].cp
190 1 : == '(') { hit = 1; break; }
191 : }
192 1 : ASSERT(hit, "history pane shows hint");
193 : /* Status row is reverse-video full width. */
194 1 : int status_row = app.layout.status.row;
195 1 : int rev = 1;
196 51 : for (int c = 0; c < 50 && rev; c++) {
197 50 : if (!(app.screen.back[status_row * 50 + c].attrs & SCREEN_ATTR_REVERSE))
198 0 : rev = 0;
199 : }
200 1 : ASSERT(rev, "status row fully reversed");
201 1 : tui_app_free(&app);
202 : }
203 :
204 1 : static void test_paint_does_not_flip_stdout(void) {
205 : /* If paint were to call screen_flip/fwrite, this test would produce
206 : * visible noise during the suite run. Tests print their own names, but
207 : * stray ANSI would show up. Sanity check: back buffer has content,
208 : * front buffer is still blank (flip hasn't happened). */
209 1 : TuiApp app; tui_app_init(&app, 10, 50);
210 1 : seed_dialogs(&app, 2);
211 1 : tui_app_paint(&app);
212 1 : int any_back = 0, any_front_nonblank = 0;
213 501 : for (int i = 0; i < 10 * 50; i++) {
214 500 : if (app.screen.back[i].cp && app.screen.back[i].cp != ' ') any_back = 1;
215 500 : if (app.screen.front[i].cp && app.screen.front[i].cp != ' ')
216 0 : any_front_nonblank = 1;
217 : }
218 1 : ASSERT(any_back, "paint filled back");
219 1 : ASSERT(!any_front_nonblank, "paint did not touch front (no flip)");
220 1 : tui_app_free(&app);
221 : }
222 :
223 1 : void test_tui_app_run(void) {
224 1 : RUN_TEST(test_init_succeeds_on_valid_size);
225 1 : RUN_TEST(test_init_rejects_too_small);
226 1 : RUN_TEST(test_resize_recomputes_layout);
227 1 : RUN_TEST(test_ctrl_c_quits);
228 1 : RUN_TEST(test_q_char_quits);
229 1 : RUN_TEST(test_left_right_switch_focus);
230 1 : RUN_TEST(test_vim_keys_switch_focus);
231 1 : RUN_TEST(test_arrow_keys_navigate_focused_pane);
232 1 : RUN_TEST(test_jk_chars_navigate);
233 1 : RUN_TEST(test_navigation_targets_focused_pane_only);
234 1 : RUN_TEST(test_enter_on_dialog_requests_load_and_shifts_focus);
235 1 : RUN_TEST(test_enter_with_no_dialog_is_noop);
236 1 : RUN_TEST(test_esc_also_quits);
237 1 : RUN_TEST(test_paint_renders_all_three_panes);
238 1 : RUN_TEST(test_paint_does_not_flip_stdout);
239 1 : }
|