From fcdf03faed9df437d457d5cef93445f187e64c5a Mon Sep 17 00:00:00 2001 From: Babayaga Date: Mon, 30 Mar 2026 12:07:13 +0200 Subject: [PATCH] kbot cpp testing --- packages/kbot/cpp/orchestrator/presets.js | 115 ++++++++- packages/kbot/cpp/orchestrator/reports.js | 3 + .../kbot/cpp/orchestrator/test-commons.js | 44 ++++ packages/kbot/cpp/orchestrator/test-ipc.mjs | 51 +++- packages/kbot/cpp/polymech.md | 9 +- packages/kbot/cpp/scripts/qwen3_4b.sh | 2 - packages/kbot/cpp/scripts/run-7b.sh | 2 +- packages/kbot/cpp/src/cmd_kbot.cpp | 2 + packages/kbot/cpp/tests/CMakeLists.txt | 24 -- .../tests/functional/test_postgres_live.cpp | 81 ------ .../kbot/cpp/tests/unit/test_enrichers.cpp | 115 --------- .../kbot/cpp/tests/unit/test_gadm_reader.cpp | 163 ------------ packages/kbot/cpp/tests/unit/test_geo.cpp | 209 ---------------- packages/kbot/cpp/tests/unit/test_grid.cpp | 235 ------------------ packages/kbot/cpp/tests/unit/test_search.cpp | 60 ----- 15 files changed, 215 insertions(+), 900 deletions(-) delete mode 100644 packages/kbot/cpp/scripts/qwen3_4b.sh delete mode 100644 packages/kbot/cpp/tests/functional/test_postgres_live.cpp delete mode 100644 packages/kbot/cpp/tests/unit/test_enrichers.cpp delete mode 100644 packages/kbot/cpp/tests/unit/test_gadm_reader.cpp delete mode 100644 packages/kbot/cpp/tests/unit/test_geo.cpp delete mode 100644 packages/kbot/cpp/tests/unit/test_grid.cpp delete mode 100644 packages/kbot/cpp/tests/unit/test_search.cpp diff --git a/packages/kbot/cpp/orchestrator/presets.js b/packages/kbot/cpp/orchestrator/presets.js index 2e6c5c7d..0809227c 100644 --- a/packages/kbot/cpp/orchestrator/presets.js +++ b/packages/kbot/cpp/orchestrator/presets.js @@ -1,13 +1,31 @@ /** * orchestrator/presets.js — defaults for IPC integration tests (extend here as suites grow). + * + * Llama local runner (llama-basics.test.ts): OpenAI-compatible API at http://localhost:8888/v1, + * router `ollama` + `base_url` override, model `default` (server picks loaded GGUF). */ -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +import { probeTcpPort } from './test-commons.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export const platform = { isWin: process.platform === 'win32', }; +/** kbot/cpp root (parent of orchestrator/). */ +export const paths = { + orchestratorDir: __dirname, + cppRoot: resolve(__dirname, '..'), + /** Same as packages/kbot/cpp/scripts/run-7b.sh — llama-server on :8888 */ + run7bScript: resolve(__dirname, '../scripts/run-7b.sh'), +}; + /** Dist binary name for the current OS. */ export function exeName() { return platform.isWin ? 'kbot.exe' : 'kbot'; @@ -38,6 +56,10 @@ export const uds = { export const timeouts = { ipcDefault: 5000, kbotAi: 180_000, + /** Llama local arithmetic (same order of magnitude as kbot-ai). */ + llamaKbotAi: 180_000, + /** Max wait for :8888 after spawning run-7b.sh (model load can be slow). */ + llamaServerStart: Number(process.env.KBOT_LLAMA_START_TIMEOUT_MS || 600_000), connectAttempts: 15, connectRetryMs: 400, postShutdownMs: 200, @@ -50,6 +72,47 @@ export const router = { }, }; +/** + * Local llama.cpp HTTP server — mirrors tests/unit/llama-basics.test.ts (LLAMA_OPTS). + * Uses router `ollama` so api_key resolves to dummy `ollama`; `base_url` points at :8888/v1. + */ +export const llama = { + get port() { + return Number(process.env.KBOT_LLAMA_PORT || 8888); + }, + get host() { + return process.env.KBOT_LLAMA_HOST || '127.0.0.1'; + }, + get baseURL() { + return process.env.KBOT_LLAMA_BASE_URL || `http://localhost:${this.port}/v1`; + }, + router: 'ollama', + get model() { + return process.env.KBOT_LLAMA_MODEL || 'default'; + }, + prompts: { + /** Same idea as llama-basics completion tests. */ + add5_3: 'What is 5 + 3? Reply with just the number, nothing else.', + }, +}; + +/** + * IPC payload for kbot-ai → local llama-server (OpenAI-compatible). + * Pass `base_url` so LLMClient uses port 8888 instead of default ollama :11434. + */ +export function kbotAiPayloadLlamaLocal(overrides = {}) { + const merged = { + prompt: llama.prompts.add5_3, + router: llama.router, + model: llama.model, + base_url: llama.baseURL, + ...overrides, + }; + merged.base_url = merged.base_url ?? merged.baseURL ?? llama.baseURL; + delete merged.baseURL; + return merged; +} + /** Stock prompts and assertions helpers for LLM smoke tests. */ export const prompts = { germanyCapital: 'What is the capital of Germany? Reply in one short sentence.', @@ -71,3 +134,53 @@ export function kbotAiPayloadFromEnv() { export function usingDefaultGermanyPrompt() { return !process.env.KBOT_IPC_PROMPT; } + +/** + * If nothing listens on llama.port, optionally spawn `scripts/run-7b.sh` (requires `sh` on PATH, e.g. Git Bash on Windows). + * + * @param {{ autostart?: boolean, startTimeoutMs?: number }} [opts] + * @returns {Promise<{ ok: boolean, alreadyRunning: boolean, started?: boolean, pid?: number }>} + */ +export async function ensureLlamaLocalServer(opts = {}) { + const autostart = opts.autostart ?? true; + const startTimeoutMs = opts.startTimeoutMs ?? timeouts.llamaServerStart; + const host = llama.host; + const port = llama.port; + const scriptPath = paths.run7bScript; + + if (await probeTcpPort(host, port, 1500)) { + return { ok: true, alreadyRunning: true }; + } + + if (!autostart) { + throw new Error( + `[llama] Nothing listening on ${host}:${port}. Start the server (e.g. sh scripts/run-7b.sh), or remove KBOT_IPC_LLAMA_AUTOSTART=0 to allow autostart` + ); + } + + if (!existsSync(scriptPath)) { + throw new Error(`[llama] Script missing: ${scriptPath}`); + } + + console.log(`[llama] Port ${port} closed — starting ${scriptPath} (timeout ${startTimeoutMs}ms) …`); + + const child = spawn('sh', [scriptPath], { + detached: true, + stdio: 'ignore', + cwd: dirname(scriptPath), + env: { ...process.env }, + }); + child.unref(); + + const deadline = Date.now() + startTimeoutMs; + while (Date.now() < deadline) { + if (await probeTcpPort(host, port, 1500)) { + return { ok: true, alreadyRunning: false, started: true, pid: child.pid }; + } + await new Promise((r) => setTimeout(r, 1500)); + } + + throw new Error( + `[llama] Server did not open ${host}:${port} within ${startTimeoutMs}ms — check llama-server / GPU / model path` + ); +} diff --git a/packages/kbot/cpp/orchestrator/reports.js b/packages/kbot/cpp/orchestrator/reports.js index e87763ec..dccbcdc9 100644 --- a/packages/kbot/cpp/orchestrator/reports.js +++ b/packages/kbot/cpp/orchestrator/reports.js @@ -145,6 +145,9 @@ export function renderMarkdownReport(payload) { if (payload.passed != null) lines.push(`| Assertions passed | ${payload.passed} |`); if (payload.failed != null) lines.push(`| Assertions failed | ${payload.failed} |`); if (payload.ipcLlm != null) lines.push(`| IPC LLM step | ${payload.ipcLlm ? 'enabled' : 'skipped'} |`); + if (payload.ipcLlama != null) { + lines.push(`| IPC llama :8888 step | ${payload.ipcLlama ? 'enabled' : 'skipped'} |`); + } lines.push(`| CWD | \`${String(meta.cwd ?? '').replace(/`/g, "'")}\` |`); lines.push(''); diff --git a/packages/kbot/cpp/orchestrator/test-commons.js b/packages/kbot/cpp/orchestrator/test-commons.js index 0e050b51..9e290401 100644 --- a/packages/kbot/cpp/orchestrator/test-commons.js +++ b/packages/kbot/cpp/orchestrator/test-commons.js @@ -3,6 +3,7 @@ */ import { randomUUID } from 'node:crypto'; +import net from 'node:net'; /** kbot-ai live call runs unless KBOT_IPC_LLM is explicitly disabled. */ export function ipcLlmEnabled() { @@ -13,6 +14,49 @@ export function ipcLlmEnabled() { return true; } +/** Llama local (:8888) IPC block — on by default; set KBOT_IPC_LLAMA=0 to skip (CI / no server). */ +export function ipcLlamaEnabled() { + const v = process.env.KBOT_IPC_LLAMA; + if (v === undefined || v === '') return true; + const s = String(v).trim().toLowerCase(); + if (s === '0' || s === 'false' || s === 'no' || s === 'off') return false; + return true; +} + +/** Auto-start scripts/run-7b.sh when :8888 is closed (default on). */ +export function llamaAutostartEnabled() { + const v = process.env.KBOT_IPC_LLAMA_AUTOSTART; + if (v === undefined || v === '') return true; + const s = String(v).trim().toLowerCase(); + if (s === '0' || s === 'false' || s === 'no' || s === 'off') return false; + return true; +} + +/** TCP connect probe — true if something accepts connections. */ +export function probeTcpPort(host, port, timeoutMs = 2000) { + return new Promise((resolve) => { + const socket = net.connect({ port, host }); + const done = (ok) => { + socket.removeAllListeners(); + try { + socket.destroy(); + } catch { + /* ignore */ + } + resolve(ok); + }; + const timer = setTimeout(() => done(false), timeoutMs); + socket.once('connect', () => { + clearTimeout(timer); + done(true); + }); + socket.once('error', () => { + clearTimeout(timer); + done(false); + }); + }); +} + /** Counters for a test run (create one per process / suite). */ export function createAssert() { let passed = 0; diff --git a/packages/kbot/cpp/orchestrator/test-ipc.mjs b/packages/kbot/cpp/orchestrator/test-ipc.mjs index d54e9332..f21943aa 100644 --- a/packages/kbot/cpp/orchestrator/test-ipc.mjs +++ b/packages/kbot/cpp/orchestrator/test-ipc.mjs @@ -11,6 +11,9 @@ * KBOT_IPC_MODEL — optional model id (e.g. openrouter slug); else C++ default for that router * KBOT_IPC_PROMPT — custom prompt (default: capital of Germany; asserts "berlin" in reply) * KBOT_IPC_LLM_LOG_MAX — max chars to print for LLM text (default: unlimited) + * KBOT_IPC_LLAMA — llama :8888 step on by default; set 0/false/no/off to skip + * KBOT_IPC_LLAMA_AUTOSTART — if 0, do not spawn scripts/run-7b.sh when :8888 is closed + * KBOT_LLAMA_* — KBOT_LLAMA_PORT, KBOT_LLAMA_BASE_URL, KBOT_LLAMA_MODEL, KBOT_LLAMA_START_TIMEOUT_MS * * Shared: presets.js, test-commons.js, reports.js * Report: cwd/tests/test-ipc__HH-mm.{json,md} (see reports.js) @@ -28,13 +31,17 @@ import { uds, timeouts, kbotAiPayloadFromEnv, + kbotAiPayloadLlamaLocal, usingDefaultGermanyPrompt, + ensureLlamaLocalServer, } from './presets.js'; import { createAssert, payloadObj, logKbotAiResponse, ipcLlmEnabled, + ipcLlamaEnabled, + llamaAutostartEnabled, createIpcClient, pipeWorkerStderr, } from './test-commons.js'; @@ -155,8 +162,43 @@ async function run() { console.log('6. kbot-ai — skipped (KBOT_IPC_LLM=0/false/no/off; default is to run live LLM)'); } - // ── 7. Graceful shutdown ──────────────────────────────────────────────── - console.log('7. Graceful shutdown'); + // ── 7. kbot-ai — llama local :8888 (optional; llama-basics parity) ─────── + if (ipcLlamaEnabled()) { + console.log('7. kbot-ai → llama local runner (OpenAI :8888, presets.llama)'); + let llamaReady = false; + try { + await ensureLlamaLocalServer({ + autostart: llamaAutostartEnabled(), + startTimeoutMs: timeouts.llamaServerStart, + }); + llamaReady = true; + } catch (e) { + console.error(` ❌ ${e?.message ?? e}`); + } + assert(llamaReady, 'llama-server listening on :8888 (or autostart run-7b.sh succeeded)'); + + if (llamaReady) { + const llamaPayload = kbotAiPayloadLlamaLocal(); + const llamaRes = await ipc.request( + { type: 'kbot-ai', payload: llamaPayload }, + timeouts.llamaKbotAi + ); + assert(llamaRes.type === 'job_result', `llama IPC response type job_result (got "${llamaRes.type}")`); + const llp = payloadObj(llamaRes); + assert(llp?.status === 'success', `llama payload status success (got "${llp?.status}")`); + assert( + typeof llp?.text === 'string' && llp.text.trim().length >= 1, + `llama assistant text present (length ${(llp?.text || '').length})` + ); + assert(/\b8\b/.test(String(llp?.text || '')), 'llama arithmetic: reply mentions 8 (5+3)'); + logKbotAiResponse('kbot-ai llama local', llamaRes); + } + } else { + console.log('7. kbot-ai llama local — skipped (KBOT_IPC_LLAMA=0; default is to run)'); + } + + // ── 8. Graceful shutdown ──────────────────────────────────────────────── + console.log('8. Graceful shutdown'); const shutdownRes = await ipc.request({ type: 'shutdown' }, timeouts.ipcDefault); assert(shutdownRes.type === 'shutdown_ack', `Shutdown acknowledged (got "${shutdownRes.type}")`); @@ -180,11 +222,16 @@ async function run() { failed: stats.failed, ok: stats.failed === 0, ipcLlm: ipcLlmEnabled(), + ipcLlama: ipcLlamaEnabled(), env: { KBOT_IPC_LLM: process.env.KBOT_IPC_LLM ?? null, + KBOT_IPC_LLAMA: process.env.KBOT_IPC_LLAMA ?? null, + KBOT_IPC_LLAMA_AUTOSTART: process.env.KBOT_IPC_LLAMA_AUTOSTART ?? null, KBOT_ROUTER: process.env.KBOT_ROUTER ?? null, KBOT_IPC_MODEL: process.env.KBOT_IPC_MODEL ?? null, KBOT_IPC_PROMPT: process.env.KBOT_IPC_PROMPT ?? null, + KBOT_LLAMA_PORT: process.env.KBOT_LLAMA_PORT ?? null, + KBOT_LLAMA_BASE_URL: process.env.KBOT_LLAMA_BASE_URL ?? null, }, metrics: buildMetricsBundle(ipcMetricsCollector, ipcRunStartedAt, finishedAt), }, diff --git a/packages/kbot/cpp/polymech.md b/packages/kbot/cpp/polymech.md index 87f42529..d79ee001 100644 --- a/packages/kbot/cpp/polymech.md +++ b/packages/kbot/cpp/polymech.md @@ -286,14 +286,9 @@ All packages depend on `logger` and `json` implicitly. ## Testing -### Unit tests (Catch2) — 62 tests, 248 assertions ✅ +### Unit tests (Catch2) -| Test file | Tests | Assertions | Validates | -|-----------|-------|------------|-----------| -| `test_geo.cpp` | 23 | 77 | Haversine, area, centroid, PIP, hex/square grid | -| `test_gadm_reader.cpp` | 18 | 53 | JSON parsing, GHS props, fallback resolution | -| `test_grid.cpp` | 13 | 105 | All 4 modes × 5 sorts, GHS filtering, PIP clipping | -| `test_search.cpp` | 8 | 13 | Config loading, key validation, error handling | +Catch2 targets live in `tests/CMakeLists.txt` (e.g. `test_logger`, `test_html`, `test_postgres`, `test_json`, `test_http`, `test_polymech`, `test_cmd_kbot`, `test_ipc`, `test_functional`, e2e targets). The old geo / gadm_reader / grid / search / enrichers / `test_postgres_live` suites were removed with those package implementations. ### Integration test (Node.js) diff --git a/packages/kbot/cpp/scripts/qwen3_4b.sh b/packages/kbot/cpp/scripts/qwen3_4b.sh deleted file mode 100644 index 6a635d55..00000000 --- a/packages/kbot/cpp/scripts/qwen3_4b.sh +++ /dev/null @@ -1,2 +0,0 @@ -ollama run qwen2.5-coder:latest - diff --git a/packages/kbot/cpp/scripts/run-7b.sh b/packages/kbot/cpp/scripts/run-7b.sh index b65a22d1..6e61427d 100644 --- a/packages/kbot/cpp/scripts/run-7b.sh +++ b/packages/kbot/cpp/scripts/run-7b.sh @@ -1,4 +1,4 @@ -./llama-server.exe \ +llama-server.exe \ --hf-repo paultimothymooney/Qwen2.5-7B-Instruct-Q4_K_M-GGUF \ --hf-file qwen2.5-7b-instruct-q4_k_m.gguf \ -t 16 \ diff --git a/packages/kbot/cpp/src/cmd_kbot.cpp b/packages/kbot/cpp/src/cmd_kbot.cpp index 7d6d73c6..9d12c4c6 100644 --- a/packages/kbot/cpp/src/cmd_kbot.cpp +++ b/packages/kbot/cpp/src/cmd_kbot.cpp @@ -102,6 +102,8 @@ int run_kbot_ai_ipc(const std::string& payload, const std::string& jobId, const if (doc.HasMember("api_key") && doc["api_key"].IsString()) opts.api_key = doc["api_key"].GetString(); if (doc.HasMember("router") && doc["router"].IsString()) opts.router = doc["router"].GetString(); if (doc.HasMember("model") && doc["model"].IsString()) opts.model = doc["model"].GetString(); + if (doc.HasMember("base_url") && doc["base_url"].IsString()) opts.base_url = doc["base_url"].GetString(); + else if (doc.HasMember("baseURL") && doc["baseURL"].IsString()) opts.base_url = doc["baseURL"].GetString(); } if (opts.api_key.empty()) { diff --git a/packages/kbot/cpp/tests/CMakeLists.txt b/packages/kbot/cpp/tests/CMakeLists.txt index e7c1a848..65369880 100644 --- a/packages/kbot/cpp/tests/CMakeLists.txt +++ b/packages/kbot/cpp/tests/CMakeLists.txt @@ -36,10 +36,6 @@ add_executable(test_supabase e2e/test_supabase.cpp) target_link_libraries(test_supabase PRIVATE Catch2::Catch2WithMain tomlplusplus::tomlplusplus logger postgres json Threads::Threads) catch_discover_tests(test_supabase WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) -add_executable(test_postgres_live functional/test_postgres_live.cpp) -target_link_libraries(test_postgres_live PRIVATE Catch2::Catch2WithMain postgres search json logger tomlplusplus::tomlplusplus Threads::Threads) -catch_discover_tests(test_postgres_live WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) - add_executable(test_polymech unit/test_polymech.cpp) target_link_libraries(test_polymech PRIVATE Catch2::Catch2WithMain polymech postgres Threads::Threads) catch_discover_tests(test_polymech) @@ -56,23 +52,3 @@ catch_discover_tests(test_polymech_e2e WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) add_executable(test_ipc unit/test_ipc.cpp) target_link_libraries(test_ipc PRIVATE Catch2::Catch2WithMain ipc Threads::Threads) catch_discover_tests(test_ipc) - -add_executable(test_geo unit/test_geo.cpp) -target_link_libraries(test_geo PRIVATE Catch2::Catch2WithMain geo Threads::Threads) -catch_discover_tests(test_geo) - -add_executable(test_gadm_reader unit/test_gadm_reader.cpp) -target_link_libraries(test_gadm_reader PRIVATE Catch2::Catch2WithMain gadm_reader Threads::Threads) -catch_discover_tests(test_gadm_reader WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) - -add_executable(test_grid unit/test_grid.cpp) -target_link_libraries(test_grid PRIVATE Catch2::Catch2WithMain grid Threads::Threads) -catch_discover_tests(test_grid WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) - -add_executable(test_search unit/test_search.cpp) -target_link_libraries(test_search PRIVATE Catch2::Catch2WithMain search Threads::Threads) -catch_discover_tests(test_search WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) - -add_executable(test_enrichers unit/test_enrichers.cpp) -target_link_libraries(test_enrichers PRIVATE Catch2::Catch2WithMain enrichers Threads::Threads) -catch_discover_tests(test_enrichers) diff --git a/packages/kbot/cpp/tests/functional/test_postgres_live.cpp b/packages/kbot/cpp/tests/functional/test_postgres_live.cpp deleted file mode 100644 index 0ea5a7d5..00000000 --- a/packages/kbot/cpp/tests/functional/test_postgres_live.cpp +++ /dev/null @@ -1,81 +0,0 @@ -#include -#include "postgres/postgres.h" -#include "search/search.h" -#include "json/json.h" -#include "logger/logger.h" - -#include - -// Note: This test requires a valid config/postgres.toml pointing to a Supabase instance. -// We test against an arbitrary table 'test_items' or standard table. -// In this case we'll test against `grid_search_runs` since we know it exists, -// using a dummy uuid for testing. -// DO NOT RUN UNLESS CONFIGURED. - -TEST_CASE("Postgres Live Operations", "[postgres_live]") { - // Load config - std::string supabase_url; - std::string supabase_key; - try { - auto config = toml::parse_file("config/postgres.toml"); - supabase_url = config["supabase"]["url"].value_or(""); - supabase_key = config["supabase"]["service_key"].value_or(""); - } catch (const std::exception &e) { - WARN("Skipping postgres live tests. Config missing or invalid: " << e.what()); - return; - } - - if (supabase_url.empty() || supabase_key.empty()) { - WARN("Skipping postgres live tests. Supabase credentials missing."); - return; - } - - postgres::Config pg_cfg; - pg_cfg.supabase_url = supabase_url; - pg_cfg.supabase_key = supabase_key; - postgres::init(pg_cfg); - - REQUIRE(postgres::ping() == "ok"); - - std::string test_id = "00000000-0000-0000-0000-0000000000cc"; - std::string user_id = "3bb4cfbf-318b-44d3-a9d3-35680e738421"; - - SECTION("Insert, Query, Update, Upsert, Delete") { - // 1. Clean up first just in case - postgres::del("grid_search_runs", "id=eq." + test_id); - - // 2. Insert - std::string insert_body = R"({"id": ")" + test_id + R"(", "user_id": ")" + user_id + R"(", "run_id": "test_run", "status": "searching", "request": {}})"; - std::string res1 = postgres::insert("grid_search_runs", insert_body); - - // 3. Query - std::string res2 = postgres::query("grid_search_runs", "*", "id=eq." + test_id); - WARN("Insert Result: " << res1); - WARN("Query Result: " << res2); - REQUIRE(json::is_valid(res2)); - REQUIRE(res2.find("test_run") != std::string::npos); - - // 4. Update - std::string update_body = R"({"status": "enriching"})"; - std::string res3 = postgres::update("grid_search_runs", update_body, "id=eq." + test_id); - REQUIRE(json::is_valid(res3)); - REQUIRE(res3.find("error") == std::string::npos); - - // 5. Upsert - std::string upsert_body = R"({"id": ")" + test_id + R"(", "user_id": ")" + user_id + R"(", "run_id": "upsert_run", "status": "complete", "request": {}})"; - std::string res4 = postgres::upsert("grid_search_runs", upsert_body, "id"); - REQUIRE(res4.find("error") == std::string::npos); - - // Query again to verify upsert - std::string res5 = postgres::query("grid_search_runs", "*", "id=eq." + test_id); - REQUIRE(res5.find("upsert_run") != std::string::npos); - - // 6. Delete - std::string res6 = postgres::del("grid_search_runs", "id=eq." + test_id); - REQUIRE(json::is_valid(res6)); - - // Verify deleted - std::string res7 = postgres::query("grid_search_runs", "*", "id=eq." + test_id); - REQUIRE(res7 == "[]"); - } -} diff --git a/packages/kbot/cpp/tests/unit/test_enrichers.cpp b/packages/kbot/cpp/tests/unit/test_enrichers.cpp deleted file mode 100644 index 9b43390d..00000000 --- a/packages/kbot/cpp/tests/unit/test_enrichers.cpp +++ /dev/null @@ -1,115 +0,0 @@ -#include -#include "enrichers/enrichers.h" - -using namespace enrichers; - -// ── is_likely_email ───────────────────────────────────────────────────────── - -TEST_CASE("is_likely_email: valid emails", "[enrichers]") { - CHECK(is_likely_email("info@example.com")); - CHECK(is_likely_email("john.doe@company.co.uk")); - CHECK(is_likely_email("contact@recycling-firm.de")); - CHECK(is_likely_email("hello@my-domain.org")); -} - -TEST_CASE("is_likely_email: rejects non-emails", "[enrichers]") { - CHECK_FALSE(is_likely_email("")); - CHECK_FALSE(is_likely_email("not-an-email")); - CHECK_FALSE(is_likely_email("@no-user.com")); - CHECK_FALSE(is_likely_email("user@")); -} - -TEST_CASE("is_likely_email: rejects asset extensions", "[enrichers]") { - CHECK_FALSE(is_likely_email("logo@site.png")); - CHECK_FALSE(is_likely_email("icon@site.svg")); - CHECK_FALSE(is_likely_email("style@site.css")); - CHECK_FALSE(is_likely_email("script@site.js")); - CHECK_FALSE(is_likely_email("photo@site.jpg")); - CHECK_FALSE(is_likely_email("photo@site.webp")); -} - -TEST_CASE("is_likely_email: rejects placeholder/hash patterns", "[enrichers]") { - CHECK_FALSE(is_likely_email("user@example.com")); - CHECK_FALSE(is_likely_email("test@test.com")); - CHECK_FALSE(is_likely_email("a3f2b@hash.com")); - CHECK_FALSE(is_likely_email("your@email.com")); - CHECK_FALSE(is_likely_email("email@email.com")); - CHECK_FALSE(is_likely_email("name@domain.com")); -} - -// ── extract_emails ────────────────────────────────────────────────────────── - -TEST_CASE("extract_emails: finds emails in text", "[enrichers]") { - auto emails = extract_emails("Contact us at info@example.org or sales@company.com"); - CHECK(emails.size() >= 2); - - bool found_info = false, found_sales = false; - for (auto& e : emails) { - if (e == "info@example.org") found_info = true; - if (e == "sales@company.com") found_sales = true; - } - CHECK(found_info); - CHECK(found_sales); -} - -TEST_CASE("extract_emails: deduplicates", "[enrichers]") { - auto emails = extract_emails("info@acme.org info@acme.org info@acme.org"); - CHECK(emails.size() == 1); -} - -TEST_CASE("extract_emails: empty text returns empty", "[enrichers]") { - auto emails = extract_emails(""); - CHECK(emails.empty()); -} - -TEST_CASE("extract_emails: filters out asset emails", "[enrichers]") { - auto emails = extract_emails("logo@site.png info@real-company.de"); - CHECK(emails.size() == 1); - CHECK(emails[0] == "info@real-company.de"); -} - -// ── resolve_url ───────────────────────────────────────────────────────────── - -TEST_CASE("resolve_url: absolute stays absolute", "[enrichers]") { - CHECK(resolve_url("https://example.com", "https://other.com/page") == "https://other.com/page"); -} - -TEST_CASE("resolve_url: relative path", "[enrichers]") { - auto r = resolve_url("https://example.com/page", "/contact"); - CHECK(r == "https://example.com/contact"); -} - -TEST_CASE("resolve_url: protocol-relative", "[enrichers]") { - auto r = resolve_url("https://example.com", "//other.com/foo"); - CHECK(r == "https://other.com/foo"); -} - -TEST_CASE("resolve_url: relative without slash", "[enrichers]") { - auto r = resolve_url("https://example.com/dir/page", "about.html"); - CHECK(r == "https://example.com/dir/about.html"); -} - -// ── status_string ─────────────────────────────────────────────────────────── - -TEST_CASE("status_string: covers all statuses", "[enrichers]") { - CHECK(std::string(status_string(EnrichStatus::OK)) == "OK"); - CHECK(std::string(status_string(EnrichStatus::NO_EMAIL)) == "NO_EMAIL"); - CHECK(std::string(status_string(EnrichStatus::META_TIMEOUT)) == "META_TIMEOUT"); - CHECK(std::string(status_string(EnrichStatus::EMAIL_TIMEOUT)) == "EMAIL_TIMEOUT"); - CHECK(std::string(status_string(EnrichStatus::FETCH_ERROR)) == "FETCH_ERROR"); - CHECK(std::string(status_string(EnrichStatus::NO_PAGES)) == "NO_PAGES"); - CHECK(std::string(status_string(EnrichStatus::ERROR)) == "ERROR"); -} - -// ── EnrichConfig defaults ─────────────────────────────────────────────────── - -TEST_CASE("EnrichConfig: default values", "[enrichers]") { - EnrichConfig cfg; - CHECK(cfg.meta_timeout_ms == 20000); - CHECK(cfg.email_timeout_ms == 30000); - CHECK(cfg.email_page_timeout_ms == 10000); - CHECK(cfg.email_max_pages == 8); - CHECK(cfg.email_abort_after == 1); - CHECK_FALSE(cfg.contact_patterns.empty()); - CHECK_FALSE(cfg.probe_paths.empty()); -} diff --git a/packages/kbot/cpp/tests/unit/test_gadm_reader.cpp b/packages/kbot/cpp/tests/unit/test_gadm_reader.cpp deleted file mode 100644 index d8fcc756..00000000 --- a/packages/kbot/cpp/tests/unit/test_gadm_reader.cpp +++ /dev/null @@ -1,163 +0,0 @@ -#include -#include -#include "gadm_reader/gadm_reader.h" -#include - -using namespace gadm; -using Catch::Matchers::WithinAbs; -using Catch::Matchers::WithinRel; - -// ── Helper: fixtures path ─────────────────────────────────────────────────── -// Tests are run with WORKING_DIRECTORY = CMAKE_SOURCE_DIR (server/cpp) - -static const std::string CACHE_DIR = "cache/gadm"; - -// ── country_code ──────────────────────────────────────────────────────────── - -TEST_CASE("country_code: simple ISO3", "[gadm][util]") { - REQUIRE(country_code("ABW") == "ABW"); -} - -TEST_CASE("country_code: dotted GID", "[gadm][util]") { - REQUIRE(country_code("AFG.1.1_1") == "AFG"); - REQUIRE(country_code("ESP.6.1_1") == "ESP"); -} - -// ── infer_level ───────────────────────────────────────────────────────────── - -TEST_CASE("infer_level: level 0 (country)", "[gadm][util]") { - REQUIRE(infer_level("ABW") == 0); - REQUIRE(infer_level("AFG") == 0); -} - -TEST_CASE("infer_level: level 1", "[gadm][util]") { - REQUIRE(infer_level("AFG.1_1") == 1); -} - -TEST_CASE("infer_level: level 2", "[gadm][util]") { - REQUIRE(infer_level("AFG.1.1_1") == 2); -} - -TEST_CASE("infer_level: level 3", "[gadm][util]") { - REQUIRE(infer_level("ESP.6.1.4_1") == 3); -} - -// ── load_boundary_file: ABW level 0 ──────────────────────────────────────── - -TEST_CASE("Load ABW level 0: basic structure", "[gadm][file]") { - auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); - REQUIRE(res.error.empty()); - REQUIRE(res.features.size() == 1); - - const auto& f = res.features[0]; - REQUIRE(f.gid == "ABW"); - REQUIRE(f.name == "Aruba"); - REQUIRE(f.level == 0); - REQUIRE(f.isOuter == true); -} - -TEST_CASE("Load ABW level 0: has rings", "[gadm][file]") { - auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); - REQUIRE(res.error.empty()); - const auto& f = res.features[0]; - - REQUIRE(f.rings.size() >= 1); - REQUIRE(f.rings[0].size() > 10); // ABW has ~55 coords -} - -TEST_CASE("Load ABW level 0: GHS population data", "[gadm][file]") { - auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); - REQUIRE(res.error.empty()); - const auto& f = res.features[0]; - - REQUIRE_THAT(f.ghsPopulation, WithinRel(104847.0, 0.01)); - REQUIRE(f.ghsPopCenters.size() == 5); - // First pop center: [-70.04183, 12.53341, 104.0] - REQUIRE_THAT(f.ghsPopCenters[0][0], WithinAbs(-70.04183, 0.0001)); - REQUIRE_THAT(f.ghsPopCenters[0][1], WithinAbs(12.53341, 0.0001)); - REQUIRE_THAT(f.ghsPopCenters[0][2], WithinAbs(104.0, 0.1)); -} - -TEST_CASE("Load ABW level 0: GHS built data", "[gadm][file]") { - auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); - REQUIRE(res.error.empty()); - const auto& f = res.features[0]; - - REQUIRE_THAT(f.ghsBuiltWeight, WithinRel(22900682.0, 0.01)); - REQUIRE(f.ghsBuiltCenters.size() == 5); - REQUIRE_THAT(f.ghsBuiltCenter.lon, WithinAbs(-69.99304, 0.001)); - REQUIRE_THAT(f.ghsBuiltCenter.lat, WithinAbs(12.51234, 0.001)); -} - -TEST_CASE("Load ABW level 0: computed bbox", "[gadm][file]") { - auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); - REQUIRE(res.error.empty()); - const auto& f = res.features[0]; - - // ABW bbox should be roughly in the Caribbean - REQUIRE(f.bbox.minLon < -69.8); - REQUIRE(f.bbox.maxLon > -70.1); - REQUIRE(f.bbox.minLat > 12.4); - REQUIRE(f.bbox.maxLat < 12.7); -} - -TEST_CASE("Load ABW level 0: computed area", "[gadm][file]") { - auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); - REQUIRE(res.error.empty()); - const auto& f = res.features[0]; - - // Aruba is ~180 km² - REQUIRE_THAT(f.areaSqKm, WithinRel(180.0, 0.15)); // 15% tolerance -} - -// ── load_boundary_file: AFG level 2 ──────────────────────────────────────── - -TEST_CASE("Load AFG.1.1_1 level 2: basic structure", "[gadm][file]") { - auto res = load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json"); - REQUIRE(res.error.empty()); - REQUIRE(res.features.size() == 1); - - const auto& f = res.features[0]; - REQUIRE(f.gid == "AFG.1.1_1"); - REQUIRE(f.name == "Baharak"); - REQUIRE(f.level == 2); -} - -TEST_CASE("Load AFG.1.1_1 level 2: has GHS data", "[gadm][file]") { - auto res = load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json"); - REQUIRE(res.error.empty()); - const auto& f = res.features[0]; - - REQUIRE(f.ghsPopCenters.size() == 5); - REQUIRE(f.ghsBuiltCenters.size() == 5); - REQUIRE(f.ghsPopulation > 0); -} - -// ── load_boundary: path resolution ────────────────────────────────────────── - -TEST_CASE("load_boundary: direct GID match", "[gadm][resolve]") { - auto res = load_boundary("ABW", 0, CACHE_DIR); - REQUIRE(res.error.empty()); - REQUIRE(res.features.size() == 1); - REQUIRE(res.features[0].gid == "ABW"); -} - -TEST_CASE("load_boundary: sub-region GID", "[gadm][resolve]") { - auto res = load_boundary("AFG.1.1_1", 2, CACHE_DIR); - REQUIRE(res.error.empty()); - REQUIRE(res.features[0].gid == "AFG.1.1_1"); -} - -TEST_CASE("load_boundary: missing file returns error", "[gadm][resolve]") { - auto res = load_boundary("DOESNOTEXIST", 0, CACHE_DIR); - REQUIRE(!res.error.empty()); - REQUIRE(res.features.empty()); -} - -// ── Error handling ────────────────────────────────────────────────────────── - -TEST_CASE("load_boundary_file: nonexistent file", "[gadm][error]") { - auto res = load_boundary_file("nonexistent.json"); - REQUIRE(!res.error.empty()); - REQUIRE(res.features.empty()); -} diff --git a/packages/kbot/cpp/tests/unit/test_geo.cpp b/packages/kbot/cpp/tests/unit/test_geo.cpp deleted file mode 100644 index e39dee69..00000000 --- a/packages/kbot/cpp/tests/unit/test_geo.cpp +++ /dev/null @@ -1,209 +0,0 @@ -#include -#include -#include "geo/geo.h" -#include - -using namespace geo; -using Catch::Matchers::WithinAbs; -using Catch::Matchers::WithinRel; - -// ── Distance ──────────────────────────────────────────────────────────────── - -TEST_CASE("Haversine distance: known reference values", "[geo][distance]") { - // London to Paris: ~343 km - Coord london{-0.1278, 51.5074}; - Coord paris{2.3522, 48.8566}; - double d = distance_km(london, paris); - REQUIRE_THAT(d, WithinRel(343.5, 0.02)); // 2% tolerance - - // Same point should be zero - REQUIRE_THAT(distance_km(london, london), WithinAbs(0.0, 1e-10)); - - // Equatorial points 1 degree apart: ~111.32 km - Coord eq0{0, 0}; - Coord eq1{1, 0}; - REQUIRE_THAT(distance_km(eq0, eq1), WithinRel(111.32, 0.01)); -} - -TEST_CASE("Haversine distance: antipodal points", "[geo][distance]") { - // North pole to south pole: ~20015 km (half circumference) - Coord north{0, 90}; - Coord south{0, -90}; - double d = distance_km(north, south); - REQUIRE_THAT(d, WithinRel(20015.0, 0.01)); -} - -// ── BBox ──────────────────────────────────────────────────────────────────── - -TEST_CASE("BBox of a simple triangle", "[geo][bbox]") { - std::vector triangle = {{0, 0}, {10, 5}, {5, 10}}; - BBox b = bbox(triangle); - REQUIRE(b.minLon == 0.0); - REQUIRE(b.minLat == 0.0); - REQUIRE(b.maxLon == 10.0); - REQUIRE(b.maxLat == 10.0); -} - -TEST_CASE("BBox center", "[geo][bbox]") { - BBox b{-10, -20, 10, 20}; - Coord c = b.center(); - REQUIRE(c.lon == 0.0); - REQUIRE(c.lat == 0.0); -} - -TEST_CASE("BBox union", "[geo][bbox]") { - std::vector boxes = {{0, 0, 5, 5}, {3, 3, 10, 10}}; - BBox u = bbox_union(boxes); - REQUIRE(u.minLon == 0.0); - REQUIRE(u.minLat == 0.0); - REQUIRE(u.maxLon == 10.0); - REQUIRE(u.maxLat == 10.0); -} - -TEST_CASE("BBox of empty ring returns zeros", "[geo][bbox]") { - std::vector empty; - BBox b = bbox(empty); - REQUIRE(b.minLon == 0.0); - REQUIRE(b.maxLon == 0.0); -} - -// ── Centroid ──────────────────────────────────────────────────────────────── - -TEST_CASE("Centroid of a square", "[geo][centroid]") { - std::vector square = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}}; - Coord c = centroid(square); - REQUIRE_THAT(c.lon, WithinAbs(5.0, 1e-10)); - REQUIRE_THAT(c.lat, WithinAbs(5.0, 1e-10)); -} - -TEST_CASE("Centroid handles closed ring (duplicate first/last)", "[geo][centroid]") { - // Closed triangle — first and last point are the same - std::vector closed = {{0, 0}, {6, 0}, {3, 6}, {0, 0}}; - Coord c = centroid(closed); - // Average of 3 unique points: (0+6+3)/3 = 3, (0+0+6)/3 = 2 - REQUIRE_THAT(c.lon, WithinAbs(3.0, 1e-10)); - REQUIRE_THAT(c.lat, WithinAbs(2.0, 1e-10)); -} - -// ── Area ──────────────────────────────────────────────────────────────────── - -TEST_CASE("Area of an equatorial 1x1 degree square", "[geo][area]") { - // ~111.32 km × ~110.57 km ≈ ~12,308 km² - std::vector sq = {{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}}; - double a = area_sq_km(sq); - REQUIRE_THAT(a, WithinRel(12308.0, 0.05)); // 5% tolerance -} - -TEST_CASE("Area of a zero-size polygon is zero", "[geo][area]") { - std::vector pt = {{5, 5}}; - REQUIRE(area_sq_km(pt) == 0.0); -} - -// ── Point-in-polygon ──────────────────────────────────────────────────────── - -TEST_CASE("PIP: point inside a square", "[geo][pip]") { - std::vector sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}}; - REQUIRE(point_in_polygon({5, 5}, sq) == true); - REQUIRE(point_in_polygon({1, 1}, sq) == true); -} - -TEST_CASE("PIP: point outside a square", "[geo][pip]") { - std::vector sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}}; - REQUIRE(point_in_polygon({-1, 5}, sq) == false); - REQUIRE(point_in_polygon({15, 5}, sq) == false); -} - -TEST_CASE("PIP: point on edge is indeterminate but consistent", "[geo][pip]") { - std::vector sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}}; - // Edge behavior is implementation-defined but should not crash - (void)point_in_polygon({0, 5}, sq); - (void)point_in_polygon({5, 0}, sq); -} - -// ── Bearing ───────────────────────────────────────────────────────────────── - -TEST_CASE("Bearing: due north", "[geo][bearing]") { - Coord a{0, 0}; - Coord b{0, 10}; - REQUIRE_THAT(bearing_deg(a, b), WithinAbs(0.0, 0.1)); -} - -TEST_CASE("Bearing: due east", "[geo][bearing]") { - Coord a{0, 0}; - Coord b{10, 0}; - REQUIRE_THAT(bearing_deg(a, b), WithinAbs(90.0, 0.5)); -} - -// ── Destination ───────────────────────────────────────────────────────────── - -TEST_CASE("Destination: 100km north from equator", "[geo][destination]") { - Coord start{0, 0}; - Coord dest = destination(start, 0.0, 100.0); // due north - REQUIRE_THAT(dest.lat, WithinRel(0.899, 0.02)); // ~0.9 degrees - REQUIRE_THAT(dest.lon, WithinAbs(0.0, 0.01)); -} - -TEST_CASE("Destination roundtrip: go 100km then measure distance", "[geo][destination]") { - Coord start{2.3522, 48.8566}; // Paris - Coord dest = destination(start, 45.0, 100.0); // 100km northeast - double d = distance_km(start, dest); - REQUIRE_THAT(d, WithinRel(100.0, 0.01)); // should be ~100km back -} - -// ── Square grid ───────────────────────────────────────────────────────────── - -TEST_CASE("Square grid: generates cells within bbox", "[geo][grid]") { - BBox extent{0, 0, 1, 1}; // ~111km x ~110km - auto cells = square_grid(extent, 50.0); // 50km cells → ~4 cells - REQUIRE(cells.size() >= 4); - for (const auto& c : cells) { - REQUIRE(c.lon >= extent.minLon); - REQUIRE(c.lon <= extent.maxLon); - REQUIRE(c.lat >= extent.minLat); - REQUIRE(c.lat <= extent.maxLat); - } -} - -TEST_CASE("Square grid: zero cell size returns empty", "[geo][grid]") { - BBox extent{0, 0, 10, 10}; - auto cells = square_grid(extent, 0.0); - REQUIRE(cells.empty()); -} - -// ── Hex grid ──────────────────────────────────────────────────────────────── - -TEST_CASE("Hex grid: generates cells within bbox", "[geo][grid]") { - BBox extent{0, 0, 1, 1}; - auto cells = hex_grid(extent, 50.0); - REQUIRE(cells.size() >= 4); - for (const auto& c : cells) { - REQUIRE(c.lon >= extent.minLon); - REQUIRE(c.lon <= extent.maxLon); - REQUIRE(c.lat >= extent.minLat); - REQUIRE(c.lat <= extent.maxLat); - } -} - -TEST_CASE("Hex grid: has offset rows", "[geo][grid]") { - BBox extent{0, 0, 2, 2}; // large enough for multiple rows - auto cells = hex_grid(extent, 30.0); - // Find first and second row Y values - if (cells.size() >= 3) { - // Just verify we got some cells (hex pattern is complex to validate) - REQUIRE(cells.size() > 2); - } -} - -// ── Viewport estimation ───────────────────────────────────────────────────── - -TEST_CASE("Viewport estimation at equator zoom 14", "[geo][viewport]") { - double sq = estimate_viewport_sq_km(0.0, 14); - // At zoom 14, equator: ~9.55 m/px → ~9.78 * 7.33 ≈ 71.7 km² - REQUIRE_THAT(sq, WithinRel(71.7, 0.15)); // 15% tolerance -} - -TEST_CASE("Viewport estimation: higher zoom = smaller area", "[geo][viewport]") { - double z14 = estimate_viewport_sq_km(40.0, 14); - double z16 = estimate_viewport_sq_km(40.0, 16); - REQUIRE(z16 < z14); -} diff --git a/packages/kbot/cpp/tests/unit/test_grid.cpp b/packages/kbot/cpp/tests/unit/test_grid.cpp deleted file mode 100644 index fb530c37..00000000 --- a/packages/kbot/cpp/tests/unit/test_grid.cpp +++ /dev/null @@ -1,235 +0,0 @@ -#include -#include -#include "grid/grid.h" -#include "gadm_reader/gadm_reader.h" - -#include -#include - -using Catch::Matchers::WithinAbs; -using Catch::Matchers::WithinRel; - -static const std::string CACHE_DIR = "cache/gadm"; - -// ── Helper: load ABW boundary ─────────────────────────────────────────────── - -static gadm::Feature load_abw() { - auto res = gadm::load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); - REQUIRE(res.error.empty()); - REQUIRE(res.features.size() == 1); - return res.features[0]; -} - -static gadm::Feature load_afg() { - auto res = gadm::load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json"); - REQUIRE(res.error.empty()); - REQUIRE(res.features.size() == 1); - return res.features[0]; -} - -// ── Admin mode ────────────────────────────────────────────────────────────── - -TEST_CASE("Grid admin: single feature → one waypoint", "[grid][admin]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "admin"; - opts.pathOrder = "zigzag"; - - auto result = grid::generate({feat}, opts); - REQUIRE(result.error.empty()); - REQUIRE(result.validCells == 1); - REQUIRE(result.waypoints.size() == 1); - - auto& wp = result.waypoints[0]; - REQUIRE(wp.step == 1); - REQUIRE(wp.radius_km > 0); - // ABW centroid should be near [-70.0, 12.5] - REQUIRE_THAT(wp.lng, WithinAbs(-70.0, 0.1)); - REQUIRE_THAT(wp.lat, WithinAbs(12.5, 0.1)); -} - -TEST_CASE("Grid admin: multiple features", "[grid][admin]") { - auto abw = load_abw(); - auto afg = load_afg(); - grid::GridOptions opts; - opts.gridMode = "admin"; - - auto result = grid::generate({abw, afg}, opts); - REQUIRE(result.error.empty()); - REQUIRE(result.validCells == 2); - REQUIRE(result.waypoints.size() == 2); - REQUIRE(result.waypoints[0].step == 1); - REQUIRE(result.waypoints[1].step == 2); -} - -TEST_CASE("Grid admin: empty features → error", "[grid][admin]") { - grid::GridOptions opts; - opts.gridMode = "admin"; - - auto result = grid::generate({}, opts); - REQUIRE(!result.error.empty()); -} - -// ── Centers mode ──────────────────────────────────────────────────────────── - -TEST_CASE("Grid centers: ABW generates waypoints from GHS centers", "[grid][centers]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "centers"; - opts.cellSize = 5.0; - opts.centroidOverlap = 0.5; - - auto result = grid::generate({feat}, opts); - REQUIRE(result.error.empty()); - REQUIRE(result.validCells > 0); - REQUIRE(result.waypoints.size() == static_cast(result.validCells)); - - // All waypoints should be near Aruba - for (const auto& wp : result.waypoints) { - REQUIRE(wp.lng > -70.2); - REQUIRE(wp.lng < -69.8); - REQUIRE(wp.lat > 12.4); - REQUIRE(wp.lat < 12.7); - } -} - -TEST_CASE("Grid centers: centroid overlap filters nearby centers", "[grid][centers]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "centers"; - opts.cellSize = 20.0; // big cells - opts.centroidOverlap = 0.0; // no overlap allowed → aggressive dedup - - auto result_aggressive = grid::generate({feat}, opts); - - opts.centroidOverlap = 0.9; // allow almost full overlap → more centers pass - auto result_relaxed = grid::generate({feat}, opts); - - REQUIRE(result_relaxed.validCells >= result_aggressive.validCells); -} - -// ── Hex grid mode ─────────────────────────────────────────────────────────── - -TEST_CASE("Grid hex: ABW at 3km cells", "[grid][hex]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "hex"; - opts.cellSize = 3.0; - - auto result = grid::generate({feat}, opts); - REQUIRE(result.error.empty()); - REQUIRE(result.validCells > 0); - // Aruba is ~30x10 km, so with 3km cells we expect ~20-60 cells - REQUIRE(result.validCells > 5); - REQUIRE(result.validCells < 200); -} - -TEST_CASE("Grid square: ABW at 5km cells", "[grid][square]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "square"; - opts.cellSize = 5.0; - - auto result = grid::generate({feat}, opts); - REQUIRE(result.error.empty()); - REQUIRE(result.validCells > 0); - REQUIRE(result.validCells < 50); // island is small -} - -TEST_CASE("Grid hex: too many cells returns error", "[grid][hex]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "hex"; - opts.cellSize = 0.01; // tiny cell → huge grid - opts.maxCellsLimit = 100; - - auto result = grid::generate({feat}, opts); - REQUIRE(!result.error.empty()); -} - -// ── Sorting ───────────────────────────────────────────────────────────────── - -TEST_CASE("Grid sort: snake vs zigzag differ for multi-row grid", "[grid][sort]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "hex"; - opts.cellSize = 3.0; - - opts.pathOrder = "zigzag"; - auto r1 = grid::generate({feat}, opts); - - opts.pathOrder = "snake"; - auto r2 = grid::generate({feat}, opts); - - REQUIRE(r1.validCells == r2.validCells); - // Snake reverses every other row, so coordinates should differ in order - if (r1.validCells > 5) { - bool anyDiff = false; - for (size_t i = 0; i < r1.waypoints.size(); ++i) { - if (std::abs(r1.waypoints[i].lng - r2.waypoints[i].lng) > 1e-6) { - anyDiff = true; - break; - } - } - REQUIRE(anyDiff); - } -} - -TEST_CASE("Grid sort: spiral-out starts near center", "[grid][sort]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "hex"; - opts.cellSize = 3.0; - opts.pathOrder = "spiral-out"; - - auto result = grid::generate({feat}, opts); - REQUIRE(result.validCells > 3); - - // Compute center of all waypoints - double cLon = 0, cLat = 0; - for (const auto& wp : result.waypoints) { cLon += wp.lng; cLat += wp.lat; } - cLon /= result.waypoints.size(); - cLat /= result.waypoints.size(); - - // First waypoint should be closer to center than last - double distFirst = std::hypot(result.waypoints.front().lng - cLon, result.waypoints.front().lat - cLat); - double distLast = std::hypot(result.waypoints.back().lng - cLon, result.waypoints.back().lat - cLat); - REQUIRE(distFirst < distLast); -} - -TEST_CASE("Grid sort: steps are sequential after sorting", "[grid][sort]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "hex"; - opts.cellSize = 3.0; - opts.pathOrder = "shortest"; - - auto result = grid::generate({feat}, opts); - for (size_t i = 0; i < result.waypoints.size(); ++i) { - REQUIRE(result.waypoints[i].step == static_cast(i + 1)); - } -} - -// ── GHS Filtering ─────────────────────────────────────────────────────────── - -TEST_CASE("Grid admin: GHS pop filter skips low-pop features", "[grid][filter]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "admin"; - opts.minGhsPop = 999999999; // impossibly high - - auto result = grid::generate({feat}, opts); - REQUIRE(result.validCells == 0); - REQUIRE(result.skippedCells == 1); -} - -TEST_CASE("Grid admin: bypass filters passes everything", "[grid][filter]") { - auto feat = load_abw(); - grid::GridOptions opts; - opts.gridMode = "admin"; - opts.minGhsPop = 999999999; - opts.bypassFilters = true; - - auto result = grid::generate({feat}, opts); - REQUIRE(result.validCells == 1); -} diff --git a/packages/kbot/cpp/tests/unit/test_search.cpp b/packages/kbot/cpp/tests/unit/test_search.cpp deleted file mode 100644 index e35a642b..00000000 --- a/packages/kbot/cpp/tests/unit/test_search.cpp +++ /dev/null @@ -1,60 +0,0 @@ -#include -#include -#include "search/search.h" - -// ── Config loading ────────────────────────────────────────────────────────── - -TEST_CASE("Config: loads SERPAPI_KEY from postgres.toml", "[search][config]") { - auto cfg = search::load_config("config/postgres.toml"); - REQUIRE(!cfg.serpapi_key.empty()); - REQUIRE(cfg.serpapi_key.size() > 20); // SHA-like key -} - -TEST_CASE("Config: loads GEO_CODER_KEY from postgres.toml", "[search][config]") { - auto cfg = search::load_config("config/postgres.toml"); - REQUIRE(!cfg.geocoder_key.empty()); -} - -TEST_CASE("Config: loads BIG_DATA_KEY from postgres.toml", "[search][config]") { - auto cfg = search::load_config("config/postgres.toml"); - REQUIRE(!cfg.bigdata_key.empty()); -} - -TEST_CASE("Config: loads postgres URL", "[search][config]") { - auto cfg = search::load_config("config/postgres.toml"); - REQUIRE(cfg.postgres_url.find("supabase.com") != std::string::npos); -} - -TEST_CASE("Config: loads supabase URL and service key", "[search][config]") { - auto cfg = search::load_config("config/postgres.toml"); - REQUIRE(cfg.supabase_url.find("supabase.co") != std::string::npos); - REQUIRE(!cfg.supabase_service_key.empty()); -} - -TEST_CASE("Config: missing file returns empty config", "[search][config]") { - auto cfg = search::load_config("nonexistent.toml"); - REQUIRE(cfg.serpapi_key.empty()); - REQUIRE(cfg.postgres_url.empty()); -} - -// ── Search validation (no network) ────────────────────────────────────────── - -TEST_CASE("Search: empty key returns error", "[search][validate]") { - search::Config cfg; // all empty - search::SearchOptions opts; - opts.query = "plumbers"; - - auto res = search::search_google_maps(cfg, opts); - REQUIRE(!res.error.empty()); - REQUIRE(res.error.find("key") != std::string::npos); -} - -TEST_CASE("Search: empty query returns error", "[search][validate]") { - search::Config cfg; - cfg.serpapi_key = "test_key"; - search::SearchOptions opts; // empty query - - auto res = search::search_google_maps(cfg, opts); - REQUIRE(!res.error.empty()); - REQUIRE(res.error.find("query") != std::string::npos); -}