test(ai): expand unit and functional coverage for /ai feature

Unit tests (tests/unittests/test_ai_client.c): +50 cases on top of the
existing 50 — total 100. Covers:
  - chat response parser (ai_parse_response): OpenAI content + Perplexity
    text formats, escape decoding, empty/null/missing inputs, format-string
    safety, multiline content
  - error envelope parser (ai_parse_error_message): standard envelope,
    nested escapes, missing fields, null/empty
  - extended JSON escape: \b, \f, \r, all-specials, UTF-8 pass-through
  - provider autocomplete cycling with >=2 matches and wrap-around
  - session edge cases: NULL args, 100-message order preservation,
    set_model(NULL), ref/unref(NULL)
  - provider edge cases: get/remove with NULL, double-remove, survival
    via session ref after ai_remove_provider
  - settings: multi-key independence, missing key, cross-provider
    isolation
  - model parsing edges: data not array, empty data, "id" outside data,
    multiple models
  - prefs round-trip: set token -> shutdown -> init -> token reloaded
    from disk (uses load_preferences fixture)

Functional tests:
  - Tier A (test_ai.c): 15 cases for the /ai command surface that don't
    need HTTP. Covers /ai help, providers list, set provider/token,
    start with/without key/unknown provider, clear, remove, default
    provider/model, switch without window, bad subcommand.
  - Tier B (test_ai_http.c + ai_http_stub.{c,h,py}): 5 cases that
    exercise the libcurl path against a local Python HTTP stub. The
    stub serves canned bodies in five modes (ok, openai, 401, 500,
    models) so each chat/models/error path is hit end-to-end without
    network.

Infrastructure:
  - TEST_GROUPS bumped to 5 in proftest.c; AI tests live in their own
    Group 5 because mixing them with stabber-driven tests in Group 4
    poisoned stbbr_stop() teardown.
  - PROF_FUNC_TEST_AI macro in functionaltests.c registers AI tests
    with ai_init_test() which wraps init_prof_test with a prof_connect()
    so stabber sees a graceful disconnect at teardown.
  - dist_check_DATA ships ai_http_stub.py with the source tarball.

Source-level changes to enable testing:
  - src/ai/ai_client.{c,h}: dropped 'static' from _parse_ai_response
    (renamed ai_parse_response) and _parse_error_response (renamed
    ai_parse_error_message); declared under a "Parsing helpers (exposed
    for testing)" section. Same approach as ai_parse_models_from_json.

Results in Docker (cproof-debian image):
  - 595/595 unit tests pass
  - 20/20 AI functional tests pass (Group 5)
This commit is contained in:
2026-05-11 16:21:45 +03:00
parent 010b2062a4
commit e67ae4e7a5
15 changed files with 1484 additions and 10 deletions

View File

@@ -1,7 +1,10 @@
#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
@@ -867,3 +870,598 @@ 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,3 +60,75 @@ 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

@@ -716,6 +716,65 @@ 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),