Line data Source code
1 : /**
2 : * @file test_tui_e2e.c
3 : * @brief TEST-11 — TUI end-to-end functional tests.
4 : *
5 : * Drives the TUI view-model layer (dialog_pane, history_pane, tui_app_paint)
6 : * against the in-process mock Telegram server. This sits one level above the
7 : * existing unit tests (test_tui_app.c etc.) which exercise the state machine
8 : * in total isolation: here the domain calls actually fire MTProto RPCs against
9 : * the mock, so the full path
10 : *
11 : * dialog_pane_refresh → domain_get_dialogs → MTProto → mock responder
12 : * history_pane_load → domain_get_history → MTProto → mock responder
13 : * tui_app_paint → back-buffer contains dialog title + message text
14 : *
15 : * is exercised with real MTProto framing and TL parsing.
16 : *
17 : * No PTY is required: the Screen writes to a FILE* that is swapped to
18 : * /dev/null (we only inspect the back buffer, not the ANSI byte stream).
19 : */
20 :
21 : #include "test_helpers.h"
22 :
23 : #include "mock_socket.h"
24 : #include "mock_tel_server.h"
25 :
26 : #include "api_call.h"
27 : #include "mtproto_session.h"
28 : #include "transport.h"
29 : #include "app/session_store.h"
30 : #include "tl_registry.h"
31 : #include "tl_serial.h"
32 :
33 : #include "domain/read/dialogs.h"
34 : #include "domain/read/history.h"
35 :
36 : #include "tui/app.h"
37 : #include "tui/dialog_pane.h"
38 : #include "tui/history_pane.h"
39 :
40 : #include <stdio.h>
41 : #include <stdlib.h>
42 : #include <string.h>
43 : #include <unistd.h>
44 :
45 : /* ---- CRCs not surfaced in tl_registry.h ---- */
46 : #define CRC_dialog 0xd58a08c6U
47 : #define CRC_peerNotifySettings 0xa83b0426U
48 : #define CRC_messages_getDialogs 0xa0f4cb4fU
49 : #define CRC_messages_getHistory 0x4423e6c5U
50 :
51 : /* ---- Helpers (same pattern as test_read_path.c) ---- */
52 :
53 6 : static void with_tmp_home_tui(const char *tag) {
54 : char tmp[256];
55 6 : snprintf(tmp, sizeof(tmp), "/tmp/tg-cli-ft-tui-%s", tag);
56 : char bin[512];
57 6 : snprintf(bin, sizeof(bin), "%s/.config/tg-cli/session.bin", tmp);
58 6 : (void)unlink(bin);
59 6 : setenv("HOME", tmp, 1);
60 6 : }
61 :
62 6 : static void connect_mock_tui(Transport *t) {
63 6 : transport_init(t);
64 6 : ASSERT(transport_connect(t, "127.0.0.1", 443) == 0, "connect");
65 : }
66 :
67 6 : static void init_cfg_tui(ApiConfig *cfg) {
68 6 : api_config_init(cfg);
69 6 : cfg->api_id = 12345;
70 6 : cfg->api_hash = "deadbeefcafebabef00dbaadfeedc0de";
71 6 : }
72 :
73 6 : static void load_session_tui(MtProtoSession *s) {
74 6 : ASSERT(mt_server_seed_session(2, NULL, NULL, NULL) == 0, "seed");
75 6 : mtproto_session_init(s);
76 6 : int dc = 0;
77 6 : ASSERT(session_store_load(s, &dc) == 0, "load session");
78 : }
79 :
80 : /* ---- Mock responders ---- */
81 :
82 : /**
83 : * messages.dialogs with one user-peer dialog (id=555, unread=2, title="Alice").
84 : *
85 : * The users vector carries one user with:
86 : * flags.0 → access_hash present
87 : * flags.1 → first_name present (used as title for user dialogs)
88 : * so the domain layer will join title = "Alice".
89 : */
90 6 : static void on_dialogs_alice(MtRpcContext *ctx) {
91 : TlWriter w;
92 6 : tl_writer_init(&w);
93 6 : tl_write_uint32(&w, TL_messages_dialogs);
94 :
95 : /* dialogs: Vector<Dialog> with 1 entry */
96 6 : tl_write_uint32(&w, TL_vector);
97 6 : tl_write_uint32(&w, 1);
98 6 : tl_write_uint32(&w, CRC_dialog);
99 6 : tl_write_uint32(&w, 0); /* flags = 0, no optional fields */
100 6 : tl_write_uint32(&w, TL_peerUser);
101 6 : tl_write_int64 (&w, 555LL);
102 6 : tl_write_int32 (&w, 1200); /* top_message */
103 6 : tl_write_int32 (&w, 0); /* read_inbox_max_id */
104 6 : tl_write_int32 (&w, 0); /* read_outbox_max_id */
105 6 : tl_write_int32 (&w, 2); /* unread_count */
106 6 : tl_write_int32 (&w, 0); /* unread_mentions_count */
107 6 : tl_write_int32 (&w, 0); /* unread_reactions_count */
108 6 : tl_write_uint32(&w, CRC_peerNotifySettings);
109 6 : tl_write_uint32(&w, 0);
110 :
111 : /* messages vector: empty */
112 6 : tl_write_uint32(&w, TL_vector);
113 6 : tl_write_uint32(&w, 0);
114 :
115 : /* chats vector: empty */
116 6 : tl_write_uint32(&w, TL_vector);
117 6 : tl_write_uint32(&w, 0);
118 :
119 : /* users vector: one user — flags.0 (access_hash) | flags.1 (first_name) */
120 6 : tl_write_uint32(&w, TL_vector);
121 6 : tl_write_uint32(&w, 1);
122 6 : tl_write_uint32(&w, TL_user);
123 6 : tl_write_uint32(&w, (1u << 0) | (1u << 1)); /* flags */
124 6 : tl_write_uint32(&w, 0); /* flags2 */
125 6 : tl_write_int64 (&w, 555LL);
126 6 : tl_write_int64 (&w, 0xAABBCCDDEEFF0011LL); /* access_hash (flags.0) */
127 6 : tl_write_string(&w, "Alice"); /* first_name (flags.1) */
128 :
129 6 : mt_server_reply_result(ctx, w.data, w.len);
130 6 : tl_writer_free(&w);
131 6 : }
132 :
133 : /**
134 : * messages.messages with one plain-text inbound message:
135 : * id=42, text="Hello TUI world", date=1700000000
136 : */
137 4 : static void on_history_one_text(MtRpcContext *ctx) {
138 : TlWriter w;
139 4 : tl_writer_init(&w);
140 4 : tl_write_uint32(&w, TL_messages_messages);
141 :
142 : /* messages vector: 1 entry */
143 4 : tl_write_uint32(&w, TL_vector);
144 4 : tl_write_uint32(&w, 1);
145 4 : tl_write_uint32(&w, TL_message);
146 4 : tl_write_uint32(&w, 0); /* flags = 0 (no from_id etc.) */
147 4 : tl_write_uint32(&w, 0); /* flags2 = 0 */
148 4 : tl_write_int32 (&w, 42); /* id */
149 : /* peer_id: peerUser id=555 (flags.28 off → no saved_peer) */
150 4 : tl_write_uint32(&w, TL_peerUser);
151 4 : tl_write_int64 (&w, 555LL);
152 4 : tl_write_int32 (&w, 1700000000); /* date */
153 4 : tl_write_string(&w, "Hello TUI world"); /* message */
154 :
155 : /* chats vector: empty */
156 4 : tl_write_uint32(&w, TL_vector);
157 4 : tl_write_uint32(&w, 0);
158 : /* users vector: empty */
159 4 : tl_write_uint32(&w, TL_vector);
160 4 : tl_write_uint32(&w, 0);
161 :
162 4 : mt_server_reply_result(ctx, w.data, w.len);
163 4 : tl_writer_free(&w);
164 4 : }
165 :
166 : /* ---- Utility: scan the back buffer for a substring ---- */
167 :
168 : /**
169 : * Return 1 if the string @p needle appears as consecutive codepoints in any
170 : * row of the Screen back buffer, 0 otherwise.
171 : */
172 8 : static int screen_back_contains(const Screen *sc, const char *needle) {
173 8 : int nlen = (int)strlen(needle);
174 8 : if (nlen == 0) return 1;
175 8 : int total = sc->rows * sc->cols;
176 172 : for (int start = 0; start <= total - nlen; start++) {
177 172 : int match = 1;
178 416 : for (int k = 0; k < nlen && match; k++) {
179 244 : if (sc->back[start + k].cp != (uint32_t)(unsigned char)needle[k])
180 164 : match = 0;
181 : }
182 172 : if (match) return 1;
183 : }
184 0 : return 0;
185 : }
186 :
187 : /* ================================================================ */
188 : /* Tests */
189 : /* ================================================================ */
190 :
191 : /**
192 : * TEST-11a: dialog_pane_refresh fires messages.getDialogs, parses the
193 : * response and stores the dialog entry. The back buffer rendered by
194 : * tui_app_paint contains the dialog title "Alice".
195 : */
196 2 : static void test_tui_dialog_refresh_paints_title(void) {
197 2 : with_tmp_home_tui("dlg-paint");
198 2 : mt_server_init(); mt_server_reset();
199 2 : dialogs_cache_flush();
200 2 : MtProtoSession s; load_session_tui(&s);
201 2 : mt_server_expect(CRC_messages_getDialogs, on_dialogs_alice, NULL);
202 :
203 2 : ApiConfig cfg; init_cfg_tui(&cfg);
204 2 : Transport t; connect_mock_tui(&t);
205 :
206 : TuiApp app;
207 2 : ASSERT(tui_app_init(&app, 24, 80) == 0, "app init");
208 2 : app.screen.out = fopen("/dev/null", "w"); /* silence ANSI bytes */
209 :
210 2 : ASSERT(dialog_pane_refresh(&app.dialogs, &cfg, &s, &t) == 0,
211 : "dialog_pane_refresh ok");
212 2 : app.dialogs.lv.rows_visible = app.layout.dialogs.rows;
213 :
214 2 : ASSERT(app.dialogs.count == 1, "one dialog loaded");
215 2 : ASSERT(strcmp(app.dialogs.entries[0].title, "Alice") == 0,
216 : "title == Alice");
217 2 : ASSERT(app.dialogs.entries[0].peer_id == 555LL, "peer_id == 555");
218 2 : ASSERT(app.dialogs.entries[0].unread_count == 2, "unread_count == 2");
219 :
220 2 : tui_app_paint(&app);
221 :
222 2 : ASSERT(screen_back_contains(&app.screen, "Alice"),
223 : "back buffer contains 'Alice' after paint");
224 :
225 2 : if (app.screen.out != stdout) fclose(app.screen.out);
226 2 : tui_app_free(&app);
227 2 : transport_close(&t);
228 2 : mt_server_reset();
229 : }
230 :
231 : /**
232 : * TEST-11b: history_pane_load fires messages.getHistory for the selected
233 : * dialog's peer and stores the message. After tui_app_paint the back buffer
234 : * contains the message text.
235 : */
236 2 : static void test_tui_history_load_paints_message(void) {
237 2 : with_tmp_home_tui("hist-paint");
238 2 : mt_server_init(); mt_server_reset();
239 2 : dialogs_cache_flush();
240 2 : MtProtoSession s; load_session_tui(&s);
241 :
242 : /* Seed two expected RPC calls: dialogs then history. */
243 2 : mt_server_expect(CRC_messages_getDialogs, on_dialogs_alice, NULL);
244 2 : mt_server_expect(CRC_messages_getHistory, on_history_one_text, NULL);
245 :
246 2 : ApiConfig cfg; init_cfg_tui(&cfg);
247 2 : Transport t; connect_mock_tui(&t);
248 :
249 : TuiApp app;
250 2 : ASSERT(tui_app_init(&app, 24, 80) == 0, "app init");
251 2 : app.screen.out = fopen("/dev/null", "w");
252 :
253 : /* Phase 1: refresh dialogs. */
254 2 : ASSERT(dialog_pane_refresh(&app.dialogs, &cfg, &s, &t) == 0,
255 : "dialog_pane_refresh ok");
256 2 : app.dialogs.lv.rows_visible = app.layout.dialogs.rows;
257 :
258 : /* Phase 2: simulate Enter — open first dialog's history. */
259 2 : const DialogEntry *d = dialog_pane_selected(&app.dialogs);
260 2 : ASSERT(d != NULL, "a dialog is selected");
261 :
262 2 : HistoryPeer peer = {0};
263 2 : peer.kind = HISTORY_PEER_USER;
264 2 : peer.peer_id = d->peer_id;
265 2 : peer.access_hash = d->access_hash;
266 :
267 2 : ASSERT(history_pane_load(&app.history, &cfg, &s, &t, &peer) == 0,
268 : "history_pane_load ok");
269 2 : app.history.lv.rows_visible = app.layout.history.rows;
270 :
271 2 : ASSERT(app.history.count == 1, "one message loaded");
272 2 : ASSERT(app.history.entries[0].id == 42, "message id == 42");
273 2 : ASSERT(strcmp(app.history.entries[0].text, "Hello TUI world") == 0,
274 : "message text correct");
275 :
276 2 : tui_app_paint(&app);
277 :
278 2 : ASSERT(screen_back_contains(&app.screen, "Hello TUI world"),
279 : "back buffer contains message text after paint");
280 :
281 2 : if (app.screen.out != stdout) fclose(app.screen.out);
282 2 : tui_app_free(&app);
283 2 : transport_close(&t);
284 2 : mt_server_reset();
285 : }
286 :
287 : /**
288 : * TEST-11c: keypress sequence j → Enter → q exercises the full TUI event
289 : * loop against real MTProto data. After Enter the history pane is loaded
290 : * (via the expected server responders), 'q' returns TUI_EVENT_QUIT, and
291 : * the final paint keeps both panes populated.
292 : */
293 2 : static void test_tui_keypress_sequence_j_enter_q(void) {
294 2 : with_tmp_home_tui("key-seq");
295 2 : mt_server_init(); mt_server_reset();
296 2 : dialogs_cache_flush();
297 2 : MtProtoSession s; load_session_tui(&s);
298 :
299 2 : mt_server_expect(CRC_messages_getDialogs, on_dialogs_alice, NULL);
300 2 : mt_server_expect(CRC_messages_getHistory, on_history_one_text, NULL);
301 :
302 2 : ApiConfig cfg; init_cfg_tui(&cfg);
303 2 : Transport t; connect_mock_tui(&t);
304 :
305 : TuiApp app;
306 2 : ASSERT(tui_app_init(&app, 24, 80) == 0, "app init");
307 2 : app.screen.out = fopen("/dev/null", "w");
308 :
309 : /* Refresh dialogs (simulates TUI startup). */
310 2 : ASSERT(dialog_pane_refresh(&app.dialogs, &cfg, &s, &t) == 0,
311 : "dialog refresh");
312 2 : app.dialogs.lv.rows_visible = app.layout.dialogs.rows;
313 :
314 : /* 'j' — move selection down (from 0 to 1, clamped to 0 since only one). */
315 2 : TuiEvent ev = tui_app_handle_char(&app, 'j');
316 2 : ASSERT(ev == TUI_EVENT_REDRAW || ev == TUI_EVENT_NONE,
317 : "'j' returns REDRAW or NONE");
318 :
319 : /* Enter — open the selected dialog. */
320 2 : ev = tui_app_handle_key(&app, TERM_KEY_ENTER);
321 2 : ASSERT(ev == TUI_EVENT_OPEN_DIALOG, "Enter returns OPEN_DIALOG");
322 2 : ASSERT(app.focus == TUI_FOCUS_HISTORY, "focus shifted to history");
323 :
324 : /* Simulate the caller's response to OPEN_DIALOG: load history. */
325 2 : const DialogEntry *d = &app.dialogs.entries[0];
326 2 : HistoryPeer peer = {0};
327 2 : peer.kind = HISTORY_PEER_USER;
328 2 : peer.peer_id = d->peer_id;
329 2 : peer.access_hash = d->access_hash;
330 2 : ASSERT(history_pane_load(&app.history, &cfg, &s, &t, &peer) == 0,
331 : "history loaded after OPEN_DIALOG");
332 2 : app.history.lv.rows_visible = app.layout.history.rows;
333 :
334 : /* Final paint — both panes should now carry real data. */
335 2 : tui_app_paint(&app);
336 2 : ASSERT(screen_back_contains(&app.screen, "Alice"),
337 : "back buffer has dialog title");
338 2 : ASSERT(screen_back_contains(&app.screen, "Hello TUI world"),
339 : "back buffer has message text");
340 :
341 : /* 'q' — quit. */
342 2 : ev = tui_app_handle_char(&app, 'q');
343 2 : ASSERT(ev == TUI_EVENT_QUIT, "'q' returns QUIT");
344 :
345 2 : if (app.screen.out != stdout) fclose(app.screen.out);
346 2 : tui_app_free(&app);
347 2 : transport_close(&t);
348 2 : mt_server_reset();
349 : }
350 :
351 : /* ================================================================ */
352 : /* Suite entry point */
353 : /* ================================================================ */
354 :
355 2 : void run_tui_e2e_tests(void) {
356 2 : RUN_TEST(test_tui_dialog_refresh_paints_title);
357 2 : RUN_TEST(test_tui_history_load_paints_message);
358 2 : RUN_TEST(test_tui_keypress_sequence_j_enter_q);
359 2 : }
|