mirror of
https://git.jabber.space/devs/cproof.git
synced 2026-07-20 04:26:20 +00:00
Unit tests (tests/unittests/test_ai_client.c): +50 cases on top of the
existing 50 — total 100. Covers:
- chat response parser (ai_parse_response): OpenAI content + Perplexity
text formats, escape decoding, empty/null/missing inputs, format-string
safety, multiline content
- error envelope parser (ai_parse_error_message): standard envelope,
nested escapes, missing fields, null/empty
- extended JSON escape: \b, \f, \r, all-specials, UTF-8 pass-through
- provider autocomplete cycling with >=2 matches and wrap-around
- session edge cases: NULL args, 100-message order preservation,
set_model(NULL), ref/unref(NULL)
- provider edge cases: get/remove with NULL, double-remove, survival
via session ref after ai_remove_provider
- settings: multi-key independence, missing key, cross-provider
isolation
- model parsing edges: data not array, empty data, "id" outside data,
multiple models
- prefs round-trip: set token -> shutdown -> init -> token reloaded
from disk (uses load_preferences fixture)
Functional tests:
- Tier A (test_ai.c): 15 cases for the /ai command surface that don't
need HTTP. Covers /ai help, providers list, set provider/token,
start with/without key/unknown provider, clear, remove, default
provider/model, switch without window, bad subcommand.
- Tier B (test_ai_http.c + ai_http_stub.{c,h,py}): 5 cases that
exercise the libcurl path against a local Python HTTP stub. The
stub serves canned bodies in five modes (ok, openai, 401, 500,
models) so each chat/models/error path is hit end-to-end without
network.
Infrastructure:
- TEST_GROUPS bumped to 5 in proftest.c; AI tests live in their own
Group 5 because mixing them with stabber-driven tests in Group 4
poisoned stbbr_stop() teardown.
- PROF_FUNC_TEST_AI macro in functionaltests.c registers AI tests
with ai_init_test() which wraps init_prof_test with a prof_connect()
so stabber sees a graceful disconnect at teardown.
- dist_check_DATA ships ai_http_stub.py with the source tarball.
Source-level changes to enable testing:
- src/ai/ai_client.{c,h}: dropped 'static' from _parse_ai_response
(renamed ai_parse_response) and _parse_error_response (renamed
ai_parse_error_message); declared under a "Parsing helpers (exposed
for testing)" section. Same approach as ai_parse_models_from_json.
Results in Docker (cproof-debian image):
- 595/595 unit tests pass
- 20/20 AI functional tests pass (Group 5)
1468 lines
46 KiB
C
1468 lines
46 KiB
C
#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, default providers should exist */
|
|
AIProvider* openai = ai_get_provider("openai");
|
|
assert_non_null(openai);
|
|
assert_string_equal("openai", openai->name);
|
|
assert_string_equal("https://api.openai.com/", openai->api_url);
|
|
|
|
AIProvider* perplexity = ai_get_provider("perplexity");
|
|
assert_non_null(perplexity);
|
|
assert_string_equal("perplexity", perplexity->name);
|
|
assert_string_equal("https://api.perplexity.ai/", perplexity->api_url);
|
|
}
|
|
|
|
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", "my-org");
|
|
assert_non_null(provider);
|
|
assert_string_equal("custom", provider->name);
|
|
assert_string_equal("https://custom.api.com/v1", provider->api_url);
|
|
assert_string_equal("my-org", provider->org_id);
|
|
|
|
/* Update existing provider (returns ref; caller owns it) */
|
|
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1", NULL);
|
|
assert_non_null(updated);
|
|
assert_string_equal("https://new.api.com/v1", updated->api_url);
|
|
assert_null(updated->org_id);
|
|
|
|
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", NULL);
|
|
assert_true(ai_remove_provider("temp"));
|
|
assert_null(ai_get_provider("temp"));
|
|
}
|
|
|
|
void
|
|
test_ai_list_providers(void** state)
|
|
{
|
|
GList* providers = ai_list_providers();
|
|
assert_non_null(providers);
|
|
assert_int_equal(2, g_list_length(providers)); /* openai and perplexity */
|
|
|
|
/* Free list (ai_list_providers returns non-ref'd providers; caller must not unref) */
|
|
g_list_free(providers);
|
|
|
|
/* Add another provider */
|
|
ai_add_provider("test", "https://test.api.com/v1", NULL);
|
|
providers = ai_list_providers();
|
|
assert_int_equal(3, g_list_length(providers));
|
|
|
|
/* Free list */
|
|
g_list_free(providers);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* API Key Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_set_provider_key(void** state)
|
|
{
|
|
ai_set_provider_key("openai", "sk-test-key-123");
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("openai");
|
|
assert_non_null(key);
|
|
assert_string_equal("sk-test-key-123", key);
|
|
}
|
|
|
|
/* Update key */
|
|
ai_set_provider_key("openai", "sk-new-key-456");
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("openai");
|
|
assert_string_equal("sk-new-key-456", key);
|
|
}
|
|
|
|
/* Remove key */
|
|
ai_set_provider_key("openai", NULL);
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("openai");
|
|
assert_null(key);
|
|
}
|
|
}
|
|
|
|
void
|
|
test_ai_get_provider_key(void** state)
|
|
{
|
|
/* No key set initially */
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("openai");
|
|
assert_null(key);
|
|
}
|
|
|
|
/* Set and get key */
|
|
ai_set_provider_key("perplexity", "pplx-abc123");
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("perplexity");
|
|
assert_non_null(key);
|
|
assert_string_equal("pplx-abc123", key);
|
|
}
|
|
|
|
/* Wrong provider returns null */
|
|
{
|
|
auto_gchar gchar* key = ai_get_provider_key("openai");
|
|
assert_null(key);
|
|
}
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Session Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_session_create(void** state)
|
|
{
|
|
AISession* session = ai_session_create("openai", "gpt-4");
|
|
assert_non_null(session);
|
|
assert_string_equal("openai", session->provider_name);
|
|
assert_string_equal("gpt-4", session->model);
|
|
assert_null(session->api_key); /* No key set */
|
|
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
void
|
|
test_ai_session_ref_unref(void** state)
|
|
{
|
|
AISession* session = ai_session_create("openai", "gpt-4");
|
|
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)
|
|
{
|
|
AISession* session = ai_session_create("openai", "gpt-4");
|
|
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)
|
|
{
|
|
AISession* session = ai_session_create("openai", "gpt-4");
|
|
|
|
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)
|
|
{
|
|
AISession* session = ai_session_create("openai", "gpt-4");
|
|
assert_string_equal("gpt-4", session->model);
|
|
|
|
ai_session_set_model(session, "gpt-3.5-turbo");
|
|
assert_string_equal("gpt-3.5-turbo", 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_set_provider_key("openai", "sk-test-key-123");
|
|
|
|
AISession* session = ai_session_create("openai", "gpt-4");
|
|
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("openai", 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", "org1");
|
|
assert_non_null(provider);
|
|
assert_string_equal("https://first.api.com/v1", provider->api_url);
|
|
assert_string_equal("org1", provider->org_id);
|
|
|
|
/* Update the same provider (returns ref) */
|
|
provider = ai_add_provider("custom", "https://second.api.com/v1", "org2");
|
|
assert_non_null(provider);
|
|
assert_string_equal("https://second.api.com/v1", provider->api_url);
|
|
assert_string_equal("org2", provider->org_id);
|
|
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", NULL));
|
|
}
|
|
|
|
void
|
|
test_ai_add_provider_null_url_returns_null(void** state)
|
|
{
|
|
assert_null(ai_add_provider("test", NULL, 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)
|
|
{
|
|
assert_null(ai_session_create("openai", NULL));
|
|
}
|
|
|
|
void
|
|
test_ai_session_api_key_null_when_no_key_set(void** state)
|
|
{
|
|
/* openai has no key set by default */
|
|
AISession* session = ai_session_create("openai", "gpt-4");
|
|
assert_non_null(session);
|
|
assert_null(session->api_key);
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Provider Autocomplete Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_providers_find_forward(void** state)
|
|
{
|
|
/* Test forward iteration - should return first match */
|
|
auto_gchar gchar* result = ai_providers_find("o", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("openai", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_forward_perplexity(void** state)
|
|
{
|
|
/* Test forward iteration for perplexity */
|
|
auto_gchar gchar* result = ai_providers_find("p", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("perplexity", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_forward_custom(void** state)
|
|
{
|
|
/* Add a custom provider and test */
|
|
ai_add_provider("custom", "https://custom.api.com/v1", NULL);
|
|
|
|
auto_gchar gchar* result = ai_providers_find("c", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("custom", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_forward_no_match(void** state)
|
|
{
|
|
/* Test no match */
|
|
auto_gchar gchar* result = ai_providers_find("z", FALSE, NULL);
|
|
assert_null(result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_forward_partial_match(void** state)
|
|
{
|
|
/* Test partial match - should return providers starting with "ope" */
|
|
auto_gchar gchar* result = ai_providers_find("ope", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("openai", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_next(void** state)
|
|
{
|
|
/* Test that stateless implementation returns same result each call */
|
|
auto_gchar gchar* result1 = ai_providers_find("o", FALSE, NULL);
|
|
assert_non_null(result1);
|
|
assert_string_equal("openai", result1);
|
|
|
|
/* Second call with same params returns same result (stateless) */
|
|
auto_gchar gchar* result2 = ai_providers_find("o", FALSE, NULL);
|
|
assert_non_null(result2);
|
|
assert_string_equal("openai", result2);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_previous(void** state)
|
|
{
|
|
/* Test that previous=TRUE returns last match in list */
|
|
/* With only "openai" starting with "o", both FALSE and TRUE return same result */
|
|
auto_gchar gchar* result1 = ai_providers_find("o", FALSE, NULL);
|
|
assert_non_null(result1);
|
|
assert_string_equal("openai", result1);
|
|
|
|
/* previous=TRUE also returns "openai" (only one match, so first==last) */
|
|
auto_gchar gchar* result2 = ai_providers_find("o", TRUE, NULL);
|
|
assert_non_null(result2);
|
|
assert_string_equal("openai", result2);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_null_search_str(void** state)
|
|
{
|
|
/* NULL search_str triggers cycling: returns first provider in list */
|
|
auto_gchar gchar* result = ai_providers_find(NULL, FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("openai", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_empty_search_str(void** state)
|
|
{
|
|
/* Empty search_str triggers cycling: returns first provider in list */
|
|
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("openai", result);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_case_insensitive(void** state)
|
|
{
|
|
/* Test that matching is case-insensitive (via g_ascii_strdown) */
|
|
auto_gchar gchar* result = ai_providers_find("OPENAI", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("openai", result);
|
|
|
|
result = ai_providers_find("OpenAI", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("openai", result);
|
|
|
|
result = ai_providers_find("openai", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("openai", result);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* AI Autocomplete Integration Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_start_provider_autocomplete_only_on_exact(void** state)
|
|
{
|
|
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
|
|
assert_non_null(result);
|
|
assert_string_equal("openai", result);
|
|
}
|
|
|
|
void
|
|
test_ai_models_find_null_session(void** state)
|
|
{
|
|
auto_gchar gchar* result = ai_models_find("gpt", FALSE, NULL);
|
|
assert_null(result);
|
|
}
|
|
|
|
void
|
|
test_ai_models_find_null_provider(void** state)
|
|
{
|
|
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
|
|
AISession* session = ai_session_create("temp", "gpt-4");
|
|
assert_non_null(session);
|
|
ai_session_unref(session);
|
|
}
|
|
|
|
void
|
|
test_ai_providers_find_cycling(void** state)
|
|
{
|
|
/* NULL search_str should cycle through providers */
|
|
auto_gchar gchar* result1 = ai_providers_find(NULL, FALSE, NULL);
|
|
assert_non_null(result1);
|
|
|
|
auto_gchar gchar* result2 = ai_providers_find(NULL, FALSE, NULL);
|
|
assert_non_null(result2);
|
|
|
|
/* Cycling: consecutive calls with NULL should return different providers */
|
|
assert_string_not_equal(result1, result2);
|
|
}
|
|
|
|
/* ========================================================================
|
|
* Provider Default Model and Settings Tests
|
|
* ======================================================================== */
|
|
|
|
void
|
|
test_ai_set_provider_default_model(void** state)
|
|
{
|
|
/* Set default model for openai provider */
|
|
ai_set_provider_default_model("openai", "gpt-5");
|
|
|
|
/* Verify the model was set */
|
|
const gchar* model = ai_get_provider_default_model("openai");
|
|
assert_non_null(model);
|
|
assert_string_equal("gpt-5", model);
|
|
|
|
/* Update default model */
|
|
ai_set_provider_default_model("openai", "gpt-4o");
|
|
model = ai_get_provider_default_model("openai");
|
|
assert_non_null(model);
|
|
assert_string_equal("gpt-4o", model);
|
|
|
|
/* Set default model for perplexity */
|
|
ai_set_provider_default_model("perplexity", "sonar-pro");
|
|
model = ai_get_provider_default_model("perplexity");
|
|
assert_non_null(model);
|
|
assert_string_equal("sonar-pro", 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"));
|
|
|
|
/* Default providers may or may not have default models set initially */
|
|
/* After setting, they should return the model */
|
|
ai_set_provider_default_model("openai", "test-model");
|
|
assert_string_equal("test-model", ai_get_provider_default_model("openai"));
|
|
|
|
/* NULL model argument should be ignored (no change) */
|
|
ai_set_provider_default_model("openai", NULL);
|
|
/* After setting NULL, the model should still be "test-model" because
|
|
* ai_set_provider_default_model returns early on NULL model */
|
|
assert_string_equal("test-model", ai_get_provider_default_model("openai"));
|
|
}
|
|
|
|
void
|
|
test_ai_set_provider_setting(void** state)
|
|
{
|
|
/* NULL provider should be ignored */
|
|
ai_set_provider_setting(NULL, "tools", "enabled");
|
|
|
|
/* NULL setting should be ignored */
|
|
ai_set_provider_setting("openai", NULL, "enabled");
|
|
|
|
/* Non-existent provider should log warning but not crash */
|
|
ai_set_provider_setting("nonexistent", "tools", "enabled");
|
|
|
|
/* Set a setting for openai */
|
|
ai_set_provider_setting("openai", "tools", "enabled");
|
|
ai_set_provider_setting("openai", "search", "disabled");
|
|
|
|
/* Verify settings were set */
|
|
gchar* tools = ai_get_provider_setting("openai", "tools");
|
|
assert_non_null(tools);
|
|
assert_string_equal("enabled", tools);
|
|
g_free(tools);
|
|
|
|
gchar* search = ai_get_provider_setting("openai", "search");
|
|
assert_non_null(search);
|
|
assert_string_equal("disabled", search);
|
|
g_free(search);
|
|
|
|
/* NULL value removes the setting */
|
|
ai_set_provider_setting("openai", "tools", NULL);
|
|
tools = ai_get_provider_setting("openai", "tools");
|
|
assert_null(tools);
|
|
|
|
/* Setting that was never set returns NULL */
|
|
assert_null(ai_get_provider_setting("openai", "nonexistent_setting"));
|
|
}
|
|
|
|
void
|
|
test_ai_get_provider_setting(void** state)
|
|
{
|
|
/* NULL provider returns NULL */
|
|
assert_null(ai_get_provider_setting(NULL, "tools"));
|
|
|
|
/* NULL setting returns NULL */
|
|
assert_null(ai_get_provider_setting("openai", NULL));
|
|
|
|
/* Non-existent provider returns NULL */
|
|
assert_null(ai_get_provider_setting("nonexistent", "tools"));
|
|
|
|
/* Provider without settings returns NULL */
|
|
assert_null(ai_get_provider_setting("perplexity", "tools"));
|
|
|
|
/* Set and get setting */
|
|
ai_set_provider_setting("openai", "temperature", "0.7");
|
|
gchar* temp = ai_get_provider_setting("openai", "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("openai", "max_tokens", "2048");
|
|
temp = ai_get_provider_setting("openai", "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)
|
|
{
|
|
/* After init, models should be fresh (or at least not crash) */
|
|
/* The ai_client_init() loads models from prefs, which may be empty initially */
|
|
/* So we just verify the function works correctly */
|
|
|
|
/* NULL provider returns FALSE */
|
|
assert_false(ai_models_are_fresh(NULL));
|
|
|
|
/* Non-existent provider returns FALSE */
|
|
assert_false(ai_models_are_fresh("nonexistent"));
|
|
|
|
/* Default providers - check that function doesn't crash */
|
|
/* Freshness depends on whether models were loaded from prefs */
|
|
gboolean fresh_openai = ai_models_are_fresh("openai");
|
|
assert_true(fresh_openai == TRUE || fresh_openai == FALSE); /* Just verify no crash */
|
|
|
|
gboolean fresh_perplexity = ai_models_are_fresh("perplexity");
|
|
assert_true(fresh_perplexity == TRUE || fresh_perplexity == FALSE); /* Just verify no crash */
|
|
}
|
|
|
|
/* ========================================================================
|
|
* 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", NULL);
|
|
|
|
/* 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_provider_unref(provider);
|
|
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("perplexity", "https://api.perplexity.ai/", NULL);
|
|
|
|
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_provider_unref(provider);
|
|
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", NULL);
|
|
|
|
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_provider_unref(provider);
|
|
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", NULL);
|
|
|
|
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_provider_unref(provider);
|
|
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);
|
|
|
|
/* NULL json should not crash */
|
|
ai_parse_models_from_json(provider, NULL);
|
|
assert_null(provider->models);
|
|
|
|
ai_provider_unref(provider);
|
|
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", NULL);
|
|
|
|
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_provider_unref(provider);
|
|
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", NULL);
|
|
|
|
/* 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_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);
|
|
}
|