Parser:
- Never return reasoning text as the reply: the no-output_text path
anchors strictly on the message "content" array; a reasoning-only body
(truncated by max_output_tokens, carries only "summary") now fails the
parse instead of leaking the chain-of-thought. Regression test added.
- _find_json_field skips field-name occurrences that are string values
(no ':' after), so {"type":"text","text":"hi"} parses instead of
failing on the value of "type".
- _same_json_object tracks brace depth, so a nested object between the
"text" key and the "output_text" tag (e.g. "annotations") no longer
rejects the part's own text.
- Chat-completions extraction is bounded to the choices array (new
_json_array_end helper), so "message"/"content" in a sibling object
(e.g. a top-level "warning") is not returned as the reply.
AUTO fallback:
- Model errors are classified only from a structured JSON error
envelope; a route-missing 404 page merely mentioning models (e.g.
listing /v1/models) no longer suppresses the fallback. Trade-off: a
model error in a non-JSON body costs one extra fallback attempt.
- Fall back on a curl-level failure: an endpoint that accepts the
connection but never responds no longer burns the 60s timeout without
trying the flavour that works.
- Fall back on an unparseable 2xx: a gateway answering unknown paths
with a 200 error page previously reset the hint and re-probed the same
dead flavour on every request. The body is parsed inside the attempt
loop and no longer re-parsed after it.
- The second attempt's 401/403/429/5xx now wins over the stashed 400/422
payload rejection, so an invalid key or rate limit is not hidden
behind a payload-shape error; a second 404 still defers to the first.
- When the first flavour's endpoint is missing but the other answers
(even unsuccessfully, e.g. 401), the resolved hint moves to the
surviving flavour, so the dead endpoint is not probed first forever.
Settings:
- Reserved payload keys are rejected only when setting a value; removal
is allowed again, so a setting persisted before its key became
reserved (e.g. "input") is no longer stuck in the config.
1859 lines
64 KiB
C
1859 lines
64 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 (borrowed pointer, same as the create path) */
|
|
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);
|
|
}
|
|
|
|
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 (borrowed pointer) */
|
|
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);
|
|
}
|
|
|
|
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_set_provider_setting_reserved_key(void** state)
|
|
{
|
|
ai_add_provider("p_reserved_keys", "https://example.test/rk/");
|
|
|
|
/* Reserved payload keys are rejected (would duplicate fixed fields) */
|
|
assert_false(ai_set_provider_setting("p_reserved_keys", "model", "x"));
|
|
assert_false(ai_set_provider_setting("p_reserved_keys", "messages", "[]"));
|
|
assert_false(ai_set_provider_setting("p_reserved_keys", "input", "[]"));
|
|
assert_false(ai_set_provider_setting("p_reserved_keys", "stream", "true"));
|
|
assert_null(ai_get_provider_setting("p_reserved_keys", "model"));
|
|
|
|
/* "store" is a legitimate chat-completions parameter and overrides the
|
|
* responses-flavour "store":false default, so it stays settable */
|
|
assert_true(ai_set_provider_setting("p_reserved_keys", "store", "true"));
|
|
auto_gchar gchar* store = ai_get_provider_setting("p_reserved_keys", "store");
|
|
assert_non_null(store);
|
|
assert_string_equal("true", store);
|
|
|
|
/* Ordinary keys still work */
|
|
assert_true(ai_set_provider_setting("p_reserved_keys", "temperature", "0.7"));
|
|
auto_gchar gchar* temp = ai_get_provider_setting("p_reserved_keys", "temperature");
|
|
assert_non_null(temp);
|
|
assert_string_equal("0.7", temp);
|
|
}
|
|
|
|
void
|
|
test_ai_get_provider_setting_keys(void** state)
|
|
{
|
|
ai_add_provider("p_setting_keys", "https://example.test/skeys/");
|
|
|
|
/* No settings yet, unknown provider, NULL — all yield NULL */
|
|
assert_null(ai_get_provider_setting_keys("p_setting_keys"));
|
|
assert_null(ai_get_provider_setting_keys("nonexistent"));
|
|
assert_null(ai_get_provider_setting_keys(NULL));
|
|
|
|
ai_set_provider_setting("p_setting_keys", "temperature", "0.7");
|
|
ai_set_provider_setting("p_setting_keys", "reasoning_effort", "high");
|
|
|
|
/* Both keys listed (order is hash-defined, assert membership) */
|
|
GList* keys = ai_get_provider_setting_keys("p_setting_keys");
|
|
assert_int_equal(2, g_list_length(keys));
|
|
assert_non_null(g_list_find_custom(keys, "temperature", (GCompareFunc)g_strcmp0));
|
|
assert_non_null(g_list_find_custom(keys, "reasoning_effort", (GCompareFunc)g_strcmp0));
|
|
g_list_free_full(keys, g_free);
|
|
|
|
/* Removing a setting removes its key */
|
|
ai_set_provider_setting("p_setting_keys", "temperature", NULL);
|
|
keys = ai_get_provider_setting_keys("p_setting_keys");
|
|
assert_int_equal(1, g_list_length(keys));
|
|
assert_string_equal("reasoning_effort", keys->data);
|
|
g_list_free_full(keys, g_free);
|
|
}
|
|
|
|
void
|
|
test_ai_set_provider_api_type(void** state)
|
|
{
|
|
ai_add_provider("p_api_type", "https://example.test/at/");
|
|
|
|
/* Default is auto */
|
|
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type"));
|
|
|
|
/* Pin each flavour explicitly */
|
|
assert_true(ai_set_provider_api_type("p_api_type", "chat-completions"));
|
|
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_api_type"));
|
|
|
|
assert_true(ai_set_provider_api_type("p_api_type", "responses"));
|
|
assert_int_equal(AI_API_TYPE_RESPONSES, ai_get_provider_api_type("p_api_type"));
|
|
|
|
/* auto resets to detection */
|
|
assert_true(ai_set_provider_api_type("p_api_type", "auto"));
|
|
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type"));
|
|
|
|
/* Invalid values and unknown providers are rejected */
|
|
assert_false(ai_set_provider_api_type("p_api_type", "v2"));
|
|
assert_false(ai_set_provider_api_type("p_api_type", NULL));
|
|
assert_false(ai_set_provider_api_type("nonexistent", "responses"));
|
|
assert_false(ai_set_provider_api_type(NULL, "responses"));
|
|
|
|
/* Unknown provider reads as auto */
|
|
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("nonexistent"));
|
|
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type(NULL));
|
|
}
|
|
|
|
void
|
|
test_ai_api_type_string_round_trip(void** state)
|
|
{
|
|
AIApiType type = AI_API_TYPE_AUTO;
|
|
|
|
assert_true(ai_api_type_from_string("responses", &type));
|
|
assert_int_equal(AI_API_TYPE_RESPONSES, type);
|
|
assert_string_equal("responses", ai_api_type_to_string(type));
|
|
|
|
assert_true(ai_api_type_from_string("chat-completions", &type));
|
|
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, type);
|
|
assert_string_equal("chat-completions", ai_api_type_to_string(type));
|
|
|
|
assert_true(ai_api_type_from_string("auto", &type));
|
|
assert_int_equal(AI_API_TYPE_AUTO, type);
|
|
assert_string_equal("auto", ai_api_type_to_string(type));
|
|
|
|
assert_false(ai_api_type_from_string("completions", &type));
|
|
assert_false(ai_api_type_from_string("", &type));
|
|
assert_false(ai_api_type_from_string(NULL, &type));
|
|
}
|
|
|
|
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_responses_output_text(void** state)
|
|
{
|
|
/* Responses API: "content" is an array of typed parts; the inner "text"
|
|
* of the output_text part is the assistant message. */
|
|
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_responses_full_envelope(void** state)
|
|
{
|
|
/* Realistic responses body: a reasoning item precedes the message item;
|
|
* the parser must still land on the message's output_text. */
|
|
const gchar* json = "{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\","
|
|
"\"output\":["
|
|
"{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[]},"
|
|
"{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\","
|
|
"\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Hello!\"}]}"
|
|
"]}";
|
|
auto_gchar gchar* out = ai_parse_response(json);
|
|
assert_non_null(out);
|
|
assert_string_equal("Hello!", out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_choices_win_over_output(void** state)
|
|
{
|
|
/* A chat-completions envelope is authoritative even if the content
|
|
* happens to mention "output". */
|
|
const gchar* json = "{\"choices\":[{\"message\":{\"content\":\"chat result\"}}],\"output\":[{\"content\":[{\"text\":\"wrong\"}]}]}";
|
|
auto_gchar gchar* out = ai_parse_response(json);
|
|
assert_non_null(out);
|
|
assert_string_equal("chat result", out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_responses_reasoning_summary_skipped(void** state)
|
|
{
|
|
/* A reasoning item with a non-empty summary precedes the message item;
|
|
* the summary's "text" must not be mistaken for the assistant reply. */
|
|
const gchar* json = "{\"output\":["
|
|
"{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"User asks X\"}]},"
|
|
"{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\","
|
|
"\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Actual answer\"}]}"
|
|
"]}";
|
|
auto_gchar gchar* out = ai_parse_response(json);
|
|
assert_non_null(out);
|
|
assert_string_equal("Actual answer", out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_responses_reasoning_only_returns_null(void** state)
|
|
{
|
|
/* An incomplete/truncated body with only a reasoning item (no message
|
|
* "content" array at all) must yield a parse failure, not leak the
|
|
* reasoning summary's "text" as the assistant reply. */
|
|
const gchar* json = "{\"status\":\"incomplete\",\"output\":["
|
|
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret reasoning\"}]}"
|
|
"]}";
|
|
gchar* out = ai_parse_response(json);
|
|
assert_null(out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_choices_in_string_value(void** state)
|
|
{
|
|
/* "choices" occurring as a string value must not hijack a valid
|
|
* responses envelope. */
|
|
const gchar* json = "{\"kind\":\"choices\",\"output\":[{\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}]}";
|
|
auto_gchar gchar* out = ai_parse_response(json);
|
|
assert_non_null(out);
|
|
assert_string_equal("hi", out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_content_before_choices(void** state)
|
|
{
|
|
/* Nonstandard body: a top-level content string precedes an empty
|
|
* choices array; the bare-content fallback must still find it. */
|
|
const gchar* json = "{\"content\":\"hi\",\"choices\":[]}";
|
|
auto_gchar gchar* out = ai_parse_response(json);
|
|
assert_non_null(out);
|
|
assert_string_equal("hi", out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_content_preferred_over_text(void** state)
|
|
{
|
|
/* Chat-completions "content" is extracted; a stray "text" field is ignored. */
|
|
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 content field", out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_responses_brace_in_text(void** state)
|
|
{
|
|
/* Responses part with "text" before "type":"output_text" and a '{' inside
|
|
* the answer: the nearest preceding "text" key must be extracted, not a
|
|
* brace inside the string value. */
|
|
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"use { here\",\"type\":\"output_text\"}]}]}";
|
|
auto_gchar gchar* out = ai_parse_response(json);
|
|
assert_non_null(out);
|
|
assert_string_equal("use { here", out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_choices_logprobs_before_content(void** state)
|
|
{
|
|
/* A non-string "content" (logprobs array) ordered before message.content
|
|
* must not defeat extraction; anchoring on "message" finds the reply. */
|
|
const gchar* json = "{\"choices\":[{\"logprobs\":{\"content\":[{\"token\":\"hi\"}]},"
|
|
"\"message\":{\"role\":\"assistant\",\"content\":\"real answer\"}}]}";
|
|
auto_gchar gchar* out = ai_parse_response(json);
|
|
assert_non_null(out);
|
|
assert_string_equal("real answer", out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_reasoning_not_leaked_without_text(void** state)
|
|
{
|
|
/* An output_text part with no "text" at all (truncated body) must yield a
|
|
* parse failure, not leak the preceding reasoning summary's text. */
|
|
const gchar* json = "{\"output\":["
|
|
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]},"
|
|
"{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"annotations\":[]}]}"
|
|
"]}";
|
|
gchar* out = ai_parse_response(json);
|
|
assert_null(out);
|
|
}
|
|
|
|
void
|
|
test_ai_parse_response_responses_text_value_text(void** state)
|
|
{
|
|
/* A reply that is exactly the string "text" must not confuse the
|
|
* backscan (the value's own quoted bytes match the key needle). */
|
|
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"text\",\"type\":\"output_text\"}]}]}";
|
|
auto_gchar gchar* out = ai_parse_response(json);
|
|
assert_non_null(out);
|
|
assert_string_equal("text", 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_round_trip_api_type(void** state)
|
|
{
|
|
ai_add_provider("p_persist_api_type", "https://example.test/pat/");
|
|
assert_true(ai_set_provider_api_type("p_persist_api_type", "chat-completions"));
|
|
|
|
ai_client_shutdown();
|
|
ai_client_init();
|
|
|
|
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_persist_api_type"));
|
|
|
|
/* Setting auto removes the persisted value */
|
|
assert_true(ai_set_provider_api_type("p_persist_api_type", "auto"));
|
|
|
|
ai_client_shutdown();
|
|
ai_client_init();
|
|
|
|
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_persist_api_type"));
|
|
}
|
|
|
|
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));
|
|
}
|