kbot cpp testing
This commit is contained in:
parent
f0385f41ec
commit
fcdf03faed
@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@ -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('');
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
ollama run qwen2.5-coder:latest
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "postgres/postgres.h"
|
||||
#include "search/search.h"
|
||||
#include "json/json.h"
|
||||
#include "logger/logger.h"
|
||||
|
||||
#include <toml++/toml.h>
|
||||
|
||||
// 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 == "[]");
|
||||
}
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#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());
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include "gadm_reader/gadm_reader.h"
|
||||
#include <cmath>
|
||||
|
||||
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());
|
||||
}
|
||||
@ -1,209 +0,0 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include "geo/geo.h"
|
||||
#include <cmath>
|
||||
|
||||
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<Coord> 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<BBox> 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<Coord> 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<Coord> 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<Coord> 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<Coord> 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<Coord> pt = {{5, 5}};
|
||||
REQUIRE(area_sq_km(pt) == 0.0);
|
||||
}
|
||||
|
||||
// ── Point-in-polygon ────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("PIP: point inside a square", "[geo][pip]") {
|
||||
std::vector<Coord> 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<Coord> 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<Coord> 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);
|
||||
}
|
||||
@ -1,235 +0,0 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include "grid/grid.h"
|
||||
#include "gadm_reader/gadm_reader.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <set>
|
||||
|
||||
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<size_t>(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<int>(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);
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user