Compare commits

..

2 Commits

Author SHA1 Message Date
2c74c0a058 refactor(ui): scroll mechanism — derive paged, defer render, split edges
Four-level refactor of the chat-window scrolling state:

L1. Extract win_page_space() and unify the two-branch clamp at the end of
    win_page_down into a single last_page_start formula. Removes the
    duplicated `getmaxy(stdscr) - 4` magic across page_up / page_down /
    sub_page_up / sub_page_down and the off-by-one between the two clamp
    branches.

L2. Remove ProfLayout::paged sticky flag. Replace with win_is_paged(),
    derived from y_pos and the current pad height. Eliminates the entire
    bug class of "stuck paged=1" — there is no flag to drift, no manual
    reset to forget. The earlier non-CHAT bottom-reach reset, the
    WIN_SCROLL_REACHED_BOTTOM reset, the win_clear paged=1 line, and the
    cl_ev_send_ai_msg paged=0 workaround all collapse into "y_pos governs
    everything." cl_ev_send_ai_msg now calls win_move_to_end so the user's
    own message is visible after scroll-up.

L3. _win_printf no longer drops messages when paged. The line is appended
    to the buffer and to the curses pad unconditionally; it lands below
    the visible viewport so the user's reading position is unchanged. The
    unread badge still increments. Suppressed messages are no longer lost
    forever — page-down naturally reveals them.

L4. Replace the win_scroll_state_t enum (INNER / REACHED_TOP /
    REACHED_BOTTOM, conflating layout edge with DB exhaustion) with a
    ScrollEdges struct of two independent booleans:
      db_exhausted_above — a previous page-up DB fetch returned empty
      db_exhausted_below — same for page-down
    The flags are flipped to TRUE on DB_RESPONSE_EMPTY and cleared by a
    scroll in the opposite direction (so MAM late-delivery and /history
    switch can re-trigger fetches). Buffer-edge state is no longer
    encoded here — y_pos handles that.

Tests: stub_ui gets a no-op win_move_to_end. 545/545 unit tests pass.
2026-05-10 16:17:01 +03:00
c167f48714 fix(ui): reset paged flag at buffer bottom for non-chat windows
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 10m34s
CI Code / Check spelling (pull_request) Failing after 11m11s
CI Code / Check coding style (pull_request) Failing after 11m42s
CI Code / Linux (ubuntu) (pull_request) Failing after 12m25s
CI Code / Linux (debian) (pull_request) Failing after 13m2s
CI Code / Linux (arch) (pull_request) Failing after 13m33s
2026-05-10 15:09:32 +03:00
22 changed files with 111 additions and 1555 deletions

View File

@@ -203,13 +203,8 @@ functionaltest_sources = \
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \
tests/functionaltests/ai_http_stub.c tests/functionaltests/ai_http_stub.h \
tests/functionaltests/test_ai_http.c tests/functionaltests/test_ai_http.h \
tests/functionaltests/functionaltests.c
dist_check_DATA = tests/functionaltests/ai_http_stub.py
main_source = src/main.c
python_sources = \

View File

@@ -1196,8 +1196,8 @@ _build_json_payload(AISession* session, const gchar* prompt)
return json_payload;
}
gchar*
ai_parse_response(const gchar* response_json)
static gchar*
_parse_ai_response(const gchar* response_json)
{
if (!response_json || strlen(response_json) == 0)
return NULL;
@@ -1283,8 +1283,8 @@ ai_parse_response(const gchar* response_json)
* Extract error message from an API error JSON response.
* Handles format: {"error":{"message":"...","type":"...","code":...}}
*/
gchar*
ai_parse_error_message(const gchar* error_json)
static gchar*
_parse_error_response(const gchar* error_json)
{
if (!error_json || strlen(error_json) == 0)
return NULL;
@@ -1420,7 +1420,7 @@ _ai_request_thread(gpointer data)
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
response.size, response.data ? response.data : "NULL");
/* Try to extract the actual error message from the JSON response */
auto_gchar gchar* parsed_error = ai_parse_error_message(response.data);
auto_gchar gchar* parsed_error = _parse_error_response(response.data);
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response.data && strlen(response.data) > 0 ? response.data : "Unknown error");
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
@@ -1429,7 +1429,7 @@ _ai_request_thread(gpointer data)
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
auto_gchar gchar* response_data = response.data;
response.data = NULL;
auto_gchar gchar* content = ai_parse_response(response_data);
auto_gchar gchar* content = _parse_ai_response(response_data);
if (content) {
/* Add assistant response to history */
ai_session_add_message(session, "assistant", content);

View File

@@ -169,7 +169,7 @@ gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data);
gboolean ai_models_are_fresh(const gchar* provider_name);
/* ========================================================================
* Parsing helpers (exposed for testing)
* Model Parsing (for testing)
* ======================================================================== */
/**
@@ -180,24 +180,6 @@ gboolean ai_models_are_fresh(const gchar* provider_name);
*/
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
/**
* Extract assistant content from an LLM response body. Tries Perplexity
* /v1/responses "text" first, falls back to OpenAI "content". Decodes
* the \" and \n escape sequences only.
* @param response_json The JSON body from the provider
* @return Newly allocated content string, or NULL if not found (caller frees)
*/
gchar* ai_parse_response(const gchar* response_json);
/**
* Extract human-readable error message from an API error envelope.
* Expected format: {"error":{"message":"...","type":"...","code":...}}
* Decodes \" \n \\ \t escapes.
* @param error_json The JSON body of the error response
* @return Newly allocated error message, or NULL if not parseable (caller frees)
*/
gchar* ai_parse_error_message(const gchar* error_json);
/* ========================================================================
* Session Management
* ======================================================================== */

View File

@@ -301,11 +301,10 @@ cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const
return;
}
/* Reset paged flag before printing user message.
* If the user scrolled up to view history, paged=1 would suppress
* the message in _win_printf(). Reset it here so the message displays. */
aiwin->window.layout->paged = 0;
aiwin->window.layout->unread_msg = 0;
// Snap viewport to bottom so the user's own message is visible even if
// they had scrolled up to read history; otherwise win_is_paged() in
// _win_printf would suppress the print.
win_move_to_end(&aiwin->window);
// Display user message in AI window.
win_print_outgoing(&aiwin->window, ">>", id, NULL, message);

View File

