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:
- Console only (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.
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.
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
- 15/15 AI functional tests pass (Group 5)
This commit is contained in:
@@ -203,6 +203,7 @@ 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/functionaltests.c
|
||||
|
||||
main_source = src/main.c
|
||||
|
||||
@@ -1250,8 +1250,8 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
|
||||
return json_payload;
|
||||
}
|
||||
|
||||
static gchar*
|
||||
_parse_ai_response(const gchar* response_json)
|
||||
gchar*
|
||||
ai_parse_response(const gchar* response_json)
|
||||
{
|
||||
if (!response_json || strlen(response_json) == 0)
|
||||
return NULL;
|
||||
@@ -1337,8 +1337,8 @@ _parse_ai_response(const gchar* response_json)
|
||||
* Extract error message from an API error JSON response.
|
||||
* Handles format: {"error":{"message":"...","type":"...","code":...}}
|
||||
*/
|
||||
static gchar*
|
||||
_parse_error_response(const gchar* error_json)
|
||||
gchar*
|
||||
ai_parse_error_message(const gchar* error_json)
|
||||
{
|
||||
if (!error_json || strlen(error_json) == 0)
|
||||
return NULL;
|
||||
@@ -1496,7 +1496,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 = _parse_error_response(response.data);
|
||||
auto_gchar gchar* parsed_error = ai_parse_error_message(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", local_provider_name, local_model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
@@ -1505,7 +1505,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 = _parse_ai_response(response_data);
|
||||
auto_gchar gchar* content = ai_parse_response(response_data);
|
||||
if (content) {
|
||||
/* Add assistant response to history (under lock) */
|
||||
pthread_mutex_lock(&session->lock);
|
||||
|
||||
@@ -181,7 +181,7 @@ gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data);
|
||||
gboolean ai_models_are_fresh(const gchar* provider_name);
|
||||
|
||||
/* ========================================================================
|
||||
* Model Parsing (for testing)
|
||||
* Parsing helpers (exposed for testing)
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
@@ -192,6 +192,24 @@ 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
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
#ifdef HAVE_SQLITE
|
||||
#include "test_export_import.h"
|
||||
#endif
|
||||
#include "test_ai.h"
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) \
|
||||
@@ -66,6 +67,14 @@
|
||||
#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
|
||||
@@ -84,9 +93,9 @@ main(int argc, char* argv[])
|
||||
int group = 0; /* 0 = all groups */
|
||||
if (argc > 1) {
|
||||
group = atoi(argv[1]);
|
||||
if (group < 1 || group > 4) {
|
||||
if (group < 1 || group > 5) {
|
||||
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
|
||||
fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n");
|
||||
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -307,6 +316,32 @@ 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[] = {
|
||||
/* Console-only — 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),
|
||||
};
|
||||
|
||||
/* Test group registry for easy extension */
|
||||
struct
|
||||
{
|
||||
@@ -318,6 +353,7 @@ 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 (console only)", group5_tests, ARRAY_SIZE(group5_tests) },
|
||||
};
|
||||
const int num_groups = ARRAY_SIZE(groups);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
#include "proftest.h"
|
||||
|
||||
/* Number of parallel test groups for CI builds */
|
||||
#define TEST_GROUPS 4
|
||||
#define TEST_GROUPS 5
|
||||
|
||||
/* Number of ports reserved per test group; allows skipping TIME_WAIT ports */
|
||||
#define PORTS_PER_GROUP 50
|
||||
|
||||
241
tests/functionaltests/test_ai.c
Normal file
241
tests/functionaltests/test_ai.c
Normal file
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
28
tests/functionaltests/test_ai.h
Normal file
28
tests/functionaltests/test_ai.h
Normal file
@@ -0,0 +1,28 @@
|
||||
#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
|
||||
@@ -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
|
||||
@@ -863,3 +866,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user