All checks were successful
CI Code / Check spelling (push) Successful in 18s
CI Code / Check coding style (push) Successful in 29s
CI Code / Code Coverage (push) Successful in 2m44s
CI Code / Linux (debian) (push) Successful in 4m46s
CI Code / Linux (ubuntu) (push) Successful in 4m59s
CI Code / Linux (arch) (push) Successful in 5m56s
Add an AI client module that integrates with OpenAI-compatible API providers (OpenAI, Perplexity, and custom endpoints) to provide AI-assisted chat within CProof. Users can start sessions with /ai start, send prompts, receive responses in a dedicated AI window, switch between providers and models, and manage API keys — all with tab-completion. Providers are configured via /ai set commands with per-provider API keys, endpoints, default models, and custom settings. Two default providers (openai, perplexity) are seeded on first use. Provider state persists in [ai/<name>] sections of the preferences keyfile with automatic migration from the previous flat-key format. The /ai command integrates into the existing command system with 8 subcommands covering provider management, session lifecycle, model fetching, and conversation clearing. Autocomplete uses the standard flat prefix-matching chain for reliable tab-completion at every nesting level. A new ProfAiWin window type is added to the window system. Architecture: Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s) Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM JSON parsing uses unified helpers for both chat responses and error envelopes with consistent escape decoding. The response parser tries Perplexity /v1/responses "text" field first, then falls back to OpenAI "content". Error parsing extracts provider error.message from the standard envelope format. Model parsing handles multiple API response formats (OpenAI list, Perplexity, array) including edge cases. Tests include 470+ lines of unit tests covering provider management, session lifecycle, JSON parsing (multiple formats), autocomplete cycling, and error handling, plus functional tests for /ai command dispatch. A stub_ai.c module isolates unit tests from UI dependencies.
1609 lines
53 KiB
C
1609 lines
53 KiB
C
#include "glib.h"
|
|
#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
|
|
* ======================================================================== */
|
|
|
|
int
|
|
ai_client_setup(void** state)
|
|
{
|
|
ai_client_init();
|
|
return 0;
|
|
}
|
|
|
|
int
|
|
ai_client_teardown(void** state)
|
|
{
|
|
ai_client_shutdown();
|
|
return 0;
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Provider Management Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_client_init(void** state)
|
|
{
|
|
/* After init, at least one default provider must exist with a non-empty
|
|
* URL. We intentionally don't pin specific names or URLs — the source
|
|
* of truth lives in preferences.c (prefs_ai_get_providers) and the set
|
|
* of defaults is free to grow, shrink, or be renamed without breaking
|
|
* tests of unrelated functionality. */
|
|
GList* providers = ai_list_providers();
|
|
assert_non_null(providers);
|
|
assert_true(g_list_length(providers) >= 1);
|
|
|
|
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
|
|
AIProvider* p = curr->data;
|
|
assert_non_null(p);
|
|
assert_non_null(p->name);
|
|
assert_true(strlen(p->name) > 0);
|
|
assert_non_null(p->api_url);
|
|
assert_true(strlen(p->api_url) > 0);
|
|
}
|
|
|
|
g_list_free(providers);
|
|
}
|
|
|
|
void
|
|
test_ai_add_provider(void** state)
|
|
{
|
|
/* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */
|
|
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1");
|
|
assert_non_null(provider);
|
|
assert_string_equal("custom", provider->name);
|
|
assert_string_equal("https://custom.api.com/v1", provider->api_url);
|
|
|
|
/* Update existing provider (returns ref; caller owns it) */
|
|
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1");
|
|
assert_non_null(updated);
|
|
assert_string_equal("https://new.api.com/v1", updated->api_url);
|
|
|
|
ai_provider_unref(updated);
|
|
}
|
|
|
|
void
|
|
test_ai_remove_provider(void** state)
|
|
{
|
|
/* Remove default provider should fail */
|
|
assert_false(ai_remove_provider("nonexistent"));
|
|
|
|
/* Add and remove custom provider */
|
|
ai_add_provider("temp", "https://temp.api.com/v1");
|
|
assert_true(ai_remove_provider("temp"));
|
|
assert_null(ai_get_provider("temp"));
|
|
}
|
|
|
|
void
|
|
test_ai_list_providers(void** state)
|
|
{
|
|
/* Count whatever defaults init produced — we only assert the *delta*
|
|
* after ai_add_provider, not the absolute number. Lets us survive any
|
|
* future change to how many built-in providers ship. */
|
|
GList* before = ai_list_providers();
|
|
gint baseline = before ? (gint)g_list_length(before) : 0;
|
|
g_list_free(before);
|
|
|
|
ai_add_provider("test_list_extra", "https://example.test/extra/");
|
|
GList* after = ai_list_providers();
|
|
assert_int_equal(baseline + 1, g_list_length(after));
|
|
g_list_free(after);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* API Key Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_set_provider_key(void** state)
|
|
{
|
|
ai_add_provider("p_setkey", "https://example.test/setkey/");
|
|
|
|
ai_set_provider_key("p_setkey", "sk-test-key-123");
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("p_setkey");
|
|
assert_non_null(key);
|
|
assert_string_equal("sk-test-key-123", key);
|
|
}
|
|
|
|
/* Update key */
|
|
ai_set_provider_key("p_setkey", "sk-new-key-456");
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("p_setkey");
|
|
assert_string_equal("sk-new-key-456", key);
|
|
}
|
|
|
|
/* Remove key */
|
|
ai_set_provider_key("p_setkey", NULL);
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("p_setkey");
|
|
assert_null(key);
|
|
}
|
|
}
|
|
|
|
void
|
|
test_ai_get_provider_key(void** state)
|
|
{
|
|
ai_add_provider("p_getkey_a", "https://example.test/getkey-a/");
|
|
ai_add_provider("p_getkey_b", "https://example.test/getkey-b/");
|
|
|
|
/* No key set initially */
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("p_getkey_a");
|
|
assert_null(key);
|
|
}
|
|
|
|
/* Set and get key on one provider */
|
|
ai_set_provider_key("p_getkey_b", "pplx-abc123");
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("p_getkey_b");
|
|
assert_non_null(key);
|
|
assert_string_equal("pplx-abc123", key);
|
|
}
|
|
|
|
/* Other provider still has no key */
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("p_getkey_a");
|
|
assert_null(key);
|
|
}
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Session Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_session_create(void** state)
|
|
{
|
|
ai_add_provider("p_sess_create", "https://example.test/sess/");
|
|
|
|
AISession* session = ai_session_create("p_sess_create", "model-a");
|
|
assert_non_null(session);
|
|
assert_string_equal("p_sess_create", session->provider_name);
|
|
assert_string_equal("model-a", session->model);
|
|
assert_null(session->api_key); /* No key set */
|
|
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
void
|
|
test_ai_session_ref_unref(void** state)
|
|
{
|
|
ai_add_provider("p_sess_ref", "https://example.test/ref/");
|
|
|
|
AISession* session = ai_session_create("p_sess_ref", "model-a");
|
|
assert_non_null(session);
|
|
|
|
/* Reference */
|
|
AISession* ref = ai_session_ref(session);
|
|
assert_true(ref == session);
|
|
|
|
/* Unreference twice */
|
|
ai_session_unref(session);
|
|
ai_session_unref(ref); /* Should free here */
|
|
}
|
|
|
|
void
|
|
test_ai_session_add_message(void** state)
|
|
{
|
|
ai_add_provider("p_sess_add", "https://example.test/add/");
|
|
|
|
AISession* session = ai_session_create("p_sess_add", "model-a");
|
|
assert_non_null(session);
|
|
|
|
ai_session_add_message(session, "user", "Hello");
|
|
ai_session_add_message(session, "assistant", "Hi there!");
|
|
|
|
assert_int_equal(2, g_list_length(session->history));
|
|
|
|
AIMessage* first = session->history->data;
|
|
assert_string_equal("user", first->role);
|
|
assert_string_equal("Hello", first->content);
|
|
|
|
AIMessage* second = g_list_next(session->history)->data;
|
|
assert_string_equal("assistant", second->role);
|
|
assert_string_equal("Hi there!", second->content);
|
|
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
void
|
|
test_ai_session_clear_history(void** state)
|
|
{
|
|
ai_add_provider("p_sess_clear", "https://example.test/clear/");
|
|
|
|
AISession* session = ai_session_create("p_sess_clear", "model-a");
|
|
|
|
ai_session_add_message(session, "user", "Message 1");
|
|
ai_session_add_message(session, "user", "Message 2");
|
|
ai_session_add_message(session, "assistant", "Response");
|
|
|
|
assert_int_equal(3, g_list_length(session->history));
|
|
|
|
ai_session_clear_history(session);
|
|
assert_int_equal(0, g_list_length(session->history));
|
|
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
void
|
|
test_ai_session_set_model(void** state)
|
|
{
|
|
ai_add_provider("p_sess_setmodel", "https://example.test/setmodel/");
|
|
|
|
AISession* session = ai_session_create("p_sess_setmodel", "model-a");
|
|
assert_string_equal("model-a", session->model);
|
|
|
|
ai_session_set_model(session, "model-b");
|
|
assert_string_equal("model-b", session->model);
|
|
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* JSON Escape Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_json_escape(void** state)
|
|
{
|
|
gchar* escaped = ai_json_escape("hello \"world\"");
|
|
assert_string_equal("hello \\\"world\\\"", escaped);
|
|
g_free(escaped);
|
|
}
|
|
|
|
void
|
|
test_ai_json_escape_null(void** state)
|
|
{
|
|
gchar* escaped = ai_json_escape(NULL);
|
|
assert_string_equal("", escaped);
|
|
g_free(escaped);
|
|
}
|
|
|
|
void
|
|
test_ai_json_escape_empty(void** state)
|
|
{
|
|
gchar* escaped = ai_json_escape("");
|
|
assert_string_equal("", escaped);
|
|
g_free(escaped);
|
|
}
|
|
|
|
void
|
|
test_ai_json_escape_special_chars(void** state)
|
|
{
|
|
gchar* escaped = ai_json_escape("line1\nline2\ttab\\backslash\"quote");
|
|
assert_string_equal("line1\\nline2\\ttab\\\\backslash\\\"quote", escaped);
|
|
g_free(escaped);
|
|
}
|
|
|
|
void
|
|
test_ai_json_escape_percent_signs(void** state)
|
|
{
|
|
/* Critical: % characters in content must be escaped for JSON, not treated as format specifiers */
|
|
gchar* escaped = ai_json_escape("100% complete with %s and %d format strings");
|
|
assert_string_equal("100% complete with %s and %d format strings", escaped);
|
|
g_free(escaped);
|
|
}
|
|
|
|
void
|
|
test_ai_json_escape_backslash_quote(void** state)
|
|
{
|
|
/* Test escaped quote handling */
|
|
gchar* escaped = ai_json_escape("He said \"hello\" and \\ goodbye");
|
|
assert_string_equal("He said \\\"hello\\\" and \\\\ goodbye", escaped);
|
|
g_free(escaped);
|
|
}
|
|
|
|
void
|
|
test_ai_session_api_key_is_copied(void** state)
|
|
{
|
|
/* Verify that session owns its own copy of the API key */
|
|
ai_add_provider("p_keycopy", "https://example.test/keycopy/");
|
|
ai_set_provider_key("p_keycopy", "sk-test-key-123");
|
|
|
|
AISession* session = ai_session_create("p_keycopy", "model-a");
|
|
assert_non_null(session);
|
|
assert_string_equal("sk-test-key-123", session->api_key);
|
|
|
|
/* Remove the provider key - session should still have its copy */
|
|
ai_set_provider_key("p_keycopy", NULL);
|
|
assert_non_null(session->api_key);
|
|
assert_string_equal("sk-test-key-123", session->api_key);
|
|
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
void
|
|
test_ai_add_provider_update_existing(void** state)
|
|
{
|
|
/* Add a provider (hash table owns ref) */
|
|
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1");
|
|
assert_non_null(provider);
|
|
assert_string_equal("https://first.api.com/v1", provider->api_url);
|
|
|
|
/* Update the same provider (returns ref) */
|
|
provider = ai_add_provider("custom", "https://second.api.com/v1");
|
|
assert_non_null(provider);
|
|
assert_string_equal("https://second.api.com/v1", provider->api_url);
|
|
ai_provider_unref(provider);
|
|
}
|
|
|
|
void
|
|
test_ai_add_provider_null_name_returns_null(void** state)
|
|
{
|
|
assert_null(ai_add_provider(NULL, "https://api.com/v1"));
|
|
}
|
|
|
|
void
|
|
test_ai_add_provider_null_url_returns_null(void** state)
|
|
{
|
|
assert_null(ai_add_provider("test", NULL));
|
|
}
|
|
|
|
void
|
|
test_ai_session_create_null_provider_returns_null(void** state)
|
|
{
|
|
assert_null(ai_session_create("nonexistent", "gpt-4"));
|
|
}
|
|
|
|
void
|
|
test_ai_session_create_null_model_returns_null(void** state)
|
|
{
|
|
ai_add_provider("p_create_nullmodel", "https://example.test/nm/");
|
|
assert_null(ai_session_create("p_create_nullmodel", NULL));
|
|
}
|
|
|
|
void
|
|
test_ai_session_api_key_null_when_no_key_set(void** state)
|
|
{
|
|
/* Provider without a stored key — session->api_key must be NULL. */
|
|
ai_add_provider("p_nokey", "https://example.test/nokey/");
|
|
AISession* session = ai_session_create("p_nokey", "model-a");
|
|
assert_non_null(session);
|
|
assert_null(session->api_key);
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Provider Autocomplete Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_providers_find_forward(void** state)
|
|
{
|
|
/* Distinctive prefix that won't collide with any current or future
|
|
* default. Self-add provider so the test doesn't depend on which
|
|
* built-ins ship. */
|
|
ai_add_provider("zfind_one", "https://example.test/zf1/");
|
|
|
|
auto_gchar gchar* result = ai_providers_find("zfind", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("zfind_one", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_forward_custom(void** state)
|
|
{
|
|
ai_add_provider("zcustom", "https://example.test/zcustom/");
|
|
|
|
auto_gchar gchar* result = ai_providers_find("zcustom", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("zcustom", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_forward_no_match(void** state)
|
|
{
|
|
/* Prefix designed to match no provider — neither defaults nor any
|
|
* we ourselves added. */
|
|
auto_gchar gchar* result = ai_providers_find("__nope__", FALSE, NULL);
|
|
assert_null(result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_forward_partial_match(void** state)
|
|
{
|
|
ai_add_provider("zpartial_xyz", "https://example.test/zp/");
|
|
auto_gchar gchar* result = ai_providers_find("zpart", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("zpartial_xyz", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_next(void** state)
|
|
{
|
|
/* Two consecutive calls with the same prefix and only one matching
|
|
* provider must return that same provider both times. */
|
|
ai_add_provider("znext_one", "https://example.test/zn1/");
|
|
|
|
auto_gchar gchar* result1 = ai_providers_find("znext", FALSE, NULL);
|
|
assert_non_null(result1);
|
|
assert_string_equal("znext_one", result1);
|
|
|
|
auto_gchar gchar* result2 = ai_providers_find("znext", FALSE, NULL);
|
|
assert_non_null(result2);
|
|
assert_string_equal("znext_one", result2);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_previous(void** state)
|
|
{
|
|
/* With only one match the previous-direction call still returns it. */
|
|
ai_add_provider("zprev_one", "https://example.test/zp1/");
|
|
|
|
auto_gchar gchar* result1 = ai_providers_find("zprev", FALSE, NULL);
|
|
assert_non_null(result1);
|
|
assert_string_equal("zprev_one", result1);
|
|
|
|
auto_gchar gchar* result2 = ai_providers_find("zprev", TRUE, NULL);
|
|
assert_non_null(result2);
|
|
assert_string_equal("zprev_one", result2);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_empty_search_str(void** state)
|
|
{
|
|
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
|
|
assert_non_null(result);
|
|
}
|
|
|
|
/*
|
|
* ai_providers_find(NULL, ...) currently SIGSEGVs in libglib's
|
|
* __strlen_avx2: autocomplete_complete() stores g_strdup(NULL) == NULL
|
|
* in ac->search_str, and the downstream _search() forwards that NULL into
|
|
* g_str_to_ascii(NULL, NULL), which is undefined.
|
|
*
|
|
* The crash isn't reachable from production today (cmd_ac.c's
|
|
* _autocomplete_param_common always passes a non-NULL malloc'd substring),
|
|
* but the public ai_providers_find() signature accepts NULL and the test
|
|
* documents that it should not crash. The test is intentionally not
|
|
* registered in unittests.c while the NULL path remains a SIGSEGV — see
|
|
* AI_AUTOCOMPLETE_POSSIBLE_ISSUES.md, potential issue 1.
|
|
*/
|
|
void
|
|
test_ai_providers_find_null_search_str(void** state)
|
|
{
|
|
auto_gchar gchar* result = ai_providers_find(NULL, FALSE, NULL);
|
|
assert_non_null(result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_case_insensitive(void** state)
|
|
{
|
|
/* Matching is case-insensitive (via g_ascii_strdown). Use a self-added
|
|
* provider with a distinctive lowercased name. */
|
|
ai_add_provider("zcase_marker", "https://example.test/zcm/");
|
|
|
|
auto_gchar gchar* r1 = ai_providers_find("ZCASE_MARKER", FALSE, NULL);
|
|
assert_non_null(r1);
|
|
assert_string_equal("zcase_marker", r1);
|
|
|
|
auto_gchar gchar* r2 = ai_providers_find("Zcase_Marker", FALSE, NULL);
|
|
assert_non_null(r2);
|
|
assert_string_equal("zcase_marker", r2);
|
|
|
|
auto_gchar gchar* r3 = ai_providers_find("zcase_marker", FALSE, NULL);
|
|
assert_non_null(r3);
|
|
assert_string_equal("zcase_marker", r3);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* AI Autocomplete Integration Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_start_provider_autocomplete_only_on_exact(void** state)
|
|
{
|
|
/* Empty search should return *some* provider — exact name depends on
|
|
* which defaults shipped. We only care that the call doesn't crash and
|
|
* yields a non-NULL result. */
|
|
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
|
|
assert_non_null(result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_cycling(void** state)
|
|
{
|
|
/* Add two distinct providers; empty-prefix cycling should walk through
|
|
* both (defaults may also be present, but the assertion only cares that
|
|
* two consecutive calls return different providers). */
|
|
ai_add_provider("zcycle_a", "https://example.test/zca/");
|
|
ai_add_provider("zcycle_b", "https://example.test/zcb/");
|
|
|
|
auto_gchar gchar* result1 = ai_providers_find("", FALSE, NULL);
|
|
assert_non_null(result1);
|
|
|
|
auto_gchar gchar* result2 = ai_providers_find("", FALSE, NULL);
|
|
assert_non_null(result2);
|
|
|
|
assert_string_not_equal(result1, result2);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Provider Default Model and Settings Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_set_provider_default_model(void** state)
|
|
{
|
|
ai_add_provider("p_dm_set_a", "https://example.test/dma/");
|
|
ai_add_provider("p_dm_set_b", "https://example.test/dmb/");
|
|
|
|
ai_set_provider_default_model("p_dm_set_a", "model-1");
|
|
|
|
const gchar* model = ai_get_provider_default_model("p_dm_set_a");
|
|
assert_non_null(model);
|
|
assert_string_equal("model-1", model);
|
|
|
|
/* Update default model */
|
|
ai_set_provider_default_model("p_dm_set_a", "model-1-update");
|
|
model = ai_get_provider_default_model("p_dm_set_a");
|
|
assert_non_null(model);
|
|
assert_string_equal("model-1-update", model);
|
|
|
|
/* Independent default model on a different provider */
|
|
ai_set_provider_default_model("p_dm_set_b", "model-2");
|
|
model = ai_get_provider_default_model("p_dm_set_b");
|
|
assert_non_null(model);
|
|
assert_string_equal("model-2", model);
|
|
}
|
|
|
|
void
|
|
test_ai_get_provider_default_model(void** state)
|
|
{
|
|
/* NULL provider returns NULL */
|
|
assert_null(ai_get_provider_default_model(NULL));
|
|
|
|
/* Non-existent provider returns NULL */
|
|
assert_null(ai_get_provider_default_model("nonexistent"));
|
|
|
|
ai_add_provider("p_dm_get", "https://example.test/dmg/");
|
|
|
|
/* After setting, it returns the model */
|
|
ai_set_provider_default_model("p_dm_get", "test-model");
|
|
assert_string_equal("test-model", ai_get_provider_default_model("p_dm_get"));
|
|
|
|
/* NULL model argument is ignored (no change) */
|
|
ai_set_provider_default_model("p_dm_get", NULL);
|
|
assert_string_equal("test-model", ai_get_provider_default_model("p_dm_get"));
|
|
}
|
|
|
|
void
|
|
test_ai_set_provider_setting(void** state)
|
|
{
|
|
ai_add_provider("p_set_settings", "https://example.test/sset/");
|
|
|
|
/* NULL provider should be ignored */
|
|
ai_set_provider_setting(NULL, "tools", "enabled");
|
|
|
|
/* NULL setting should be ignored */
|
|
ai_set_provider_setting("p_set_settings", NULL, "enabled");
|
|
|
|
/* Non-existent provider should log warning but not crash */
|
|
ai_set_provider_setting("nonexistent", "tools", "enabled");
|
|
|
|
/* Set settings for our provider */
|
|
ai_set_provider_setting("p_set_settings", "tools", "enabled");
|
|
ai_set_provider_setting("p_set_settings", "search", "disabled");
|
|
|
|
/* Verify settings were set */
|
|
gchar* tools = ai_get_provider_setting("p_set_settings", "tools");
|
|
assert_non_null(tools);
|
|
assert_string_equal("enabled", tools);
|
|
g_free(tools);
|
|
|
|
gchar* search = ai_get_provider_setting("p_set_settings", "search");
|
|
assert_non_null(search);
|
|
assert_string_equal("disabled", search);
|
|
g_free(search);
|
|
|
|
/* NULL value removes the setting */
|
|
ai_set_provider_setting("p_set_settings", "tools", NULL);
|
|
tools = ai_get_provider_setting("p_set_settings", "tools");
|
|
assert_null(tools);
|
|
|
|
/* Setting that was never set returns NULL */
|
|
assert_null(ai_get_provider_setting("p_set_settings", "nonexistent_setting"));
|
|
}
|
|
|
|
void
|
|
test_ai_get_provider_setting(void** state)
|
|
{
|
|
ai_add_provider("p_get_settings_a", "https://example.test/gseta/");
|
|
ai_add_provider("p_get_settings_b", "https://example.test/gsetb/");
|
|
|
|
/* NULL provider returns NULL */
|
|
assert_null(ai_get_provider_setting(NULL, "tools"));
|
|
|
|
/* NULL setting returns NULL */
|
|
assert_null(ai_get_provider_setting("p_get_settings_a", NULL));
|
|
|
|
/* Non-existent provider returns NULL */
|
|
assert_null(ai_get_provider_setting("nonexistent", "tools"));
|
|
|
|
/* Provider with no settings configured returns NULL */
|
|
assert_null(ai_get_provider_setting("p_get_settings_b", "tools"));
|
|
|
|
/* Set and get setting */
|
|
ai_set_provider_setting("p_get_settings_a", "temperature", "0.7");
|
|
gchar* temp = ai_get_provider_setting("p_get_settings_a", "temperature");
|
|
assert_non_null(temp);
|
|
assert_string_equal("0.7", temp);
|
|
g_free(temp);
|
|
|
|
/* Setting value is a copy (caller must free) */
|
|
ai_set_provider_setting("p_get_settings_a", "max_tokens", "2048");
|
|
temp = ai_get_provider_setting("p_get_settings_a", "max_tokens");
|
|
assert_non_null(temp);
|
|
assert_string_equal("2048", temp);
|
|
g_free(temp);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Model Caching Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_models_are_fresh_initial(void** state)
|
|
{
|
|
/* NULL provider returns FALSE */
|
|
assert_false(ai_models_are_fresh(NULL));
|
|
|
|
/* Non-existent provider returns FALSE */
|
|
assert_false(ai_models_are_fresh("nonexistent"));
|
|
|
|
/* A freshly-added provider with no cached models should return FALSE
|
|
* (or at least not crash). */
|
|
ai_add_provider("p_fresh", "https://example.test/fresh/");
|
|
gboolean fresh = ai_models_are_fresh("p_fresh");
|
|
assert_true(fresh == TRUE || fresh == FALSE);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Model Parsing Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_parse_models_openai_format(void** state)
|
|
{
|
|
/* Test parsing OpenAI-compatible API response format */
|
|
/* Format: {"object":"list","data":[{"id":"model1",...},...]} */
|
|
ai_client_init();
|
|
|
|
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
|
|
|
/* JSON response from Perplexity/OpenAI API */
|
|
const gchar* json = "{\"object\":\"list\","
|
|
"\"data\":["
|
|
"{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
|
"{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
|
"{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}"
|
|
"]}";
|
|
|
|
ai_parse_models_from_json(provider, json);
|
|
|
|
/* Verify models were parsed correctly */
|
|
assert_int_equal(4, g_list_length(provider->models));
|
|
|
|
/* Check specific models */
|
|
GList* models = provider->models;
|
|
assert_string_equal("anthropic/claude-haiku-4-5", models->data);
|
|
models = g_list_next(models);
|
|
assert_string_equal("anthropic/claude-opus-4-5", models->data);
|
|
models = g_list_next(models);
|
|
assert_string_equal("openai/gpt-5", models->data);
|
|
models = g_list_next(models);
|
|
assert_string_equal("google/gemini-3-flash-preview", models->data);
|
|
|
|
ai_client_shutdown();
|
|
}
|
|
|
|
void
|
|
test_ai_parse_models_perplexity_format(void** state)
|
|
{
|
|
/* Test parsing actual Perplexity API response with all models from curl test */
|
|
ai_client_init();
|
|
|
|
AIProvider* provider = ai_add_provider("p_models_perplex_fmt", "https://example.test/pmpf/");
|
|
|
|
const gchar* json = "{\"object\":\"list\","
|
|
"\"data\":["
|
|
"{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
|
"{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
|
"{\"id\":\"anthropic/claude-opus-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
|
"{\"id\":\"anthropic/claude-opus-4-7\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
|
"{\"id\":\"anthropic/claude-sonnet-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
|
"{\"id\":\"anthropic/claude-sonnet-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
|
"{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"},"
|
|
"{\"id\":\"google/gemini-3.1-pro-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"},"
|
|
"{\"id\":\"nvidia/nemotron-3-super-120b-a12b\",\"object\":\"model\",\"created\":0,\"owned_by\":\"nvidia\"},"
|
|
"{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"openai/gpt-5-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"openai/gpt-5.1\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"openai/gpt-5.2\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"openai/gpt-5.4\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"openai/gpt-5.4-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"openai/gpt-5.4-nano\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"openai/gpt-5.5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
|
"{\"id\":\"perplexity/sonar\",\"object\":\"model\",\"created\":0,\"owned_by\":\"perplexity\"},"
|
|
"{\"id\":\"xai/grok-4-1-fast-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
|
"{\"id\":\"xai/grok-4.20-multi-agent\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
|
"{\"id\":\"xai/grok-4.20-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
|
"{\"id\":\"xai/grok-4.20-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
|
"{\"id\":\"xai/grok-4.3\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}"
|
|
"]}";
|
|
|
|
ai_parse_models_from_json(provider, json);
|
|
|
|
/* Verify all 23 models were parsed correctly */
|
|
assert_int_equal(23, g_list_length(provider->models));
|
|
|
|
/* Verify NO field names or owned_by values are in the model list */
|
|
/* The bug was that "id", "object", "model", "created", "owned_by" and provider names
|
|
* like "anthropic", "google", "openai", "nvidia", "perplexity", "xai" were extracted */
|
|
GList* models = provider->models;
|
|
while (models) {
|
|
const gchar* model = (const gchar*)models->data;
|
|
/* These should never be model IDs */
|
|
assert_false(g_strcmp0(model, "id") == 0);
|
|
assert_false(g_strcmp0(model, "object") == 0);
|
|
assert_false(g_strcmp0(model, "model") == 0);
|
|
assert_false(g_strcmp0(model, "created") == 0);
|
|
assert_false(g_strcmp0(model, "owned_by") == 0);
|
|
assert_false(g_strcmp0(model, "list") == 0);
|
|
/* These are owned_by values, not model IDs */
|
|
assert_false(g_strcmp0(model, "anthropic") == 0);
|
|
assert_false(g_strcmp0(model, "google") == 0);
|
|
assert_false(g_strcmp0(model, "openai") == 0);
|
|
assert_false(g_strcmp0(model, "nvidia") == 0);
|
|
assert_false(g_strcmp0(model, "perplexity") == 0);
|
|
assert_false(g_strcmp0(model, "xai") == 0);
|
|
models = g_list_next(models);
|
|
}
|
|
|
|
/* Verify first and last model IDs */
|
|
models = provider->models;
|
|
assert_string_equal("anthropic/claude-haiku-4-5", models->data);
|
|
models = g_list_last(models);
|
|
assert_string_equal("xai/grok-4.3", models->data);
|
|
|
|
ai_client_shutdown();
|
|
}
|
|
|
|
void
|
|
test_ai_parse_models_array_format(void** state)
|
|
{
|
|
/* Test parsing simple array format: ["model1", "model2", ...] */
|
|
ai_client_init();
|
|
|
|
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
|
|
|
const gchar* json = "[\"model1\", \"model2\", \"model3\"]";
|
|
|
|
ai_parse_models_from_json(provider, json);
|
|
|
|
assert_int_equal(3, g_list_length(provider->models));
|
|
|
|
GList* models = provider->models;
|
|
assert_string_equal("model1", models->data);
|
|
models = g_list_next(models);
|
|
assert_string_equal("model2", models->data);
|
|
models = g_list_next(models);
|
|
assert_string_equal("model3", models->data);
|
|
|
|
ai_client_shutdown();
|
|
}
|
|
|
|
void
|
|
test_ai_parse_models_empty_json(void** state)
|
|
{
|
|
/* Test parsing empty/invalid JSON */
|
|
ai_client_init();
|
|
|
|
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
|
|
|
ai_parse_models_from_json(provider, "");
|
|
assert_null(provider->models);
|
|
assert_int_equal(0, g_list_length(provider->models));
|
|
|
|
ai_parse_models_from_json(provider, "{\"invalid\": true}");
|
|
assert_null(provider->models);
|
|
assert_int_equal(0, g_list_length(provider->models));
|
|
|
|
ai_client_shutdown();
|
|
}
|
|
|
|
void
|
|
test_ai_parse_models_null_handling(void** state)
|
|
{
|
|
/* Test NULL handling */
|
|
ai_client_init();
|
|
|
|
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
|
|
|
/* NULL json should not crash */
|
|
ai_parse_models_from_json(provider, NULL);
|
|
assert_null(provider->models);
|
|
|
|
ai_client_shutdown();
|
|
}
|
|
|
|
void
|
|
test_ai_parse_models_escaped_quotes(void** state)
|
|
{
|
|
/* Test parsing model IDs with escaped quotes */
|
|
ai_client_init();
|
|
|
|
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
|
|
|
const gchar* json = "{\"object\":\"list\","
|
|
"\"data\":["
|
|
"{\"id\":\"test/model\\\"with\\\"quotes\",\"object\":\"model\",\"created\":0,\"owned_by\":\"test\"}"
|
|
"]}";
|
|
|
|
ai_parse_models_from_json(provider, json);
|
|
|
|
assert_int_equal(1, g_list_length(provider->models));
|
|
/* Model ID should have the escaped quotes handled */
|
|
GList* models = provider->models;
|
|
assert_non_null(models);
|
|
|
|
ai_client_shutdown();
|
|
}
|
|
|
|
void
|
|
test_ai_parse_models_with_whitespace(void** state)
|
|
{
|
|
/* Test parsing JSON with whitespace after colons */
|
|
ai_client_init();
|
|
|
|
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
|
|
|
/* JSON with spaces after colons (common in formatted JSON) */
|
|
const gchar* json = "{ \"object\": \"list\", "
|
|
"\"data\": [ "
|
|
"{ \"id\": \"model-with-spaces\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"test\" }"
|
|
"]}";
|
|
|
|
ai_parse_models_from_json(provider, json);
|
|
|
|
assert_int_equal(1, g_list_length(provider->models));
|
|
GList* models = provider->models;
|
|
assert_string_equal("model-with-spaces", models->data);
|
|
|
|
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/");
|
|
ai_add_provider("beta", "https://b.example/");
|
|
ai_add_provider("alphabet", "https://ab.example/");
|
|
ai_add_provider("alpine", "https://al.example/");
|
|
|
|
/* Three providers share prefix "alp": alpha, alphabet, alpine. */
|
|
GHashTable* seen = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
|
|
|
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/");
|
|
ai_add_provider("alphabet", "https://ab/");
|
|
ai_add_provider("alpine", "https://al/");
|
|
|
|
/* 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/");
|
|
ai_add_provider("alphabet", "https://ab/");
|
|
|
|
/* 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)
|
|
{
|
|
ai_add_provider("p_addmsg_nullrole", "https://example.test/anr/");
|
|
AISession* s = ai_session_create("p_addmsg_nullrole", "model-a");
|
|
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)
|
|
{
|
|
ai_add_provider("p_addmsg_nullcontent", "https://example.test/anc/");
|
|
AISession* s = ai_session_create("p_addmsg_nullcontent", "model-a");
|
|
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)
|
|
{
|
|
ai_add_provider("p_history_order", "https://example.test/ho/");
|
|
AISession* s = ai_session_create("p_history_order", "model-a");
|
|
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)
|
|
{
|
|
ai_add_provider("p_clear_empty", "https://example.test/ce/");
|
|
AISession* s = ai_session_create("p_clear_empty", "model-a");
|
|
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)
|
|
{
|
|
ai_add_provider("p_setmodel_null", "https://example.test/smn/");
|
|
AISession* s = ai_session_create("p_setmodel_null", "model-original");
|
|
assert_non_null(s);
|
|
ai_session_set_model(s, NULL);
|
|
assert_string_equal("model-original", 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/");
|
|
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/");
|
|
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_add_provider("p_settings_multi", "https://example.test/sm/");
|
|
|
|
ai_set_provider_setting("p_settings_multi", "tools", "enabled");
|
|
ai_set_provider_setting("p_settings_multi", "search", "disabled");
|
|
|
|
auto_gchar gchar* tools = ai_get_provider_setting("p_settings_multi", "tools");
|
|
auto_gchar gchar* search = ai_get_provider_setting("p_settings_multi", "search");
|
|
|
|
assert_string_equal("enabled", tools);
|
|
assert_string_equal("disabled", search);
|
|
}
|
|
|
|
void
|
|
test_ai_settings_get_missing_returns_null(void** state)
|
|
{
|
|
ai_add_provider("p_settings_missing", "https://example.test/smi/");
|
|
gchar* nope = ai_get_provider_setting("p_settings_missing", "not_set");
|
|
assert_null(nope);
|
|
}
|
|
|
|
void
|
|
test_ai_settings_isolated_between_providers(void** state)
|
|
{
|
|
/* Distinct URLs to make accidental cross-talk visible if it ever happens. */
|
|
ai_add_provider("p_settings_iso_a", "https://example.test/sia/");
|
|
ai_add_provider("p_settings_iso_b", "https://example.test/sib/");
|
|
|
|
ai_set_provider_setting("p_settings_iso_a", "tools", "yes");
|
|
ai_set_provider_setting("p_settings_iso_b", "tools", "no");
|
|
|
|
auto_gchar gchar* a = ai_get_provider_setting("p_settings_iso_a", "tools");
|
|
auto_gchar gchar* b = ai_get_provider_setting("p_settings_iso_b", "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/");
|
|
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/");
|
|
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/");
|
|
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/");
|
|
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_add_provider("p_persist_one", "https://example.test/p1/");
|
|
ai_set_provider_key("p_persist_one", "sk-persisted-123");
|
|
|
|
{
|
|
auto_gchar gchar* before = ai_get_provider_key("p_persist_one");
|
|
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("p_persist_one");
|
|
assert_non_null(after);
|
|
assert_string_equal("sk-persisted-123", after);
|
|
}
|
|
|
|
void
|
|
test_ai_prefs_round_trip_remove_key(void** state)
|
|
{
|
|
ai_add_provider("p_persist_remove", "https://example.test/pr/");
|
|
ai_set_provider_key("p_persist_remove", "sk-to-be-removed");
|
|
ai_set_provider_key("p_persist_remove", NULL);
|
|
|
|
ai_client_shutdown();
|
|
ai_client_init();
|
|
|
|
auto_gchar gchar* after = ai_get_provider_key("p_persist_remove");
|
|
assert_null(after);
|
|
}
|
|
|
|
void
|
|
test_ai_prefs_multiple_providers_persist(void** state)
|
|
{
|
|
/* Two providers with distinct URLs so a future bug that swaps URLs
|
|
* between providers would be visible (we don't assert URLs here, but
|
|
* keeping them distinct keeps the test artifact useful). */
|
|
ai_add_provider("p_persist_multi_a", "https://example.test/pma/");
|
|
ai_add_provider("p_persist_multi_b", "https://example.test/pmb/");
|
|
ai_set_provider_key("p_persist_multi_a", "sk-multi-a");
|
|
ai_set_provider_key("p_persist_multi_b", "sk-multi-b");
|
|
|
|
ai_client_shutdown();
|
|
ai_client_init();
|
|
|
|
auto_gchar gchar* k1 = ai_get_provider_key("p_persist_multi_a");
|
|
auto_gchar gchar* k2 = ai_get_provider_key("p_persist_multi_b");
|
|
|
|
assert_non_null(k1);
|
|
assert_non_null(k2);
|
|
assert_string_equal("sk-multi-a", k1);
|
|
assert_string_equal("sk-multi-b", k2);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Autocomplete deeper coverage — providers
|
|
*
|
|
* Model autocomplete (ai_models_find) tests were removed when that public
|
|
* symbol was retired in favour of a static ai_models_ac inside cmd_ac.c.
|
|
* The cycling cases below now exercise only the providers-side AC.
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_providers_find_after_remove_skips_removed(void** state)
|
|
{
|
|
ai_add_provider("zalpha", "https://z/");
|
|
auto_gchar gchar* before = ai_providers_find("zalpha", FALSE, NULL);
|
|
assert_non_null(before);
|
|
assert_string_equal("zalpha", before);
|
|
|
|
assert_true(ai_remove_provider("zalpha"));
|
|
|
|
/* After removal, completion must not return the removed provider. */
|
|
auto_gchar gchar* after = ai_providers_find("zalpha", FALSE, NULL);
|
|
assert_null(after);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Reset hook + persistence (prefs-backed providers and model cache)
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_providers_reset_ac_restarts_cycle(void** state)
|
|
{
|
|
/* After ai_providers_reset_ac() the cycling cursor must point back to
|
|
* the start of the list — the next ai_providers_find() call must
|
|
* return the same entry as the very first call before any cycling. */
|
|
ai_add_provider("zreset_a", "https://example.test/zra/");
|
|
ai_add_provider("zreset_b", "https://example.test/zrb/");
|
|
|
|
auto_gchar gchar* first = ai_providers_find("zreset", FALSE, NULL);
|
|
auto_gchar gchar* second = ai_providers_find("zreset", FALSE, NULL);
|
|
assert_non_null(first);
|
|
assert_non_null(second);
|
|
assert_string_not_equal(first, second);
|
|
|
|
ai_providers_reset_ac();
|
|
|
|
auto_gchar gchar* after_reset = ai_providers_find("zreset", FALSE, NULL);
|
|
assert_non_null(after_reset);
|
|
assert_string_equal(first, after_reset);
|
|
}
|
|
|
|
void
|
|
test_ai_add_provider_persisted_across_init(void** state)
|
|
{
|
|
/* A provider added through the public API must survive an
|
|
* ai_client_shutdown + ai_client_init cycle with the same URL. */
|
|
ai_add_provider("p_persist_provider", "https://example.test/persist-prov/");
|
|
assert_non_null(ai_get_provider("p_persist_provider"));
|
|
|
|
ai_client_shutdown();
|
|
ai_client_init();
|
|
|
|
AIProvider* after = ai_get_provider("p_persist_provider");
|
|
assert_non_null(after);
|
|
assert_string_equal("https://example.test/persist-prov/", after->api_url);
|
|
}
|
|
|
|
void
|
|
test_ai_remove_provider_persisted_across_init(void** state)
|
|
{
|
|
/* A provider removed by the user must not be reintroduced by the
|
|
* default-bootstrap path on the next init. */
|
|
ai_add_provider("p_persist_remove_prov", "https://example.test/remprov/");
|
|
assert_non_null(ai_get_provider("p_persist_remove_prov"));
|
|
|
|
assert_true(ai_remove_provider("p_persist_remove_prov"));
|
|
assert_null(ai_get_provider("p_persist_remove_prov"));
|
|
|
|
ai_client_shutdown();
|
|
ai_client_init();
|
|
|
|
assert_null(ai_get_provider("p_persist_remove_prov"));
|
|
}
|
|
|
|
void
|
|
test_ai_models_persisted_across_init(void** state)
|
|
{
|
|
/* Models cached on a provider must be reloaded from prefs on the next
|
|
* ai_client_init — the count and order should match.
|
|
*
|
|
* Avoid a provider name ending in "_models": prefs_ai_list_providers()
|
|
* filters out keys ending with "_models_url" to skip the model-list
|
|
* shadow keys, which would also swallow a real provider whose name
|
|
* happens to end with "_models". */
|
|
AIProvider* p = ai_add_provider("p_modelpersist", "https://example.test/pmodels/");
|
|
assert_non_null(p);
|
|
|
|
const gchar* json = "{\"object\":\"list\",\"data\":["
|
|
"{\"id\":\"model-alpha\",\"object\":\"model\"},"
|
|
"{\"id\":\"model-beta\",\"object\":\"model\"}"
|
|
"]}";
|
|
ai_parse_models_from_json(p, json);
|
|
assert_int_equal(2, g_list_length(p->models));
|
|
|
|
ai_client_shutdown();
|
|
ai_client_init();
|
|
|
|
AIProvider* after = ai_get_provider("p_modelpersist");
|
|
assert_non_null(after);
|
|
assert_int_equal(2, g_list_length(after->models));
|
|
}
|