@@ -147,7 +147,7 @@ void
ui_update(void)
{
ProfWin* current = wins_get_current();
if (current->layout->paged == 0) {
if (!win_is_paged(current)) {
win_move_to_end(current);
}

View File

@@ -283,7 +283,7 @@ _show_attention(ProfWin* current, gboolean attention)
static void
_show_scrolled(ProfWin* current)
{
if (current && current->layout->paged == 1) {
if (win_is_paged(current)) {
int bracket_attrs = theme_attrs(THEME_TITLE_BRACKET);
int scrolled_attrs = theme_attrs(THEME_TITLE_SCROLLED);

View File

@@ -120,7 +120,6 @@ typedef struct prof_layout_t
WINDOW* win;
ProfBuff buffer;
int y_pos;
int paged;
int unread_msg;
} ProfLayout;
@@ -149,16 +148,24 @@ typedef enum {
WIN_AI
} win_type_t;
typedef enum {
WIN_SCROLL_INNER,
WIN_SCROLL_REACHED_TOP,
WIN_SCROLL_REACHED_BOTTOM
} win_scroll_state_t;
// ScrollEdges tracks the two end-of-history conditions independently,
// disentangling buffer-layout state (handled implicitly via y_pos) from
// DB-content state. db_exhausted_above means a previous page-up fetch came
// back empty: there is nothing older in the chat database, so the next
// page-up should not re-issue the SQL/flatfile read. db_exhausted_below
// is the analogous flag for newer rows. Both default to FALSE, are flipped
// to TRUE on a respective DB_RESPONSE_EMPTY fetch, and are cleared by
// scrolling in the opposite direction so a re-attempt happens after new
// activity (e.g. MAM late delivery, /history switch).
typedef struct {
gboolean db_exhausted_above;
gboolean db_exhausted_below;
} ScrollEdges;
typedef struct prof_win_t
{
win_type_t type;
win_scroll_state_t scroll_state;
ScrollEdges scroll_edges;
ProfLayout* layout;
Autocomplete urls_ac;
Autocomplete quotes_ac;

View File

@@ -84,6 +84,26 @@ _check_subwin_width(int cols, int width)
return cols <= 1 ? 1 : CLAMP(width, 1, cols - 1);
}
// Visible chat-area height in lines: stdscr minus the title bar (1), the
// status bar (1), the input prompt area (1), and the separator (1).
int
win_page_space(void)
{
return getmaxy(stdscr) - 4;
}
// True when the user has scrolled away from the live tail and there is
// content below the visible viewport. Derived from y_pos and the current
// height of the pad — no sticky flag, no possibility of staleness.
gboolean
win_is_paged(const ProfWin* window)
{
if (!window || !window->layout || !window->layout->win)
return FALSE;
int total_rows = getcury(window->layout->win);
return (total_rows - window->layout->y_pos) > win_page_space();
}
int
win_roster_cols(void)
{
@@ -117,7 +137,6 @@ _win_create_simple_layout(void)
wbkgd(layout->base.win, theme_attrs(THEME_TEXT));
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
@@ -135,7 +154,6 @@ _win_create_split_layout(void)
wbkgd(layout->base.win, theme_attrs(THEME_TEXT));
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
layout->subwin = NULL;
@@ -150,7 +168,7 @@ win_create_console(void)
{
ProfConsoleWin* new_win = malloc(sizeof(ProfConsoleWin));
new_win->window.type = WIN_CONSOLE;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_split_layout();
return &new_win->window;
@@ -162,7 +180,7 @@ win_create_chat(const char* const barejid)
assert(barejid != NULL);
ProfChatWin* new_win = malloc(sizeof(ProfChatWin));
new_win->window.type = WIN_CHAT;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->barejid = strdup(barejid);
@@ -196,7 +214,7 @@ win_create_muc(const char* const roomjid)
int cols = getmaxx(stdscr);
new_win->window.type = WIN_MUC;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
ProfLayoutSplit* layout = malloc(sizeof(ProfLayoutSplit));
layout->base.type = LAYOUT_SPLIT;
@@ -215,7 +233,6 @@ win_create_muc(const char* const roomjid)
layout->memcheck = LAYOUT_SPLIT_MEMCHECK;
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
new_win->window.layout = (ProfLayout*)layout;
@@ -254,7 +271,7 @@ win_create_config(const char* const roomjid, DataForm* form, ProfConfWinCallback
assert(form != NULL);
ProfConfWin* new_win = malloc(sizeof(ProfConfWin));
new_win->window.type = WIN_CONFIG;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->roomjid = strdup(roomjid);
new_win->form = form;
@@ -273,7 +290,7 @@ win_create_private(const char* const fulljid)
assert(fulljid != NULL);
ProfPrivateWin* new_win = malloc(sizeof(ProfPrivateWin));
new_win->window.type = WIN_PRIVATE;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->fulljid = strdup(fulljid);
new_win->unread = 0;
@@ -290,7 +307,7 @@ win_create_xmlconsole(void)
{
ProfXMLWin* new_win = malloc(sizeof(ProfXMLWin));
new_win->window.type = WIN_XML;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->memcheck = PROFXMLWIN_MEMCHECK;
@@ -305,7 +322,7 @@ win_create_plugin(const char* const plugin_name, const char* const tag)
assert(tag != NULL);
ProfPluginWin* new_win = malloc(sizeof(ProfPluginWin));
new_win->window.type = WIN_PLUGIN;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->tag = strdup(tag);
@@ -322,7 +339,7 @@ win_create_vcard(vCard* vcard)
assert(vcard != NULL);
ProfVcardWin* new_win = malloc(sizeof(ProfVcardWin));
new_win->window.type = WIN_VCARD;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->vcard = vcard;
@@ -336,7 +353,7 @@ win_create_ai(AISession* session)
{
ProfAiWin* new_win = g_new0(ProfAiWin, 1);
new_win->window.type = WIN_AI;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->message_buffer = buffer_create();
@@ -722,11 +739,13 @@ win_page_up(ProfWin* window, int scroll_size)
int* page_start = &(window->layout->y_pos);
int page_start_initial = *page_start;
// Size of the visible chat page
int page_space = getmaxy(stdscr) - 4;
int page_space = win_page_space();
if (scroll_size == 0)
scroll_size = page_space;
win_scroll_state_t* scroll_state = &window->scroll_state;
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) ? WIN_SCROLL_INNER : *scroll_state;
ScrollEdges* edges = &window->scroll_edges;
// Going up means the bottom of the DB may have new content again; allow
// a re-fetch on the next page-down.
edges->db_exhausted_below = FALSE;
*page_start -= scroll_size;
gboolean has_scroll_past_buffer = *page_start < 0;
@@ -736,16 +755,18 @@ win_page_up(ProfWin* window, int scroll_size)
gboolean is_still_fetching_mam = first_entry && (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, LOADING_MESSAGE) == 0);
if (!is_still_fetching_mam) {
// WIN_SCROLL_REACHED_TOP means that we reached top of DB and there is no need to refetch data from it
gboolean is_db_offset_jump_needed = false;
if (*scroll_state != WIN_SCROLL_REACHED_TOP) {
// db_exhausted_above suppresses further upward DB fetches; once
// a previous fetch returned empty, there is nothing older.
gboolean is_db_offset_jump_needed = FALSE;
if (!edges->db_exhausted_above) {
db_history_result_t db_response = chatwin_db_history(chatwin, NULL, NULL, TRUE);
is_db_offset_jump_needed = db_response == DB_RESPONSE_SUCCESS;
*scroll_state = db_response == DB_RESPONSE_EMPTY ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER;
log_debug("Scroll state after DB history fetch: %s", *scroll_state ? "WIN_SCROLL_REACHED_TOP" : "WIN_SCROLL_INNER");
edges->db_exhausted_above = (db_response == DB_RESPONSE_EMPTY);
log_debug("Scroll edges after DB history fetch: above=%d below=%d",
edges->db_exhausted_above, edges->db_exhausted_below);
}
if (*scroll_state == WIN_SCROLL_REACHED_TOP) {
if (edges->db_exhausted_above) {
*page_start = 0;
if (prefs_get_boolean(PREF_MAM)) {
win_print_loading_history(window);
@@ -777,9 +798,7 @@ win_page_up(ProfWin* window, int scroll_size)
win_update_virtual(window);
}
// Update scrolling state
int total_rows = getcury(window->layout->win);
window->layout->paged = (total_rows) - *page_start > page_space;
// paged is derived on demand from y_pos / total_rows; nothing to set here.
}
void
@@ -788,13 +807,15 @@ win_page_down(ProfWin* window, int scroll_size)
int total_rows = getcury(window->layout->win);
int total_rows_with_unread = total_rows + window->layout->unread_msg;
int* page_start = &(window->layout->y_pos);
int page_space = getmaxy(stdscr) - 4;
int page_space = win_page_space();
int page_start_initial = *page_start;
if (scroll_size == 0)
scroll_size = page_space;
win_scroll_state_t* scroll_state = &window->scroll_state;
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_TOP) ? WIN_SCROLL_INNER : *scroll_state;
ScrollEdges* edges = &window->scroll_edges;
// Going down means the top of the DB may have new content again; allow
// a re-fetch on the next page-up (e.g. MAM late delivery).
edges->db_exhausted_above = FALSE;
*page_start += scroll_size;
@@ -805,7 +826,7 @@ win_page_down(ProfWin* window, int scroll_size)
if ((past_bottom || at_page_space_and_past_unread) && is_chat) {
int bf_size = buffer_size(window->layout->buffer);
if (bf_size > 0 && *scroll_state != WIN_SCROLL_REACHED_BOTTOM) {
if (bf_size > 0 && !edges->db_exhausted_below) {
// How many lines are left until end of the screen
int current_offset = total_rows - *page_start;
GDateTime* now = g_date_time_new_now_local();
@@ -814,7 +835,7 @@ win_page_down(ProfWin* window, int scroll_size)
auto_gchar gchar* end_date = g_date_time_format_iso8601(now);
db_history_result_t db_response = chatwin_db_history((ProfChatWin*)window, start, end_date, FALSE);
if (db_response == DB_RESPONSE_EMPTY)
*scroll_state = WIN_SCROLL_REACHED_BOTTOM;
edges->db_exhausted_below = TRUE;
// similar to page_up (see explanation there)
// Get offset of the message that was the latest prior to loading messages from the DB
@@ -826,26 +847,22 @@ win_page_down(ProfWin* window, int scroll_size)
total_rows = getcury(window->layout->win);
// near the end, but will display only part of the page space, move a bit back to show full page
if ((total_rows - *page_start) < page_space) {
*page_start = total_rows - page_space;
// went past end, show last page
} else if (*page_start >= total_rows) {
*page_start = total_rows - page_space - 1;
// Clamp to the last full page so the viewport never goes past the buffer
// and always shows a full page when one is available.
int last_page_start = total_rows > page_space ? total_rows - page_space : 0;
if (*page_start > last_page_start) {
*page_start = last_page_start;
}
window->layout->paged = 1;
// update only if position has changed
if ((page_start_initial != *page_start) || window->layout->unread_msg) {
win_update_virtual(window);
}
/* Switch off page if no messages left to read.
* TODO: update buffer end handling to check messages just after last entry.
*/
if (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) {
window->layout->paged = 0;
// Once the user is back at the buffer's last page the unread badge no
// longer reflects anything they have not seen — clear it. paged itself
// is derived from y_pos so there is no flag to flip.
if (*page_start >= last_page_start) {
window->layout->unread_msg = 0;
}
}
@@ -854,8 +871,7 @@ void
win_sub_page_down(ProfWin* window)
{
if (window->layout->type == LAYOUT_SPLIT) {
int rows = getmaxy(stdscr);
int page_space = rows - 4;
int page_space = win_page_space();
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
int sub_y = getcury(split_layout->subwin);
int* sub_y_pos = &(split_layout->sub_y_pos);
@@ -878,8 +894,7 @@ void
win_sub_page_up(ProfWin* window)
{
if (window->layout->type == LAYOUT_SPLIT) {
int rows = getmaxy(stdscr);
int page_space = rows - 4;
int page_space = win_page_space();
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
int* sub_y_pos = &(split_layout->sub_y_pos);
@@ -906,7 +921,6 @@ win_clear(ProfWin* window)
int y = getcury(window->layout->win);
int* page_start = &(window->layout->y_pos);
*page_start = y;
window->layout->paged = 1;
window->layout->unread_msg = 0;
win_update_virtual(window);
}
@@ -1016,14 +1030,12 @@ win_refresh_with_subwin(ProfWin* window)
void
win_move_to_end(ProfWin* window)
{
window->layout->paged = 0;
window->layout->unread_msg = 0;
int rows = getmaxy(stdscr);
int y = getcury(window->layout->win);
int size = rows - 3;
int page_space = win_page_space();
window->layout->y_pos = y - (size - 1);
window->layout->y_pos = y - page_space;
if (window->layout->y_pos < 0) {
window->layout->y_pos = 0;
}
@@ -1800,12 +1812,13 @@ win_newline(ProfWin* window)
static void
_win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* timestamp, int flags, theme_item_t theme_item, const char* const display_from, const char* const from_jid, const char* const message_id, const char* const message, ...)
{
/* Prevent printing and buffer update when user is viewing message history [SCROLLING]*/
if (window->layout->paged && wins_is_current(window)) {
window->layout->unread_msg++;
return;
}
/* Append the message to the curses pad and the in-memory buffer
* unconditionally. When the user is scrolled up (paged), the new line
* lands on rows below the visible viewport so it does not disturb what
* they are reading; they encounter it naturally on page-down. The
* unread badge is bumped so the title bar still reflects new activity.
*/
const gboolean is_paged_now = win_is_paged(window) && wins_is_current(window);
if (timestamp == NULL) {
timestamp = g_date_time_new_now_local();
@@ -1822,6 +1835,10 @@ _win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* t
_win_print_internal(window, show_char, pad_indent, timestamp, flags, theme_item, display_from, msg, NULL);
buffer_append(window->layout->buffer, show_char, pad_indent, timestamp, flags, theme_item, display_from, from_jid, msg, NULL, message_id, y_start_pos, getcury(window->layout->win));
if (is_paged_now) {
window->layout->unread_msg++;
}
inp_nonblocking(TRUE);
g_date_time_unref(timestamp);

View File

@@ -81,6 +81,8 @@ void win_redraw(ProfWin* window);
void win_print_loading_history(ProfWin* window);
int win_roster_cols(void);
int win_occpuants_cols(void);
int win_page_space(void);
gboolean win_is_paged(const ProfWin* window);
void win_sub_print(WINDOW* win, char* msg, gboolean newline, gboolean wrap, int indent);
void win_sub_newline_lazy(WINDOW* win);
void win_mark_received(ProfWin* window, const char* const id);

View File

@@ -1,100 +0,0 @@
/*
* ai_http_stub.c
*
* Spawns tests/functionaltests/ai_http_stub.py as a child process for
* functional tests that exercise the AI client's HTTP code path. The
* stub listens on 127.0.0.1 on an OS-chosen port and prints "PORT=<n>"
* as its first line; we read it back and hand the port to the caller.
*/
#include "ai_http_stub.h"
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#define STUB_SCRIPT "../tests/functionaltests/ai_http_stub.py"
#define READLINE_MAX 64
static pid_t stub_pid = 0;
int
ai_http_stub_start(const char* mode, const char* reply)
{
int pipefd[2];
if (pipe(pipefd) != 0) {
return 0;
}
pid_t pid = fork();
if (pid < 0) {
close(pipefd[0]);
close(pipefd[1]);
return 0;
}
if (pid == 0) {
/* Child: redirect stdout to the pipe write end, then exec the stub. */
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
if (mode) {
setenv("AI_STUB_MODE", mode, 1);
}
if (reply) {
setenv("AI_STUB_REPLY", reply, 1);
}
/* Argument "0" -> let the OS pick a free port. */
execlp("python3", "python3", STUB_SCRIPT, "0", (char*)NULL);
/* Falls through on exec failure. */
_exit(127);
}
/* Parent: read "PORT=<n>\n" from the child's stdout. */
close(pipefd[1]);
char buf[READLINE_MAX] = { 0 };
ssize_t total = 0;
while (total < (ssize_t)sizeof(buf) - 1) {
ssize_t n = read(pipefd[0], buf + total, sizeof(buf) - 1 - total);
if (n <= 0) {
break;
}
total += n;
if (memchr(buf, '\n', total)) {
break;
}
}
close(pipefd[0]);
int port = 0;
if (sscanf(buf, "PORT=%d", &port) != 1 || port <= 0) {
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
return 0;
}
stub_pid = pid;
return port;
}
void
ai_http_stub_stop(void)
{
if (stub_pid <= 0) {
return;
}
kill(stub_pid, SIGTERM);
/* Give the stub a moment to exit cleanly, then reap. */
int status = 0;
pid_t waited = waitpid(stub_pid, &status, 0);
(void)waited;
(void)status;
stub_pid = 0;
}

View File

@@ -1,25 +0,0 @@
#ifndef __H_AI_HTTP_STUB
#define __H_AI_HTTP_STUB
#include <sys/types.h>
/*
* Spawn the Python AI stub script (tests/functionaltests/ai_http_stub.py).
*
* mode — one of "ok", "openai", "401", "500", "models" (see the stub
* script). Selects the response body and HTTP status.
* reply — body text used by "ok"/"openai"/"401" modes. Pass NULL for
* the default ("MOCK-REPLY").
*
* Returns the listening port (>0) on success, 0 on failure. The child's
* pid is recorded internally; call ai_http_stub_stop() to terminate it.
*/
int ai_http_stub_start(const char* mode, const char* reply);
/*
* Send SIGTERM to the most recently spawned stub and reap it.
* No-op if no stub is running.
*/
void ai_http_stub_stop(void);
#endif

View File

@@ -1,117 +0,0 @@
#!/usr/bin/env python3
"""
Minimal HTTP stub for AI functional tests.
Listens on 127.0.0.1:<port> and serves canned JSON responses for any POST.
Behaviour is selected via the URL path and the AI_STUB_MODE environment
variable, set by the test runner before exec().
Modes:
- "ok" : 200 with a valid OpenAI Responses-API body
({"output":[{"content":[{"text":"...","type":"output_text"}]}]})
- "openai" : 200 with a legacy OpenAI Chat Completions body
({"choices":[{"message":{"content":"..."}}]})
- "401" : 401 with an OpenAI-style error envelope
- "500" : 500 with a body that triggers the parse-error fallback
- "models" : 200 with a /v1/models list body
The reply text is taken from AI_STUB_REPLY (default: "MOCK-REPLY").
The stub writes its actual listening port to stdout as a single line
"PORT=<n>" so the parent can read it back, then runs until SIGTERM.
"""
import json
import os
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
REPLY = os.environ.get("AI_STUB_REPLY", "MOCK-REPLY")
MODE = os.environ.get("AI_STUB_MODE", "ok")
def _dumps(body: object) -> bytes:
"""Produce compact JSON without whitespace — profanity's hand-rolled
parser does strstr for `"key":"`, which fails when json.dumps inserts
a space after the colon by default."""
return json.dumps(body, separators=(",", ":")).encode("utf-8")
def _body_for(mode: str) -> tuple[int, bytes]:
if mode == "ok":
body = {
"output": [
{
"content": [
{"text": REPLY, "type": "output_text"}
]
}
]
}
return 200, _dumps(body)
if mode == "openai":
body = {
"choices": [
{"message": {"role": "assistant", "content": REPLY}}
]
}
return 200, _dumps(body)
if mode == "401":
body = {
"error": {
"message": REPLY,
"type": "invalid_request_error",
"code": "invalid_api_key",
}
}
return 401, _dumps(body)
if mode == "500":
return 500, b"<html><body>Internal Server Error</body></html>"
if mode == "models":
body = {
"object": "list",
"data": [
{"id": "model-a", "object": "model"},
{"id": "model-b", "object": "model"},
{"id": "model-c", "object": "model"},
],
}
return 200, _dumps(body)
return 200, b"{}"
class StubHandler(BaseHTTPRequestHandler):
def _send(self):
status, body = _body_for(MODE)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
if length:
self.rfile.read(length)
self._send()
def do_GET(self):
self._send()
def log_message(self, fmt, *args):
return
def main() -> None:
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
srv = HTTPServer(("127.0.0.1", port), StubHandler)
actual = srv.server_address[1]
print(f"PORT={actual}", flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

View File

@@ -59,8 +59,6 @@
#ifdef HAVE_SQLITE
#include "test_export_import.h"
#endif
#include "test_ai.h"
#include "test_ai_http.h"
/* Macro to wrap each test with setup/teardown functions */
#define PROF_FUNC_TEST(test) \
@@ -68,14 +66,6 @@
#test, test, init_prof_test, close_prof_test, #test \
}
/* AI tests use a custom init that primes stabber via prof_connect so
* stbbr_stop() in close_prof_test() doesn't hang on a never-connected
* server thread. See ai_init_test() in test_ai.c. */
#define PROF_FUNC_TEST_AI(test) \
{ \
#test, test, ai_init_test, close_prof_test, #test \
}
#ifdef HAVE_SQLITE
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
static int
@@ -94,9 +84,9 @@ main(int argc, char* argv[])
int group = 0; /* 0 = all groups */
if (argc > 1) {
group = atoi(argv[1]);
if (group < 1 || group > 5) {
if (group < 1 || group > 4) {
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n");
return 1;
}
}
@@ -317,39 +307,6 @@ main(int argc, char* argv[])
#endif
};
/* ============================================================
* GROUP 5: AI command surface (console + HTTP stub)
* Standalone because AI tests don't use stabber's XMPP path; mixing
* them with XMPP-bound tests in the same group poisons stabber state
* between fixture init/teardown cycles and hangs subsequent
* prof_connect() calls.
* ============================================================ */
const struct CMUnitTest group5_tests[] = {
/* Tier A — no network calls */
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
PROF_FUNC_TEST_AI(ai_set_provider_adds_custom),
PROF_FUNC_TEST_AI(ai_set_token_marks_key_set),
PROF_FUNC_TEST_AI(ai_start_unknown_provider_errors),
PROF_FUNC_TEST_AI(ai_start_without_key_errors),
PROF_FUNC_TEST_AI(ai_start_with_key_opens_window),
PROF_FUNC_TEST_AI(ai_clear_without_window_errors),
PROF_FUNC_TEST_AI(ai_remove_provider_works),
PROF_FUNC_TEST_AI(ai_remove_provider_unknown_errors),
PROF_FUNC_TEST_AI(ai_set_default_provider_unknown_errors),
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
/* Tier B — full request/response through Python HTTP stub */
PROF_FUNC_TEST_AI(ai_http_prompt_returns_ok),
PROF_FUNC_TEST_AI(ai_http_prompt_openai_format),
PROF_FUNC_TEST_AI(ai_http_prompt_401_shows_error),
PROF_FUNC_TEST_AI(ai_http_prompt_500_surfaced),
PROF_FUNC_TEST_AI(ai_http_models_fetch_lists_models),
};
/* Test group registry for easy extension */
struct
{
@@ -361,7 +318,6 @@ main(int argc, char* argv[])
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
{ "Group 5: AI command surface (Tier A + Tier B)", group5_tests, ARRAY_SIZE(group5_tests) },
};
const int num_groups = ARRAY_SIZE(groups);

View File

@@ -20,7 +20,7 @@
#include "proftest.h"
/* Number of parallel test groups for CI builds */
#define TEST_GROUPS 5
#define TEST_GROUPS 4
/* Number of ports reserved per test group; allows skipping TIME_WAIT ports */
#define PORTS_PER_GROUP 50

View File

@@ -1,241 +0,0 @@
/*
* test_ai.c
*
* Functional tests for the /ai command surface (Tier A — no network).
*
* These tests interact with profanity through its PTY console and exercise
* paths that do not reach libcurl: command parsing, provider registration,
* key/setting/default storage, error messages, and AI window creation.
*
* Tests that require an actual provider HTTP exchange (prompt -> response,
* /ai models <provider>, HTTP error envelopes) are out of scope here and
* belong in a separate suite backed by a local HTTP stub.
*/
#include <glib.h>
#include "prof_cmocka.h"
#include <stdlib.h>
#include <string.h>
#include "proftest.h"
#include "test_ai.h"
int
ai_init_test(void** state)
{
int ret = init_prof_test(state);
if (ret != 0) {
return ret;
}
/* Connect to stabber even though AI tests don't use XMPP. Without this
* stabber's accept loop has no client to disconnect on /quit, and
* stbbr_stop() in close_prof_test() hangs uninterruptibly when joining
* the server thread. */
prof_connect();
return 0;
}
void
ai_no_args_shows_help(void** state)
{
/* `/ai` with no arguments lists the built-in providers and a usage hint. */
prof_input("/ai");
prof_timeout(5);
assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client"));
assert_true(prof_output_exact("Configured providers:"));
assert_true(prof_output_regex("openai"));
assert_true(prof_output_regex("perplexity"));
assert_true(prof_output_regex("Use '/ai start' to begin a chat"));
prof_timeout_reset();
}
void
ai_providers_lists_defaults(void** state)
{
/* `/ai providers` (no "list") shows the built-in list with URLs. */
prof_input("/ai providers");
prof_timeout(5);
assert_true(prof_output_exact("Available AI providers:"));
assert_true(prof_output_regex("openai"));
assert_true(prof_output_regex("perplexity"));
prof_timeout_reset();
}
void
ai_providers_list_shows_key_status(void** state)
{
/* `/ai providers list` lists each configured provider with key status. */
prof_input("/ai providers list");
prof_timeout(5);
assert_true(prof_output_exact("Configured providers:"));
/* No tokens have been set yet in this fresh session. */
assert_true(prof_output_regex("Key: NOT configured"));
prof_timeout_reset();
}
void
ai_set_provider_adds_custom(void** state)
{
/* Adding a custom provider should make it appear in /ai providers list. */
prof_input("/ai set provider mock http://127.0.0.1:1/");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'mock' configured"));
prof_timeout_reset();
prof_input("/ai providers list");
prof_timeout(5);
assert_true(prof_output_regex("mock"));
assert_true(prof_output_regex("http://127.0.0.1:1/"));
prof_timeout_reset();
}
void
ai_set_token_marks_key_set(void** state)
{
/* Setting a token must flip the provider's key status to "configured". */
prof_input("/ai set token openai sk-fake-test-key");
prof_timeout(5);
assert_true(prof_output_regex("API token set for provider: openai"));
prof_timeout_reset();
prof_input("/ai providers list");
prof_timeout(5);
/* openai now shows "Key: configured" while perplexity stays unconfigured. */
assert_true(prof_output_regex("Key: configured"));
prof_timeout_reset();
}
void
ai_start_unknown_provider_errors(void** state)
{
/* Unknown provider name should error out without creating a window. */
prof_input("/ai start nope_provider somemodel");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'nope_provider' not found"));
prof_timeout_reset();
}
void
ai_start_without_key_errors(void** state)
{
/* Known provider without an API key should refuse to start. */
prof_input("/ai start openai gpt-4");
prof_timeout(5);
assert_true(prof_output_regex("No API key set for provider 'openai'"));
prof_timeout_reset();
}
void
ai_start_with_key_opens_window(void** state)
{
/* With a token set, /ai start should create a WIN_AI window. */
prof_input("/ai set token openai sk-fake-test-key");
prof_timeout(5);
assert_true(prof_output_regex("API token set for provider: openai"));
prof_timeout_reset();
prof_input("/ai start openai gpt-4");
prof_timeout(5);
/* /ai start switches focus to the new WIN_AI window; cons_show output
* to the console is therefore not the right place to look. The window
* itself prints "AI Chat: <provider>/<model>" as its first line. */
assert_true(prof_output_regex("AI Chat: openai/gpt-4"));
prof_timeout_reset();
}
void
ai_clear_without_window_errors(void** state)
{
/* /ai clear from the console (no active AI window) should report nicely. */
prof_input("/ai clear");
prof_timeout(5);
assert_true(prof_output_regex("No active AI chat window to clear"));
prof_timeout_reset();
}
void
ai_remove_provider_works(void** state)
{
/* Round-trip: add -> verify present -> remove -> verify gone. */
prof_input("/ai set provider tmpprov http://127.0.0.1:2/");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'tmpprov' configured"));
prof_timeout_reset();
prof_input("/ai remove provider tmpprov");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'tmpprov' removed"));
prof_timeout_reset();
prof_input("/ai remove provider tmpprov");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'tmpprov' not found"));
prof_timeout_reset();
}
void
ai_remove_provider_unknown_errors(void** state)
{
/* Removing a provider that was never added must report "not found". */
prof_input("/ai remove provider nonexistent_xyz");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found"));
prof_timeout_reset();
}
void
ai_set_default_provider_unknown_errors(void** state)
{
/* Setting an unknown default provider should error, not silently accept. */
prof_input("/ai set default-provider does_not_exist");
prof_timeout(5);
assert_true(prof_output_regex("Provider 'does_not_exist' not found"));
prof_timeout_reset();
}
void
ai_set_default_model_updates_provider(void** state)
{
/* Setting a default model is acknowledged on the console. */
prof_input("/ai set default-model openai gpt-5-preview");
prof_timeout(5);
assert_true(prof_output_regex("Default model for provider 'openai' set to: gpt-5-preview"));
prof_timeout_reset();
}
void
ai_switch_without_window_errors(void** state)
{
/* /ai switch with no active AI window should produce an actionable error. */
prof_input("/ai switch openai gpt-4");
prof_timeout(5);
assert_true(prof_output_regex("No active AI chat window"));
prof_timeout_reset();
}
void
ai_bad_subcommand_shows_usage(void** state)
{
/* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */
prof_input("/ai not_a_subcommand");
prof_timeout(5);
assert_true(prof_output_regex("Invalid usage, see '/help ai'"));
prof_timeout_reset();
}

View File

@@ -1,28 +0,0 @@
#ifndef __H_FUNC_TEST_AI
#define __H_FUNC_TEST_AI
/*
* AI tests don't exercise the XMPP path, but the test fixture's
* stbbr_stop() hangs indefinitely when no client ever connected to
* stabber. ai_init_test wraps init_prof_test with a prof_connect()
* so stabber sees a graceful disconnect at teardown.
*/
int ai_init_test(void** state);
void ai_no_args_shows_help(void** state);
void ai_providers_lists_defaults(void** state);
void ai_providers_list_shows_key_status(void** state);
void ai_set_provider_adds_custom(void** state);
void ai_set_token_marks_key_set(void** state);
void ai_start_unknown_provider_errors(void** state);
void ai_start_without_key_errors(void** state);
void ai_start_with_key_opens_window(void** state);
void ai_clear_without_window_errors(void** state);
void ai_remove_provider_works(void** state);
void ai_remove_provider_unknown_errors(void** state);
void ai_set_default_provider_unknown_errors(void** state);
void ai_set_default_model_updates_provider(void** state);
void ai_switch_without_window_errors(void** state);
void ai_bad_subcommand_shows_usage(void** state);
#endif

View File

@@ -1,157 +0,0 @@
/*
* test_ai_http.c
*
* Functional tests for the /ai command surface that exercise the libcurl
* HTTP code path. A small Python HTTP stub (ai_http_stub.py) is spawned
* per-test on a free local port; profanity is configured to point at it
* via `/ai set provider`. The stub serves canned JSON whose shape is
* controlled via AI_STUB_MODE.
*
* These tests intentionally do not race the worker thread directly —
* they rely on prof_timeout() retries to catch the response once it
* propagates back through the AI window.
*/
#include <glib.h>
#include "prof_cmocka.h"
#include <stdlib.h>
#include <string.h>
#include "proftest.h"
#include "ai_http_stub.h"
#define WAIT_AI_RESPONSE_SEC 10
/*
* Configure profanity to use the spawned stub as a provider named "mock"
* with a dummy bearer token, then issue /ai start to open the AI window.
* Returns the stub port on success, fails the test on stub-spawn failure.
*/
static int
_setup_mock_provider(const char* mode, const char* reply)
{
int port = ai_http_stub_start(mode, reply);
assert_int_not_equal(0, port);
char cmd[256];
g_snprintf(cmd, sizeof(cmd), "/ai set provider mock http://127.0.0.1:%d/", port);
prof_input(cmd);
prof_timeout(5);
assert_true(prof_output_regex("Provider 'mock' configured"));
prof_timeout_reset();
prof_input("/ai set token mock fake-test-token");
prof_timeout(5);
assert_true(prof_output_regex("API token set for provider: mock"));
prof_timeout_reset();
return port;
}
void
ai_http_prompt_returns_ok(void** state)
{
/* Happy path: stub returns Responses API "text" body; reply appears in window. */
_setup_mock_provider("ok", "STUB-HELLO");
prof_input("/ai start mock testmodel");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/testmodel"));
prof_timeout_reset();
/* Plain text in AI window goes through cl_ev_send_ai_msg -> ai_send_prompt. */
prof_input("hello stub");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("STUB-HELLO"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_openai_format(void** state)
{
/* Legacy OpenAI chat completions shape: "choices[].message.content". */
_setup_mock_provider("openai", "FROM-CHOICES");
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("anything");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("FROM-CHOICES"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_401_shows_error(void** state)
{
/* 401 with an OpenAI-style error envelope.
* ai_parse_error_message() should extract the inner message; the AI
* window then displays "HTTP 401: <message>". */
_setup_mock_provider("401", "Bad token, try another");
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("prompt");
prof_timeout(WAIT_AI_RESPONSE_SEC);
/* Either the parsed message surfaces, or the raw HTTP 401 envelope. */
assert_true(prof_output_regex("HTTP 401") && prof_output_regex("Bad token, try another"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_500_surfaced(void** state)
{
/* 500 with a non-JSON HTML body. Parser falls back to raw body in HTTP error. */
_setup_mock_provider("500", NULL);
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("trigger");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("HTTP 500"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_models_fetch_lists_models(void** state)
{
/* /v1/models endpoint returns a list of three models. /ai models <p>
* caches them silently; /ai models <p> --cached then displays the names. */
_setup_mock_provider("models", NULL);
prof_input("/ai models mock");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("Cached 3 models for provider 'mock'"));
prof_timeout_reset();
prof_input("/ai models mock --cached");
prof_timeout(5);
assert_true(prof_output_regex("model-a"));
assert_true(prof_output_regex("model-b"));
assert_true(prof_output_regex("model-c"));
prof_timeout_reset();
ai_http_stub_stop();
}

View File

@@ -1,10 +0,0 @@
#ifndef __H_FUNC_TEST_AI_HTTP
#define __H_FUNC_TEST_AI_HTTP
void ai_http_prompt_returns_ok(void** state);
void ai_http_prompt_openai_format(void** state);
void ai_http_prompt_401_shows_error(void** state);
void ai_http_prompt_500_surfaced(void** state);
void ai_http_models_fetch_lists_models(void** state);
#endif

View File

@@ -1,10 +1,7 @@
#include "prof_cmocka.h"
#include "common.h"
#include "ai/ai_client.h"
#include "config/preferences.h"
#include "helpers.h"
#include <stdlib.h>
#include <string.h>
/* ========================================================================
* Setup/Teardown
@@ -870,598 +867,3 @@ test_ai_parse_models_with_whitespace(void** state)
ai_provider_unref(provider);
ai_client_shutdown();
}
/* ========================================================================
* Setup combining prefs + ai_client for round-trip persistence tests
* ======================================================================== */
int
ai_client_setup_with_prefs(void** state)
{
if (load_preferences(state) != 0) {
return 1;
}
ai_client_init();
return 0;
}
int
ai_client_teardown_with_prefs(void** state)
{
ai_client_shutdown();
close_preferences(state);
return 0;
}
/* ========================================================================
* Chat response parser tests (ai_parse_response)
* ======================================================================== */
void
test_ai_parse_response_openai_content(void** state)
{
const gchar* json = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Hello, world!\"}}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("Hello, world!", out);
}
void
test_ai_parse_response_perplexity_text(void** state)
{
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("Search result", out);
}
void
test_ai_parse_response_text_preferred_over_content(void** state)
{
/* When both formats are present, "text" wins (Perplexity path tried first). */
const gchar* json = "{\"text\":\"from text field\",\"content\":\"from content field\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("from text field", out);
}
void
test_ai_parse_response_escaped_quote(void** state)
{
const gchar* json = "{\"content\":\"He said \\\"hello\\\" today\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("He said \"hello\" today", out);
}
void
test_ai_parse_response_newline_escape(void** state)
{
const gchar* json = "{\"content\":\"line one\\nline two\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("line one\nline two", out);
}
void
test_ai_parse_response_empty_content(void** state)
{
const gchar* json = "{\"content\":\"\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("", out);
}
void
test_ai_parse_response_missing_field(void** state)
{
const gchar* json = "{\"foo\":\"bar\"}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_null_input(void** state)
{
assert_null(ai_parse_response(NULL));
}
void
test_ai_parse_response_empty_input(void** state)
{
assert_null(ai_parse_response(""));
}
void
test_ai_parse_response_percent_signs_safe(void** state)
{
/* Format-string safety: %s, %d in content must not be interpreted later. */
const gchar* json = "{\"content\":\"100% done with %s and %d markers\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("100% done with %s and %d markers", out);
}
void
test_ai_parse_response_braces_in_content(void** state)
{
/* Content containing JSON-looking braces should not confuse the parser. */
const gchar* json = "{\"content\":\"use {key: value} syntax\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("use {key: value} syntax", out);
}
void
test_ai_parse_response_multiline_content(void** state)
{
const gchar* json = "{\"content\":\"para 1\\n\\npara 2\\nlast\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("para 1\n\npara 2\nlast", out);
}
/* ========================================================================
* Error response parser tests (ai_parse_error_message)
* ======================================================================== */
void
test_ai_parse_error_standard_envelope(void** state)
{
const gchar* json = "{\"error\":{\"message\":\"Invalid API key provided\",\"type\":\"invalid_request_error\",\"code\":\"invalid_api_key\"}}";
auto_gchar gchar* out = ai_parse_error_message(json);
assert_non_null(out);
assert_string_equal("Invalid API key provided", out);
}
void
test_ai_parse_error_with_escapes(void** state)
{
const gchar* json = "{\"error\":{\"message\":\"Field \\\"model\\\" is required\"}}";
auto_gchar gchar* out = ai_parse_error_message(json);
assert_non_null(out);
assert_string_equal("Field \"model\" is required", out);
}
void
test_ai_parse_error_no_error_field(void** state)
{
const gchar* json = "{\"message\":\"this is not in an error envelope\"}";
gchar* out = ai_parse_error_message(json);
assert_null(out);
}
void
test_ai_parse_error_no_message_field(void** state)
{
const gchar* json = "{\"error\":{\"type\":\"some_error\",\"code\":42}}";
gchar* out = ai_parse_error_message(json);
assert_null(out);
}
void
test_ai_parse_error_null_input(void** state)
{
assert_null(ai_parse_error_message(NULL));
}
void
test_ai_parse_error_empty_input(void** state)
{
assert_null(ai_parse_error_message(""));
}
void
test_ai_parse_error_tab_escape(void** state)
{
const gchar* json = "{\"error\":{\"message\":\"col1\\tcol2\"}}";
auto_gchar gchar* out = ai_parse_error_message(json);
assert_non_null(out);
assert_string_equal("col1\tcol2", out);
}
void
test_ai_parse_error_backslash_escape(void** state)
{
const gchar* json = "{\"error\":{\"message\":\"path C:\\\\Users\\\\x\"}}";
auto_gchar gchar* out = ai_parse_error_message(json);
assert_non_null(out);
assert_string_equal("path C:\\Users\\x", out);
}
/* ========================================================================
* Extended JSON escape tests
* ======================================================================== */
void
test_ai_json_escape_backspace(void** state)
{
auto_gchar gchar* out = ai_json_escape("a\bb");
assert_string_equal("a\\bb", out);
}
void
test_ai_json_escape_formfeed(void** state)
{
auto_gchar gchar* out = ai_json_escape("a\fb");
assert_string_equal("a\\fb", out);
}
void
test_ai_json_escape_carriage_return(void** state)
{
auto_gchar gchar* out = ai_json_escape("a\rb");
assert_string_equal("a\\rb", out);
}
void
test_ai_json_escape_all_specials_combined(void** state)
{
auto_gchar gchar* out = ai_json_escape("\"\\/\b\f\n\r\t");
/* `/` is not escaped by the encoder (valid JSON either way). */
assert_string_equal("\\\"\\\\/\\b\\f\\n\\r\\t", out);
}
void
test_ai_json_escape_passes_utf8_through(void** state)
{
/* UTF-8 bytes are valid in JSON strings and should pass through unchanged. */
auto_gchar gchar* out = ai_json_escape("Привет 🦀");
assert_string_equal("Привет 🦀", out);
}
/* ========================================================================
* Autocomplete cycling with many providers
* ======================================================================== */
void
test_ai_providers_find_cycles_through_many(void** state)
{
/* Add several providers sharing a prefix; cycling should visit each. */
ai_add_provider("alpha", "https://a.example/", NULL);
ai_add_provider("beta", "https://b.example/", NULL);
ai_add_provider("alphabet", "https://ab.example/", NULL);
ai_add_provider("alpine", "https://al.example/", NULL);
/* Three providers share prefix "alp": alpha, alphabet, alpine. */
GHashTable* seen = g_hash_table_new(g_str_hash, g_str_equal);
for (int i = 0; i < 3; i++) {
auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL);
assert_non_null(match);
/* Each call should return a result that starts with "alp". */
assert_true(g_str_has_prefix(match, "alp"));
g_hash_table_add(seen, g_strdup(match));
}
/* All three should have been seen (deterministic cycling). */
assert_int_equal(3, g_hash_table_size(seen));
g_hash_table_destroy(seen);
}
void
test_ai_providers_find_previous_walks_backwards(void** state)
{
ai_add_provider("alpha", "https://a/", NULL);
ai_add_provider("alphabet", "https://ab/", NULL);
ai_add_provider("alpine", "https://al/", NULL);
/* Forward then backward should yield matches with the shared prefix. */
auto_gchar gchar* forward = ai_providers_find("alp", FALSE, NULL);
auto_gchar gchar* back = ai_providers_find("alp", TRUE, NULL);
assert_non_null(forward);
assert_non_null(back);
assert_true(g_str_has_prefix(forward, "alp"));
assert_true(g_str_has_prefix(back, "alp"));
}
void
test_ai_providers_find_wraps_around(void** state)
{
ai_add_provider("alpha", "https://a/", NULL);
ai_add_provider("alphabet", "https://ab/", NULL);
/* Two matches; four forward calls should produce a repeat. */
auto_gchar gchar* r1 = ai_providers_find("alp", FALSE, NULL);
auto_gchar gchar* r2 = ai_providers_find("alp", FALSE, NULL);
auto_gchar gchar* r3 = ai_providers_find("alp", FALSE, NULL);
auto_gchar gchar* r4 = ai_providers_find("alp", FALSE, NULL);
assert_non_null(r1);
assert_non_null(r2);
assert_non_null(r3);
assert_non_null(r4);
/* The cycle of 2 must repeat: r1 == r3 and r2 == r4 (in some order). */
assert_int_equal(0, g_strcmp0(r1, r3));
assert_int_equal(0, g_strcmp0(r2, r4));
assert_int_not_equal(0, g_strcmp0(r1, r2));
}
/* ========================================================================
* Session edge cases
* ======================================================================== */
void
test_ai_session_add_message_null_session_no_crash(void** state)
{
/* Should silently no-op rather than dereference NULL. */
ai_session_add_message(NULL, "user", "hello");
}
void
test_ai_session_add_message_null_role_no_crash(void** state)
{
AISession* s = ai_session_create("openai", "gpt-4");
assert_non_null(s);
ai_session_add_message(s, NULL, "content");
assert_int_equal(0, g_list_length(s->history));
ai_session_unref(s);
}
void
test_ai_session_add_message_null_content_no_crash(void** state)
{
AISession* s = ai_session_create("openai", "gpt-4");
assert_non_null(s);
ai_session_add_message(s, "user", NULL);
assert_int_equal(0, g_list_length(s->history));
ai_session_unref(s);
}
void
test_ai_session_history_preserves_large_order(void** state)
{
AISession* s = ai_session_create("openai", "gpt-4");
assert_non_null(s);
for (int i = 0; i < 100; i++) {
auto_gchar gchar* content = g_strdup_printf("msg-%d", i);
ai_session_add_message(s, (i % 2 == 0) ? "user" : "assistant", content);
}
assert_int_equal(100, g_list_length(s->history));
GList* curr = s->history;
for (int i = 0; i < 100 && curr; i++, curr = g_list_next(curr)) {
AIMessage* msg = curr->data;
auto_gchar gchar* expected = g_strdup_printf("msg-%d", i);
assert_string_equal(expected, msg->content);
assert_string_equal((i % 2 == 0) ? "user" : "assistant", msg->role);
}
ai_session_unref(s);
}
void
test_ai_session_clear_empty_history(void** state)
{
AISession* s = ai_session_create("openai", "gpt-4");
assert_non_null(s);
ai_session_clear_history(s);
assert_int_equal(0, g_list_length(s->history));
ai_session_unref(s);
}
void
test_ai_session_set_model_null_keeps_old(void** state)
{
AISession* s = ai_session_create("openai", "gpt-4");
assert_non_null(s);
ai_session_set_model(s, NULL);
assert_string_equal("gpt-4", s->model);
ai_session_unref(s);
}
void
test_ai_session_unref_null_no_crash(void** state)
{
ai_session_unref(NULL);
}
void
test_ai_session_ref_null_returns_null(void** state)
{
assert_null(ai_session_ref(NULL));
}
/* ========================================================================
* Provider edge cases
* ======================================================================== */
void
test_ai_get_provider_null_returns_null(void** state)
{
assert_null(ai_get_provider(NULL));
}
void
test_ai_remove_provider_null_returns_false(void** state)
{
assert_false(ai_remove_provider(NULL));
}
void
test_ai_remove_provider_twice_second_fails(void** state)
{
ai_add_provider("once", "https://once/", NULL);
assert_true(ai_remove_provider("once"));
assert_false(ai_remove_provider("once"));
}
void
test_ai_provider_survives_via_session_after_removal(void** state)
{
ai_add_provider("temp_prov", "https://temp/", NULL);
ai_set_provider_key("temp_prov", "test-key");
AISession* s = ai_session_create("temp_prov", "model-x");
assert_non_null(s);
/* Remove from registry while session holds a ref. */
assert_true(ai_remove_provider("temp_prov"));
assert_null(ai_get_provider("temp_prov"));
/* Session's provider pointer must still be valid. */
assert_non_null(s->provider);
assert_string_equal("temp_prov", s->provider->name);
assert_string_equal("https://temp/", s->provider->api_url);
ai_session_unref(s);
}
/* ========================================================================
* Settings edge cases
* ======================================================================== */
void
test_ai_settings_multiple_keys_independent(void** state)
{
ai_set_provider_setting("openai", "tools", "enabled");
ai_set_provider_setting("openai", "search", "disabled");
auto_gchar gchar* tools = ai_get_provider_setting("openai", "tools");
auto_gchar gchar* search = ai_get_provider_setting("openai", "search");
assert_string_equal("enabled", tools);
assert_string_equal("disabled", search);
}
void
test_ai_settings_get_missing_returns_null(void** state)
{
gchar* nope = ai_get_provider_setting("openai", "not_set");
assert_null(nope);
}
void
test_ai_settings_isolated_between_providers(void** state)
{
ai_set_provider_setting("openai", "tools", "yes");
ai_set_provider_setting("perplexity", "tools", "no");
auto_gchar gchar* a = ai_get_provider_setting("openai", "tools");
auto_gchar gchar* b = ai_get_provider_setting("perplexity", "tools");
assert_string_equal("yes", a);
assert_string_equal("no", b);
}
/* ========================================================================
* Model parsing edge cases
* ======================================================================== */
void
test_ai_parse_models_data_not_array(void** state)
{
AIProvider* p = ai_add_provider("p1", "https://p/", NULL);
assert_non_null(p);
/* "data" present but not an array — parser should bail without crashing. */
const gchar* json = "{\"data\":\"not-an-array\"}";
ai_parse_models_from_json(p, json);
assert_int_equal(0, g_list_length(p->models));
}
void
test_ai_parse_models_empty_data_array(void** state)
{
AIProvider* p = ai_add_provider("p1", "https://p/", NULL);
assert_non_null(p);
const gchar* json = "{\"object\":\"list\",\"data\":[]}";
ai_parse_models_from_json(p, json);
assert_int_equal(0, g_list_length(p->models));
}
void
test_ai_parse_models_id_outside_data_ignored(void** state)
{
AIProvider* p = ai_add_provider("p1", "https://p/", NULL);
assert_non_null(p);
/* "id" outside a data-array object must not be picked up. */
const gchar* json = "{\"request_id\":\"req-123\",\"data\":[]}";
ai_parse_models_from_json(p, json);
assert_int_equal(0, g_list_length(p->models));
}
void
test_ai_parse_models_multiple_models(void** state)
{
AIProvider* p = ai_add_provider("p1", "https://p/", NULL);
assert_non_null(p);
const gchar* json = "{\"object\":\"list\",\"data\":["
"{\"id\":\"m-1\",\"object\":\"model\"},"
"{\"id\":\"m-2\",\"object\":\"model\"},"
"{\"id\":\"m-3\",\"object\":\"model\"}"
"]}";
ai_parse_models_from_json(p, json);
assert_int_equal(3, g_list_length(p->models));
}
/* ========================================================================
* Prefs round-trip tests
*
* These use ai_client_setup_with_prefs which initializes prefs first.
* ai_set_provider_key writes through to prefs_ai_set_token which then
* calls _save_prefs(); a subsequent shutdown+init should reload the key.
* ======================================================================== */
void
test_ai_prefs_round_trip_api_key(void** state)
{
ai_set_provider_key("openai", "sk-persisted-123");
{
auto_gchar gchar* before = ai_get_provider_key("openai");
assert_non_null(before);
assert_string_equal("sk-persisted-123", before);
}
/* Cycle the AI client; prefs stay loaded across this. */
ai_client_shutdown();
ai_client_init();
auto_gchar gchar* after = ai_get_provider_key("openai");
assert_non_null(after);
assert_string_equal("sk-persisted-123", after);
}
void
test_ai_prefs_round_trip_remove_key(void** state)
{
ai_set_provider_key("openai", "sk-to-be-removed");
ai_set_provider_key("openai", NULL);
ai_client_shutdown();
ai_client_init();
auto_gchar gchar* after = ai_get_provider_key("openai");
assert_null(after);
}
void
test_ai_prefs_multiple_providers_persist(void** state)
{
ai_set_provider_key("openai", "sk-openai-key");
ai_set_provider_key("perplexity", "pplx-key");
ai_client_shutdown();
ai_client_init();
auto_gchar gchar* k1 = ai_get_provider_key("openai");
auto_gchar gchar* k2 = ai_get_provider_key("perplexity");
assert_non_null(k1);
assert_non_null(k2);
assert_string_equal("sk-openai-key", k1);
assert_string_equal("pplx-key", k2);
}

View File

@@ -60,75 +60,3 @@ void test_ai_start_provider_autocomplete_only_on_exact(void** state);
void test_ai_models_find_null_session(void** state);
void test_ai_models_find_null_provider(void** state);
void test_ai_providers_find_cycling(void** state);
/* Setup that also initializes prefs for round-trip persistence tests. */
int ai_client_setup_with_prefs(void** state);
int ai_client_teardown_with_prefs(void** state);
/* Chat response parser tests (ai_parse_response) */
void test_ai_parse_response_openai_content(void** state);
void test_ai_parse_response_perplexity_text(void** state);
void test_ai_parse_response_text_preferred_over_content(void** state);
void test_ai_parse_response_escaped_quote(void** state);
void test_ai_parse_response_newline_escape(void** state);
void test_ai_parse_response_empty_content(void** state);
void test_ai_parse_response_missing_field(void** state);
void test_ai_parse_response_null_input(void** state);
void test_ai_parse_response_empty_input(void** state);
void test_ai_parse_response_percent_signs_safe(void** state);
void test_ai_parse_response_braces_in_content(void** state);
void test_ai_parse_response_multiline_content(void** state);
/* Error response parser tests (ai_parse_error_message) */
void test_ai_parse_error_standard_envelope(void** state);
void test_ai_parse_error_with_escapes(void** state);
void test_ai_parse_error_no_error_field(void** state);
void test_ai_parse_error_no_message_field(void** state);
void test_ai_parse_error_null_input(void** state);
void test_ai_parse_error_empty_input(void** state);
void test_ai_parse_error_tab_escape(void** state);
void test_ai_parse_error_backslash_escape(void** state);
/* Extended JSON escape tests */
void test_ai_json_escape_backspace(void** state);
void test_ai_json_escape_formfeed(void** state);
void test_ai_json_escape_carriage_return(void** state);
void test_ai_json_escape_all_specials_combined(void** state);
void test_ai_json_escape_passes_utf8_through(void** state);
/* Autocomplete cycling with many providers */
void test_ai_providers_find_cycles_through_many(void** state);
void test_ai_providers_find_previous_walks_backwards(void** state);
void test_ai_providers_find_wraps_around(void** state);
/* Session edge cases */
void test_ai_session_add_message_null_session_no_crash(void** state);
void test_ai_session_add_message_null_role_no_crash(void** state);
void test_ai_session_add_message_null_content_no_crash(void** state);
void test_ai_session_history_preserves_large_order(void** state);
void test_ai_session_clear_empty_history(void** state);
void test_ai_session_set_model_null_keeps_old(void** state);
void test_ai_session_unref_null_no_crash(void** state);
void test_ai_session_ref_null_returns_null(void** state);
/* Provider edge cases */
void test_ai_get_provider_null_returns_null(void** state);
void test_ai_remove_provider_null_returns_false(void** state);
void test_ai_remove_provider_twice_second_fails(void** state);
void test_ai_provider_survives_via_session_after_removal(void** state);
/* Settings edge cases */
void test_ai_settings_multiple_keys_independent(void** state);
void test_ai_settings_get_missing_returns_null(void** state);
void test_ai_settings_isolated_between_providers(void** state);
/* Model parsing edge cases */
void test_ai_parse_models_data_not_array(void** state);
void test_ai_parse_models_empty_data_array(void** state);
void test_ai_parse_models_id_outside_data_ignored(void** state);
void test_ai_parse_models_multiple_models(void** state);
/* Prefs round-trip tests */
void test_ai_prefs_round_trip_api_key(void** state);
void test_ai_prefs_round_trip_remove_key(void** state);
void test_ai_prefs_multiple_providers_persist(void** state);

View File

@@ -1433,3 +1433,8 @@ cons_has_alerts(void)
{
return FALSE;
}
void
win_move_to_end(ProfWin* window)
{
}

View File

@@ -716,65 +716,6 @@ main(int argc, char* argv[])
cmocka_unit_test(test_ai_json_escape_special_chars),
cmocka_unit_test(test_ai_json_escape_percent_signs),
cmocka_unit_test(test_ai_json_escape_backslash_quote),
/* Chat response parser */
cmocka_unit_test(test_ai_parse_response_openai_content),
cmocka_unit_test(test_ai_parse_response_perplexity_text),
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
cmocka_unit_test(test_ai_parse_response_escaped_quote),
cmocka_unit_test(test_ai_parse_response_newline_escape),
cmocka_unit_test(test_ai_parse_response_empty_content),
cmocka_unit_test(test_ai_parse_response_missing_field),
cmocka_unit_test(test_ai_parse_response_null_input),
cmocka_unit_test(test_ai_parse_response_empty_input),
cmocka_unit_test(test_ai_parse_response_percent_signs_safe),
cmocka_unit_test(test_ai_parse_response_braces_in_content),
cmocka_unit_test(test_ai_parse_response_multiline_content),
/* Error response parser */
cmocka_unit_test(test_ai_parse_error_standard_envelope),
cmocka_unit_test(test_ai_parse_error_with_escapes),
cmocka_unit_test(test_ai_parse_error_no_error_field),
cmocka_unit_test(test_ai_parse_error_no_message_field),
cmocka_unit_test(test_ai_parse_error_null_input),
cmocka_unit_test(test_ai_parse_error_empty_input),
cmocka_unit_test(test_ai_parse_error_tab_escape),
cmocka_unit_test(test_ai_parse_error_backslash_escape),
/* Extended JSON escape coverage */
cmocka_unit_test(test_ai_json_escape_backspace),
cmocka_unit_test(test_ai_json_escape_formfeed),
cmocka_unit_test(test_ai_json_escape_carriage_return),
cmocka_unit_test(test_ai_json_escape_all_specials_combined),
cmocka_unit_test(test_ai_json_escape_passes_utf8_through),
/* Autocomplete cycling */
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycles_through_many, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous_walks_backwards, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_wraps_around, ai_client_setup, ai_client_teardown),
/* Session edge cases */
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_session_no_crash, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_role_no_crash, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_content_no_crash, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_history_preserves_large_order, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_clear_empty_history, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_set_model_null_keeps_old, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_unref_null_no_crash, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_ref_null_returns_null, ai_client_setup, ai_client_teardown),
/* Provider edge cases */
cmocka_unit_test_setup_teardown(test_ai_get_provider_null_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_remove_provider_null_returns_false, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_remove_provider_twice_second_fails, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_provider_survives_via_session_after_removal, ai_client_setup, ai_client_teardown),
/* Settings */
cmocka_unit_test_setup_teardown(test_ai_settings_multiple_keys_independent, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_settings_get_missing_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_settings_isolated_between_providers, ai_client_setup, ai_client_teardown),
/* Model parsing edge cases */
cmocka_unit_test_setup_teardown(test_ai_parse_models_data_not_array, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_parse_models_empty_data_array, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_parse_models_id_outside_data_ignored, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_parse_models_multiple_models, ai_client_setup, ai_client_teardown),
/* Prefs round-trip (uses prefs+ai setup) */
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
// Flatfile export/import round-trip
cmocka_unit_test(test_ff_roundtrip_simple_chat),
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),