mirror of
https://git.jabber.space/devs/cproof.git
synced 2026-07-18 18:06: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)
242 lines
6.9 KiB
C
242 lines
6.9 KiB
C
/*
|
|
* test_ai.c
|
|
*
|
|
* Functional tests for the /ai command surface (Tier A — no network).
|
|
*
|
|
* These tests interact with profanity through its PTY console and exercise
|
|
* paths that do not reach libcurl: command parsing, provider registration,
|
|
* key/setting/default storage, error messages, and AI window creation.
|
|
*
|
|
* Tests that require an actual provider HTTP exchange (prompt -> response,
|
|
* /ai models <provider>, HTTP error envelopes) are out of scope here and
|
|
* belong in a separate suite backed by a local HTTP stub.
|
|
*/
|
|
|
|
#include <glib.h>
|
|
#include "prof_cmocka.h"
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "proftest.h"
|
|
#include "test_ai.h"
|
|
|
|
int
|
|
ai_init_test(void** state)
|
|
{
|
|
int ret = init_prof_test(state);
|
|
if (ret != 0) {
|
|
return ret;
|
|
}
|
|
/* Connect to stabber even though AI tests don't use XMPP. Without this
|
|
* stabber's accept loop has no client to disconnect on /quit, and
|
|
* stbbr_stop() in close_prof_test() hangs uninterruptibly when joining
|
|
* the server thread. */
|
|
prof_connect();
|
|
return 0;
|
|
}
|
|
|
|
void
|
|
ai_no_args_shows_help(void** state)
|
|
{
|
|
/* `/ai` with no arguments lists the built-in providers and a usage hint. */
|
|
prof_input("/ai");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client"));
|
|
assert_true(prof_output_exact("Configured providers:"));
|
|
assert_true(prof_output_regex("openai"));
|
|
assert_true(prof_output_regex("perplexity"));
|
|
assert_true(prof_output_regex("Use '/ai start' to begin a chat"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_providers_lists_defaults(void** state)
|
|
{
|
|
/* `/ai providers` (no "list") shows the built-in list with URLs. */
|
|
prof_input("/ai providers");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_exact("Available AI providers:"));
|
|
assert_true(prof_output_regex("openai"));
|
|
assert_true(prof_output_regex("perplexity"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_providers_list_shows_key_status(void** state)
|
|
{
|
|
/* `/ai providers list` lists each configured provider with key status. */
|
|
prof_input("/ai providers list");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_exact("Configured providers:"));
|
|
/* No tokens have been set yet in this fresh session. */
|
|
assert_true(prof_output_regex("Key: NOT configured"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_set_provider_adds_custom(void** state)
|
|
{
|
|
/* Adding a custom provider should make it appear in /ai providers list. */
|
|
prof_input("/ai set provider mock http://127.0.0.1:1/");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'mock' configured"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai providers list");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("mock"));
|
|
assert_true(prof_output_regex("http://127.0.0.1:1/"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_set_token_marks_key_set(void** state)
|
|
{
|
|
/* Setting a token must flip the provider's key status to "configured". */
|
|
prof_input("/ai set token openai sk-fake-test-key");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("API token set for provider: openai"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai providers list");
|
|
|
|
prof_timeout(5);
|
|
/* openai now shows "Key: configured" while perplexity stays unconfigured. */
|
|
assert_true(prof_output_regex("Key: configured"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_start_unknown_provider_errors(void** state)
|
|
{
|
|
/* Unknown provider name should error out without creating a window. */
|
|
prof_input("/ai start nope_provider somemodel");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'nope_provider' not found"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_start_without_key_errors(void** state)
|
|
{
|
|
/* Known provider without an API key should refuse to start. */
|
|
prof_input("/ai start openai gpt-4");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("No API key set for provider 'openai'"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_start_with_key_opens_window(void** state)
|
|
{
|
|
/* With a token set, /ai start should create a WIN_AI window. */
|
|
prof_input("/ai set token openai sk-fake-test-key");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("API token set for provider: openai"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai start openai gpt-4");
|
|
|
|
prof_timeout(5);
|
|
/* /ai start switches focus to the new WIN_AI window; cons_show output
|
|
* to the console is therefore not the right place to look. The window
|
|
* itself prints "AI Chat: <provider>/<model>" as its first line. */
|
|
assert_true(prof_output_regex("AI Chat: openai/gpt-4"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_clear_without_window_errors(void** state)
|
|
{
|
|
/* /ai clear from the console (no active AI window) should report nicely. */
|
|
prof_input("/ai clear");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("No active AI chat window to clear"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_remove_provider_works(void** state)
|
|
{
|
|
/* Round-trip: add -> verify present -> remove -> verify gone. */
|
|
prof_input("/ai set provider tmpprov http://127.0.0.1:2/");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'tmpprov' configured"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai remove provider tmpprov");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'tmpprov' removed"));
|
|
prof_timeout_reset();
|
|
|
|
prof_input("/ai remove provider tmpprov");
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'tmpprov' not found"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_remove_provider_unknown_errors(void** state)
|
|
{
|
|
/* Removing a provider that was never added must report "not found". */
|
|
prof_input("/ai remove provider nonexistent_xyz");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_set_default_provider_unknown_errors(void** state)
|
|
{
|
|
/* Setting an unknown default provider should error, not silently accept. */
|
|
prof_input("/ai set default-provider does_not_exist");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Provider 'does_not_exist' not found"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_set_default_model_updates_provider(void** state)
|
|
{
|
|
/* Setting a default model is acknowledged on the console. */
|
|
prof_input("/ai set default-model openai gpt-5-preview");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Default model for provider 'openai' set to: gpt-5-preview"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_switch_without_window_errors(void** state)
|
|
{
|
|
/* /ai switch with no active AI window should produce an actionable error. */
|
|
prof_input("/ai switch openai gpt-4");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("No active AI chat window"));
|
|
prof_timeout_reset();
|
|
}
|
|
|
|
void
|
|
ai_bad_subcommand_shows_usage(void** state)
|
|
{
|
|
/* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */
|
|
prof_input("/ai not_a_subcommand");
|
|
|
|
prof_timeout(5);
|
|
assert_true(prof_output_regex("Invalid usage, see '/help ai'"));
|
|
prof_timeout_reset();
|
|
}
|