Compare commits
1 Commits
3fccc046b0
...
test/ai-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
e67ae4e7a5
|
@@ -204,8 +204,12 @@ functionaltest_sources = \
|
||||
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
|
||||
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
|
||||
tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \
|
||||
tests/functionaltests/ai_http_stub.c tests/functionaltests/ai_http_stub.h \
|
||||
tests/functionaltests/test_ai_http.c tests/functionaltests/test_ai_http.h \
|
||||
tests/functionaltests/functionaltests.c
|
||||
|
||||
dist_check_DATA = tests/functionaltests/ai_http_stub.py
|
||||
|
||||
main_source = src/main.c
|
||||
|
||||
python_sources = \
|
||||
|
||||
100
tests/functionaltests/ai_http_stub.c
Normal file
100
tests/functionaltests/ai_http_stub.c
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* ai_http_stub.c
|
||||
*
|
||||
* Spawns tests/functionaltests/ai_http_stub.py as a child process for
|
||||
* functional tests that exercise the AI client's HTTP code path. The
|
||||
* stub listens on 127.0.0.1 on an OS-chosen port and prints "PORT=<n>"
|
||||
* as its first line; we read it back and hand the port to the caller.
|
||||
*/
|
||||
|
||||
#include "ai_http_stub.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define STUB_SCRIPT "../tests/functionaltests/ai_http_stub.py"
|
||||
#define READLINE_MAX 64
|
||||
|
||||
static pid_t stub_pid = 0;
|
||||
|
||||
int
|
||||
ai_http_stub_start(const char* mode, const char* reply)
|
||||
{
|
||||
int pipefd[2];
|
||||
if (pipe(pipefd) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
/* Child: redirect stdout to the pipe write end, then exec the stub. */
|
||||
close(pipefd[0]);
|
||||
dup2(pipefd[1], STDOUT_FILENO);
|
||||
close(pipefd[1]);
|
||||
|
||||
if (mode) {
|
||||
setenv("AI_STUB_MODE", mode, 1);
|
||||
}
|
||||
if (reply) {
|
||||
setenv("AI_STUB_REPLY", reply, 1);
|
||||
}
|
||||
/* Argument "0" -> let the OS pick a free port. */
|
||||
execlp("python3", "python3", STUB_SCRIPT, "0", (char*)NULL);
|
||||
/* Falls through on exec failure. */
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Parent: read "PORT=<n>\n" from the child's stdout. */
|
||||
close(pipefd[1]);
|
||||
|
||||
char buf[READLINE_MAX] = { 0 };
|
||||
ssize_t total = 0;
|
||||
while (total < (ssize_t)sizeof(buf) - 1) {
|
||||
ssize_t n = read(pipefd[0], buf + total, sizeof(buf) - 1 - total);
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
total += n;
|
||||
if (memchr(buf, '\n', total)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close(pipefd[0]);
|
||||
|
||||
int port = 0;
|
||||
if (sscanf(buf, "PORT=%d", &port) != 1 || port <= 0) {
|
||||
kill(pid, SIGTERM);
|
||||
waitpid(pid, NULL, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
stub_pid = pid;
|
||||
return port;
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_stub_stop(void)
|
||||
{
|
||||
if (stub_pid <= 0) {
|
||||
return;
|
||||
}
|
||||
kill(stub_pid, SIGTERM);
|
||||
/* Give the stub a moment to exit cleanly, then reap. */
|
||||
int status = 0;
|
||||
pid_t waited = waitpid(stub_pid, &status, 0);
|
||||
(void)waited;
|
||||
(void)status;
|
||||
stub_pid = 0;
|
||||
}
|
||||
25
tests/functionaltests/ai_http_stub.h
Normal file
25
tests/functionaltests/ai_http_stub.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#ifndef __H_AI_HTTP_STUB
|
||||
#define __H_AI_HTTP_STUB
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
/*
|
||||
* Spawn the Python AI stub script (tests/functionaltests/ai_http_stub.py).
|
||||
*
|
||||
* mode — one of "ok", "openai", "401", "500", "models" (see the stub
|
||||
* script). Selects the response body and HTTP status.
|
||||
* reply — body text used by "ok"/"openai"/"401" modes. Pass NULL for
|
||||
* the default ("MOCK-REPLY").
|
||||
*
|
||||
* Returns the listening port (>0) on success, 0 on failure. The child's
|
||||
* pid is recorded internally; call ai_http_stub_stop() to terminate it.
|
||||
*/
|
||||
int ai_http_stub_start(const char* mode, const char* reply);
|
||||
|
||||
/*
|
||||
* Send SIGTERM to the most recently spawned stub and reap it.
|
||||
* No-op if no stub is running.
|
||||
*/
|
||||
void ai_http_stub_stop(void);
|
||||
|
||||
#endif
|
||||
117
tests/functionaltests/ai_http_stub.py
Normal file
117
tests/functionaltests/ai_http_stub.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal HTTP stub for AI functional tests.
|
||||
|
||||
Listens on 127.0.0.1:<port> and serves canned JSON responses for any POST.
|
||||
Behaviour is selected via the URL path and the AI_STUB_MODE environment
|
||||
variable, set by the test runner before exec().
|
||||
|
||||
Modes:
|
||||
- "ok" : 200 with a valid OpenAI Responses-API body
|
||||
({"output":[{"content":[{"text":"...","type":"output_text"}]}]})
|
||||
- "openai" : 200 with a legacy OpenAI Chat Completions body
|
||||
({"choices":[{"message":{"content":"..."}}]})
|
||||
- "401" : 401 with an OpenAI-style error envelope
|
||||
- "500" : 500 with a body that triggers the parse-error fallback
|
||||
- "models" : 200 with a /v1/models list body
|
||||
|
||||
The reply text is taken from AI_STUB_REPLY (default: "MOCK-REPLY").
|
||||
|
||||
The stub writes its actual listening port to stdout as a single line
|
||||
"PORT=<n>" so the parent can read it back, then runs until SIGTERM.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
|
||||
REPLY = os.environ.get("AI_STUB_REPLY", "MOCK-REPLY")
|
||||
MODE = os.environ.get("AI_STUB_MODE", "ok")
|
||||
|
||||
|
||||
def _dumps(body: object) -> bytes:
|
||||
"""Produce compact JSON without whitespace — profanity's hand-rolled
|
||||
parser does strstr for `"key":"`, which fails when json.dumps inserts
|
||||
a space after the colon by default."""
|
||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _body_for(mode: str) -> tuple[int, bytes]:
|
||||
if mode == "ok":
|
||||
body = {
|
||||
"output": [
|
||||
{
|
||||
"content": [
|
||||
{"text": REPLY, "type": "output_text"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
if mode == "openai":
|
||||
body = {
|
||||
"choices": [
|
||||
{"message": {"role": "assistant", "content": REPLY}}
|
||||
]
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
if mode == "401":
|
||||
body = {
|
||||
"error": {
|
||||
"message": REPLY,
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
}
|
||||
return 401, _dumps(body)
|
||||
if mode == "500":
|
||||
return 500, b"<html><body>Internal Server Error</body></html>"
|
||||
if mode == "models":
|
||||
body = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "model-a", "object": "model"},
|
||||
{"id": "model-b", "object": "model"},
|
||||
{"id": "model-c", "object": "model"},
|
||||
],
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
return 200, b"{}"
|
||||
|
||||
|
||||
class StubHandler(BaseHTTPRequestHandler):
|
||||
def _send(self):
|
||||
status, body = _body_for(MODE)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
self._send()
|
||||
|
||||
def do_GET(self):
|
||||
self._send()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
||||
srv = HTTPServer(("127.0.0.1", port), StubHandler)
|
||||
actual = srv.server_address[1]
|
||||
print(f"PORT={actual}", flush=True)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -60,6 +60,7 @@
|
||||
#include "test_export_import.h"
|
||||
#endif
|
||||
#include "test_ai.h"
|
||||
#include "test_ai_http.h"
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) \
|
||||
@@ -324,7 +325,7 @@ main(int argc, char* argv[])
|
||||
* prof_connect() calls.
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group5_tests[] = {
|
||||
/* Console-only — no network calls */
|
||||
/* Tier A — no network calls */
|
||||
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
|
||||
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
|
||||
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
|
||||
@@ -340,6 +341,13 @@ main(int argc, char* argv[])
|
||||
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
|
||||
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
|
||||
|
||||
/* Tier B — full request/response through Python HTTP stub */
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_returns_ok),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_openai_format),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_401_shows_error),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_500_surfaced),
|
||||
PROF_FUNC_TEST_AI(ai_http_models_fetch_lists_models),
|
||||
};
|
||||
|
||||
/* Test group registry for easy extension */
|
||||
@@ -353,7 +361,7 @@ main(int argc, char* argv[])
|
||||
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
|
||||
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
|
||||
{ "Group 5: AI command surface (console only)", group5_tests, ARRAY_SIZE(group5_tests) },
|
||||
{ "Group 5: AI command surface (Tier A + Tier B)", group5_tests, ARRAY_SIZE(group5_tests) },
|
||||
};
|
||||
const int num_groups = ARRAY_SIZE(groups);
|
||||
|
||||
|
||||
157
tests/functionaltests/test_ai_http.c
Normal file
157
tests/functionaltests/test_ai_http.c
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* test_ai_http.c
|
||||
*
|
||||
* Functional tests for the /ai command surface that exercise the libcurl
|
||||
* HTTP code path. A small Python HTTP stub (ai_http_stub.py) is spawned
|
||||
* per-test on a free local port; profanity is configured to point at it
|
||||
* via `/ai set provider`. The stub serves canned JSON whose shape is
|
||||
* controlled via AI_STUB_MODE.
|
||||
*
|
||||
* These tests intentionally do not race the worker thread directly —
|
||||
* they rely on prof_timeout() retries to catch the response once it
|
||||
* propagates back through the AI window.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "proftest.h"
|
||||
#include "ai_http_stub.h"
|
||||
|
||||
#define WAIT_AI_RESPONSE_SEC 10
|
||||
|
||||
/*
|
||||
* Configure profanity to use the spawned stub as a provider named "mock"
|
||||
* with a dummy bearer token, then issue /ai start to open the AI window.
|
||||
* Returns the stub port on success, fails the test on stub-spawn failure.
|
||||
*/
|
||||
static int
|
||||
_setup_mock_provider(const char* mode, const char* reply)
|
||||
{
|
||||
int port = ai_http_stub_start(mode, reply);
|
||||
assert_int_not_equal(0, port);
|
||||
|
||||
char cmd[256];
|
||||
g_snprintf(cmd, sizeof(cmd), "/ai set provider mock http://127.0.0.1:%d/", port);
|
||||
prof_input(cmd);
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'mock' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai set token mock fake-test-token");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: mock"));
|
||||
prof_timeout_reset();
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_returns_ok(void** state)
|
||||
{
|
||||
/* Happy path: stub returns Responses API "text" body; reply appears in window. */
|
||||
_setup_mock_provider("ok", "STUB-HELLO");
|
||||
|
||||
prof_input("/ai start mock testmodel");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/testmodel"));
|
||||
prof_timeout_reset();
|
||||
|
||||
/* Plain text in AI window goes through cl_ev_send_ai_msg -> ai_send_prompt. */
|
||||
prof_input("hello stub");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("STUB-HELLO"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_openai_format(void** state)
|
||||
{
|
||||
/* Legacy OpenAI chat completions shape: "choices[].message.content". */
|
||||
_setup_mock_provider("openai", "FROM-CHOICES");
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("anything");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("FROM-CHOICES"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_401_shows_error(void** state)
|
||||
{
|
||||
/* 401 with an OpenAI-style error envelope.
|
||||
* ai_parse_error_message() should extract the inner message; the AI
|
||||
* window then displays "HTTP 401: <message>". */
|
||||
_setup_mock_provider("401", "Bad token, try another");
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("prompt");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
/* Either the parsed message surfaces, or the raw HTTP 401 envelope. */
|
||||
assert_true(prof_output_regex("HTTP 401") && prof_output_regex("Bad token, try another"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_500_surfaced(void** state)
|
||||
{
|
||||
/* 500 with a non-JSON HTML body. Parser falls back to raw body in HTTP error. */
|
||||
_setup_mock_provider("500", NULL);
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("trigger");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("HTTP 500"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_models_fetch_lists_models(void** state)
|
||||
{
|
||||
/* /v1/models endpoint returns a list of three models. /ai models <p>
|
||||
* caches them silently; /ai models <p> --cached then displays the names. */
|
||||
_setup_mock_provider("models", NULL);
|
||||
|
||||
prof_input("/ai models mock");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("Cached 3 models for provider 'mock'"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai models mock --cached");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("model-a"));
|
||||
assert_true(prof_output_regex("model-b"));
|
||||
assert_true(prof_output_regex("model-c"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
10
tests/functionaltests/test_ai_http.h
Normal file
10
tests/functionaltests/test_ai_http.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#ifndef __H_FUNC_TEST_AI_HTTP
|
||||
#define __H_FUNC_TEST_AI_HTTP
|
||||
|
||||
void ai_http_prompt_returns_ok(void** state);
|
||||
void ai_http_prompt_openai_format(void** state);
|
||||
void ai_http_prompt_401_shows_error(void** state);
|
||||
void ai_http_prompt_500_surfaced(void** state);
|
||||
void ai_http_models_fetch_lists_models(void** state);
|
||||
|
||||
#endif
|
||||
@@ -1465,236 +1465,3 @@ test_ai_prefs_multiple_providers_persist(void** state)
|
||||
assert_string_equal("sk-openai-key", k1);
|
||||
assert_string_equal("pplx-key", k2);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Autocomplete deeper coverage
|
||||
*
|
||||
* Most of these exercise ai_models_find and the persistence semantics of
|
||||
* ai_providers_find. The fixture build is a bit involved because
|
||||
* ai_models_find takes a ProfAiWin* through which it walks
|
||||
* window->session->provider->models — but ai_models_find only dereferences
|
||||
* the session field, never the ProfWin base or memcheck, so a minimal
|
||||
* stack-allocated struct is sufficient.
|
||||
* ======================================================================== */
|
||||
|
||||
#include "ui/win_types.h"
|
||||
|
||||
/* Helper: add a list of models to a provider via the public model parser. */
|
||||
static void
|
||||
_seed_models(AIProvider* provider, const gchar* const* model_ids)
|
||||
{
|
||||
GString* body = g_string_new("{\"object\":\"list\",\"data\":[");
|
||||
for (int i = 0; model_ids[i]; i++) {
|
||||
if (i > 0) {
|
||||
g_string_append_c(body, ',');
|
||||
}
|
||||
g_string_append_printf(body, "{\"id\":\"%s\",\"object\":\"model\"}", model_ids[i]);
|
||||
}
|
||||
g_string_append(body, "]}");
|
||||
ai_parse_models_from_json(provider, body->str);
|
||||
g_string_free(body, TRUE);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_models_find_no_models_returns_null(void** state)
|
||||
{
|
||||
/* A provider with no cached models must return NULL, not crash. */
|
||||
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
|
||||
assert_non_null(p);
|
||||
/* p->models is NULL initially. */
|
||||
|
||||
AISession* s = ai_session_create("acme", "anything");
|
||||
assert_non_null(s);
|
||||
ProfAiWin win = { 0 };
|
||||
win.session = s;
|
||||
|
||||
auto_gchar gchar* match = ai_models_find("m", FALSE, &win);
|
||||
assert_null(match);
|
||||
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_models_find_returns_match_for_prefix(void** state)
|
||||
{
|
||||
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
|
||||
assert_non_null(p);
|
||||
const gchar* models[] = { "gpt-4o", "gpt-4o-mini", "o1-preview", NULL };
|
||||
_seed_models(p, models);
|
||||
assert_int_equal(3, g_list_length(p->models));
|
||||
|
||||
AISession* s = ai_session_create("acme", "anything");
|
||||
assert_non_null(s);
|
||||
ProfAiWin win = { 0 };
|
||||
win.session = s;
|
||||
|
||||
auto_gchar gchar* match = ai_models_find("o1", FALSE, &win);
|
||||
assert_non_null(match);
|
||||
assert_string_equal("o1-preview", match);
|
||||
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_models_find_no_match_returns_null(void** state)
|
||||
{
|
||||
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
|
||||
const gchar* models[] = { "gpt-4o", NULL };
|
||||
_seed_models(p, models);
|
||||
|
||||
AISession* s = ai_session_create("acme", "anything");
|
||||
ProfAiWin win = { 0 };
|
||||
win.session = s;
|
||||
|
||||
auto_gchar gchar* match = ai_models_find("nothing-matches", FALSE, &win);
|
||||
assert_null(match);
|
||||
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_models_find_cycles_through_matches(void** state)
|
||||
{
|
||||
/*
|
||||
* BUG-CATCHING TEST. ai_models_find should cycle through all matches
|
||||
* that share the prefix when called repeatedly (the normal Tab/Tab/Tab
|
||||
* loop). Today ai_models_find allocates a fresh Autocomplete on every
|
||||
* call and frees it before returning, so the "last_found" cursor is
|
||||
* never persisted. Repeated calls with the same prefix return the
|
||||
* same model and the user cannot reach the others.
|
||||
*
|
||||
* Fix: keep the per-provider models Autocomplete static (analogous to
|
||||
* the static providers_ac used by ai_providers_find).
|
||||
*/
|
||||
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
|
||||
const gchar* models[] = { "gpt-4o", "gpt-4o-mini", "gpt-4o-nano", NULL };
|
||||
_seed_models(p, models);
|
||||
assert_int_equal(3, g_list_length(p->models));
|
||||
|
||||
AISession* s = ai_session_create("acme", "anything");
|
||||
ProfAiWin win = { 0 };
|
||||
win.session = s;
|
||||
|
||||
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* m = ai_models_find("gpt-4o", FALSE, &win);
|
||||
assert_non_null(m);
|
||||
assert_true(g_str_has_prefix(m, "gpt-4o"));
|
||||
g_hash_table_add(seen, g_strdup(m));
|
||||
}
|
||||
assert_int_equal(3, g_hash_table_size(seen));
|
||||
|
||||
g_hash_table_destroy(seen);
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_models_find_previous_direction(void** state)
|
||||
{
|
||||
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
|
||||
const gchar* models[] = { "alpha", "beta", "gamma", NULL };
|
||||
_seed_models(p, models);
|
||||
|
||||
AISession* s = ai_session_create("acme", "anything");
|
||||
ProfAiWin win = { 0 };
|
||||
win.session = s;
|
||||
|
||||
auto_gchar gchar* forward = ai_models_find("", FALSE, &win);
|
||||
auto_gchar gchar* back = ai_models_find("", TRUE, &win);
|
||||
assert_non_null(forward);
|
||||
assert_non_null(back);
|
||||
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_models_find_null_search_cycles_all(void** state)
|
||||
{
|
||||
AIProvider* p = ai_add_provider("acme", "https://acme.example/", NULL);
|
||||
const gchar* models[] = { "alpha", "beta", "gamma", NULL };
|
||||
_seed_models(p, models);
|
||||
|
||||
AISession* s = ai_session_create("acme", "anything");
|
||||
ProfAiWin win = { 0 };
|
||||
win.session = s;
|
||||
|
||||
/* Same cycling-broken expectation as the prefix variant. NULL/empty
|
||||
* search should walk through every model. */
|
||||
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* m = ai_models_find(NULL, FALSE, &win);
|
||||
assert_non_null(m);
|
||||
g_hash_table_add(seen, g_strdup(m));
|
||||
}
|
||||
assert_int_equal(3, g_hash_table_size(seen));
|
||||
|
||||
g_hash_table_destroy(seen);
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_state_resets_on_prefix_change(void** state)
|
||||
{
|
||||
/* After cycling within prefix A, switching to a different prefix B
|
||||
* must restart from the first match of B (not be confused by the
|
||||
* leftover cursor from the previous prefix). */
|
||||
ai_add_provider("alpha-one", "https://a/", NULL);
|
||||
ai_add_provider("alpha-two", "https://a/", NULL);
|
||||
ai_add_provider("beta-one", "https://b/", NULL);
|
||||
|
||||
auto_gchar gchar* a1 = ai_providers_find("alpha", FALSE, NULL);
|
||||
auto_gchar gchar* a2 = ai_providers_find("alpha", FALSE, NULL);
|
||||
assert_non_null(a1);
|
||||
assert_non_null(a2);
|
||||
assert_true(g_str_has_prefix(a1, "alpha"));
|
||||
assert_true(g_str_has_prefix(a2, "alpha"));
|
||||
|
||||
/* Switching prefix mid-cycle. */
|
||||
auto_gchar gchar* b = ai_providers_find("beta", FALSE, NULL);
|
||||
assert_non_null(b);
|
||||
assert_string_equal("beta-one", b);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_empty_then_prefix(void** state)
|
||||
{
|
||||
/* Cycling with an empty search then narrowing to a prefix should yield
|
||||
* a result starting with the prefix. */
|
||||
auto_gchar gchar* any = ai_providers_find("", FALSE, NULL);
|
||||
assert_non_null(any);
|
||||
|
||||
auto_gchar gchar* match = ai_providers_find("op", FALSE, NULL);
|
||||
assert_non_null(match);
|
||||
assert_true(g_str_has_prefix(match, "op"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_after_remove_skips_removed(void** state)
|
||||
{
|
||||
ai_add_provider("zalpha", "https://z/", NULL);
|
||||
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);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_after_add_includes_new(void** state)
|
||||
{
|
||||
/* A provider added after autocomplete state was first initialized
|
||||
* must appear in subsequent completions. Catches the regression
|
||||
* where the static Autocomplete is populated only at init. */
|
||||
auto_gchar gchar* prime = ai_providers_find("", FALSE, NULL);
|
||||
(void)prime;
|
||||
|
||||
ai_add_provider("xenon", "https://x/", NULL);
|
||||
|
||||
auto_gchar gchar* match = ai_providers_find("xen", FALSE, NULL);
|
||||
assert_non_null(match);
|
||||
assert_string_equal("xenon", match);
|
||||
}
|
||||
|
||||
@@ -132,15 +132,3 @@ void test_ai_parse_models_multiple_models(void** state);
|
||||
void test_ai_prefs_round_trip_api_key(void** state);
|
||||
void test_ai_prefs_round_trip_remove_key(void** state);
|
||||
void test_ai_prefs_multiple_providers_persist(void** state);
|
||||
|
||||
/* Autocomplete deeper coverage */
|
||||
void test_ai_models_find_no_models_returns_null(void** state);
|
||||
void test_ai_models_find_returns_match_for_prefix(void** state);
|
||||
void test_ai_models_find_no_match_returns_null(void** state);
|
||||
void test_ai_models_find_cycles_through_matches(void** state);
|
||||
void test_ai_models_find_previous_direction(void** state);
|
||||
void test_ai_models_find_null_search_cycles_all(void** state);
|
||||
void test_ai_providers_find_state_resets_on_prefix_change(void** state);
|
||||
void test_ai_providers_find_empty_then_prefix(void** state);
|
||||
void test_ai_providers_find_after_remove_skips_removed(void** state);
|
||||
void test_ai_providers_find_after_add_includes_new(void** state);
|
||||
|
||||
@@ -775,17 +775,6 @@ main(int argc, char* argv[])
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
/* Autocomplete deeper coverage */
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_no_models_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_returns_match_for_prefix, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_no_match_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_cycles_through_matches, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_previous_direction, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_find_null_search_cycles_all, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_state_resets_on_prefix_change, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_empty_then_prefix, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_after_remove_skips_removed, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_after_add_includes_new, ai_client_setup, ai_client_teardown),
|
||||
// Flatfile export/import round-trip
|
||||
cmocka_unit_test(test_ff_roundtrip_simple_chat),
|
||||
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),
|
||||
|
||||
Reference in New Issue
Block a user