diff --git a/Makefile.cbm b/Makefile.cbm index d149f0a1f..a5dc2120d 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -268,7 +268,7 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c TEST_WATCHER_SRCS = tests/test_watcher.c @@ -294,7 +294,9 @@ TEST_UI_SRCS = tests/test_ui.c TEST_SECURITY_SRCS = tests/test_security.c -ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_SECURITY_SRCS) $(TEST_INTEGRATION_SRCS) +TEST_YAML_SRCS = tests/test_yaml.c + +ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_INTEGRATION_SRCS) # ── Build directories ──────────────────────────────────────────── diff --git a/src/foundation/arena.c b/src/foundation/arena.c index ee973aed9..81e4e3fde 100644 --- a/src/foundation/arena.c +++ b/src/foundation/arena.c @@ -136,6 +136,11 @@ void cbm_arena_reset(CBMArena *a) { } a->used = 0; a->total_alloc = 0; + /* Reset block_size to match surviving block — prevents overflow if + * block_size grew during previous allocations (e.g., 128 → 256). */ + if (a->nblocks == 1) { + a->block_size = a->block_sizes[0]; + } } void cbm_arena_destroy(CBMArena *a) { diff --git a/src/pipeline/pass_tests.c b/src/pipeline/pass_tests.c index 74b09ee21..e1e0512b3 100644 --- a/src/pipeline/pass_tests.c +++ b/src/pipeline/pass_tests.c @@ -76,6 +76,22 @@ bool cbm_is_test_path(const char *path) { return true; } + /* Directory-based: __tests__/, tests/, test/, spec/ */ + if (strstr(path, "__tests__/") || strstr(path, "/tests/") || strstr(path, "/test/") || + strstr(path, "/spec/")) { + return true; + } + /* Also match if path STARTS with these directories */ + if (strncmp(path, "tests/", 6) == 0 || strncmp(path, "test/", 5) == 0 || + strncmp(path, "spec/", 5) == 0 || strncmp(path, "__tests__/", 10) == 0) { + return true; + } + + /* Ruby: _spec.rb suffix */ + if (str_ends_with(path, len, "_spec.rb")) { + return true; + } + return false; } @@ -84,9 +100,17 @@ bool cbm_is_test_func_name(const char *name) { if (!name) { return false; } - /* Go: Test*, Benchmark*, Example* */ - if (strncmp(name, "Test", 4) == 0 || strncmp(name, "Benchmark", 9) == 0 || - strncmp(name, "Example", 7) == 0) { + /* Go: Test/Benchmark/Example + uppercase letter or end-of-string. + * "TestFoo" = test, "Testable" = not test (lowercase after prefix). */ + if (strncmp(name, "Test", 4) == 0 && (name[4] == '\0' || (name[4] >= 'A' && name[4] <= 'Z'))) { + return true; + } + if (strncmp(name, "Benchmark", 9) == 0 && + (name[9] == '\0' || (name[9] >= 'A' && name[9] <= 'Z'))) { + return true; + } + if (strncmp(name, "Example", 7) == 0 && + (name[7] == '\0' || (name[7] >= 'A' && name[7] <= 'Z'))) { return true; } /* Python/Rust/C++/Lua/Java: test_ or test prefix (lowercase) */ diff --git a/src/ui/embedded_assets.c b/src/ui/embedded_assets.c new file mode 100644 index 000000000..c5b8bcd9e --- /dev/null +++ b/src/ui/embedded_assets.c @@ -0,0 +1,36 @@ +/* + * embedded_assets.c — Generated file mapping URL paths to embedded bytes. + * DO NOT EDIT — regenerated by scripts/embed-frontend.sh + */ +#include "ui/embedded_assets.h" +#include + +extern const unsigned char _binary_assets_index_D1_3bntm_css_data[]; +extern const unsigned int _binary_assets_index_D1_3bntm_css_size; +extern const unsigned char _binary_assets_index_urRrcEH6_js_data[]; +extern const unsigned int _binary_assets_index_urRrcEH6_js_size; +extern const unsigned char _binary_index_html_data[]; +extern const unsigned int _binary_index_html_size; + +cbm_embedded_file_t CBM_EMBEDDED_FILES[] = { + {"/assets/index-D1-3bntm.css", _binary_assets_index_D1_3bntm_css_data, 0, "text/css"}, + {"/assets/index-urRrcEH6.js", _binary_assets_index_urRrcEH6_js_data, 0, "application/javascript"}, + {"/index.html", _binary_index_html_data, 0, "text/html"}, +}; +const int CBM_EMBEDDED_FILE_COUNT = 3; + +static void __attribute__((constructor)) init_embedded_sizes(void) { + cbm_embedded_file_t *files = CBM_EMBEDDED_FILES; + files[0].size = _binary_assets_index_D1_3bntm_css_size; + files[1].size = _binary_assets_index_urRrcEH6_js_size; + files[2].size = _binary_index_html_size; +} + +const cbm_embedded_file_t *cbm_embedded_lookup(const char *path) { + for (int i = 0; i < CBM_EMBEDDED_FILE_COUNT; i++) { + if (strcmp(CBM_EMBEDDED_FILES[i].path, path) == 0) { + return &CBM_EMBEDDED_FILES[i]; + } + } + return NULL; +} diff --git a/tests/test_arena.c b/tests/test_arena.c index c38483ae7..4c34a951d 100644 --- a/tests/test_arena.c +++ b/tests/test_arena.c @@ -377,6 +377,37 @@ TEST(arena_total_through_reset) { PASS(); } +TEST(arena_reset_block_size_invariant) { + /* BUG DETECTOR: after reset, block_size should match blocks[0]'s actual size. + * arena_grow() updates block_size to the new (larger) block's size. + * arena_reset() frees the grown blocks but does NOT restore block_size. + * Result: arena thinks blocks[0] has more capacity than it actually does. + * This is a heap-buffer-overflow waiting to happen. */ + CBMArena a; + cbm_arena_init_sized(&a, 128); + size_t original_block_size = a.block_size; + ASSERT_EQ(original_block_size, 128); + ASSERT_EQ(a.block_sizes[0], 128); + + /* Force growth: block_size will be updated to new (larger) value */ + cbm_arena_alloc(&a, 100); + cbm_arena_alloc(&a, 100); /* triggers grow */ + ASSERT_GTE(a.nblocks, 2); + ASSERT_GT(a.block_size, 128); /* block_size grew */ + + /* Reset: frees grown blocks, keeps blocks[0] (which is 128 bytes) */ + cbm_arena_reset(&a); + ASSERT_EQ(a.nblocks, 1); + + /* CRITICAL CHECK: block_size must match blocks[0]'s actual capacity. + * If block_size > block_sizes[0], the arena will allow allocations + * that overflow blocks[0]. */ + ASSERT_EQ(a.block_size, a.block_sizes[0]); + + cbm_arena_destroy(&a); + PASS(); +} + TEST(arena_strndup_zero_len) { /* strndup with len=0 — should return empty string */ CBMArena a; @@ -419,5 +450,6 @@ SUITE(arena) { RUN_TEST(arena_calloc_zero); RUN_TEST(arena_many_large_allocs_block_growth); RUN_TEST(arena_total_through_reset); + RUN_TEST(arena_reset_block_size_invariant); RUN_TEST(arena_strndup_zero_len); } diff --git a/tests/test_dyn_array.c b/tests/test_dyn_array.c index e0a946524..df86c07af 100644 --- a/tests/test_dyn_array.c +++ b/tests/test_dyn_array.c @@ -129,6 +129,316 @@ TEST(da_insert) { PASS(); } +TEST(da_push_ptr_struct) { + typedef struct { + int id; + double value; + const char *tag; + } Record; + CBM_DYN_ARRAY(Record) arr = {0}; + + Record *r = cbm_da_push_ptr(&arr); + r->id = 42; + r->value = 3.14; + r->tag = "first"; + + r = cbm_da_push_ptr(&arr); + r->id = 99; + r->value = 2.72; + r->tag = "second"; + + ASSERT_EQ(arr.count, 2); + ASSERT_EQ(arr.items[0].id, 42); + ASSERT_STR_EQ(arr.items[0].tag, "first"); + ASSERT_EQ(arr.items[1].id, 99); + ASSERT_STR_EQ(arr.items[1].tag, "second"); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_insert_at_beginning) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_push(&arr, 20); + cbm_da_push(&arr, 30); + cbm_da_push(&arr, 40); + cbm_da_insert(&arr, 0, 10); /* insert at front */ + ASSERT_EQ(arr.count, 4); + ASSERT_EQ(arr.items[0], 10); + ASSERT_EQ(arr.items[1], 20); + ASSERT_EQ(arr.items[2], 30); + ASSERT_EQ(arr.items[3], 40); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_insert_at_end) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_push(&arr, 10); + cbm_da_push(&arr, 20); + cbm_da_insert(&arr, 2, 30); /* insert at count (append) */ + ASSERT_EQ(arr.count, 3); + ASSERT_EQ(arr.items[0], 10); + ASSERT_EQ(arr.items[1], 20); + ASSERT_EQ(arr.items[2], 30); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_remove_first) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_push(&arr, 10); + cbm_da_push(&arr, 20); + cbm_da_push(&arr, 30); + cbm_da_remove(&arr, 0); /* remove first */ + ASSERT_EQ(arr.count, 2); + ASSERT_EQ(arr.items[0], 20); + ASSERT_EQ(arr.items[1], 30); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_remove_last) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_push(&arr, 10); + cbm_da_push(&arr, 20); + cbm_da_push(&arr, 30); + cbm_da_remove(&arr, 2); /* remove last element */ + ASSERT_EQ(arr.count, 2); + ASSERT_EQ(arr.items[0], 10); + ASSERT_EQ(arr.items[1], 20); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_remove_sequence) { + CBM_DYN_ARRAY(int) arr = {0}; + for (int i = 0; i < 5; i++) + cbm_da_push(&arr, i * 10); /* 0, 10, 20, 30, 40 */ + + cbm_da_remove(&arr, 2); /* remove 20 -> 0,10,30,40 */ + ASSERT_EQ(arr.count, 4); + ASSERT_EQ(arr.items[2], 30); + + cbm_da_remove(&arr, 0); /* remove 0 -> 10,30,40 */ + ASSERT_EQ(arr.count, 3); + ASSERT_EQ(arr.items[0], 10); + + cbm_da_remove(&arr, 2); /* remove 40 -> 10,30 */ + ASSERT_EQ(arr.count, 2); + ASSERT_EQ(arr.items[0], 10); + ASSERT_EQ(arr.items[1], 30); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_reserve_then_push) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_reserve(&arr, 50); + int cap_after_reserve = arr.cap; + ASSERT_GTE(cap_after_reserve, 50); + + /* Push 50 items — capacity should not change (no realloc needed) */ + for (int i = 0; i < 50; i++) + cbm_da_push(&arr, i); + ASSERT_EQ(arr.count, 50); + ASSERT_EQ(arr.cap, cap_after_reserve); + for (int i = 0; i < 50; i++) + ASSERT_EQ(arr.items[i], i); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_reserve_zero) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_reserve(&arr, 0); + /* Should be a no-op: cap stays 0, items stays NULL */ + ASSERT_EQ(arr.cap, 0); + ASSERT_NULL(arr.items); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_reserve_less_than_cap) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_reserve(&arr, 100); + int old_cap = arr.cap; + /* Reserve smaller than current cap — should be a no-op */ + cbm_da_reserve(&arr, 10); + ASSERT_EQ(arr.cap, old_cap); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_clear_then_push) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_push(&arr, 1); + cbm_da_push(&arr, 2); + cbm_da_push(&arr, 3); + int old_cap = arr.cap; + + cbm_da_clear(&arr); + ASSERT_EQ(arr.count, 0); + /* Memory retained after clear */ + ASSERT_EQ(arr.cap, old_cap); + ASSERT_NOT_NULL(arr.items); + + /* Push again — should reuse existing memory */ + cbm_da_push(&arr, 100); + cbm_da_push(&arr, 200); + ASSERT_EQ(arr.count, 2); + ASSERT_EQ(arr.items[0], 100); + ASSERT_EQ(arr.items[1], 200); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_free_then_push) { + CBM_DYN_ARRAY(int) arr = {0}; + cbm_da_push(&arr, 1); + cbm_da_push(&arr, 2); + cbm_da_free(&arr); + ASSERT_NULL(arr.items); + ASSERT_EQ(arr.count, 0); + ASSERT_EQ(arr.cap, 0); + + /* Re-use after free — should work like fresh array */ + cbm_da_push(&arr, 42); + ASSERT_EQ(arr.count, 1); + ASSERT_EQ(arr.items[0], 42); + ASSERT_GT(arr.cap, 0); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_large_array_10k) { + CBM_DYN_ARRAY(int64_t) arr = {0}; + for (int i = 0; i < 10000; i++) + cbm_da_push(&arr, (int64_t)i * 1000); + ASSERT_EQ(arr.count, 10000); + ASSERT_GTE(arr.cap, 10000); + + /* Verify contiguous memory: all values accessible and correct */ + for (int i = 0; i < 10000; i++) + ASSERT_EQ(arr.items[i], (int64_t)i * 1000); + + /* Verify truly contiguous: pointer arithmetic */ + int64_t *base = arr.items; + for (int i = 0; i < 10000; i++) + ASSERT_EQ(&arr.items[i], base + i); + + cbm_da_free(&arr); + PASS(); +} + +TEST(da_nested_struct_with_ptrs) { + typedef struct { + const char *name; + int scores[3]; + const char *category; + } Entry; + CBM_DYN_ARRAY(Entry) arr = {0}; + + cbm_da_push(&arr, ((Entry){.name = "alpha", .scores = {10, 20, 30}, .category = "A"})); + cbm_da_push(&arr, ((Entry){.name = "beta", .scores = {40, 50, 60}, .category = "B"})); + cbm_da_push(&arr, ((Entry){.name = "gamma", .scores = {70, 80, 90}, .category = "A"})); + + ASSERT_EQ(arr.count, 3); + ASSERT_STR_EQ(arr.items[0].name, "alpha"); + ASSERT_STR_EQ(arr.items[1].name, "beta"); + ASSERT_STR_EQ(arr.items[2].name, "gamma"); + ASSERT_EQ(arr.items[0].scores[2], 30); + ASSERT_EQ(arr.items[2].scores[0], 70); + ASSERT_STR_EQ(arr.items[0].category, "A"); + ASSERT_STR_EQ(arr.items[1].category, "B"); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_push_pop_lifo) { + CBM_DYN_ARRAY(int) arr = {0}; + /* Push 1..5 */ + for (int i = 1; i <= 5; i++) + cbm_da_push(&arr, i); + + /* Pop should return in LIFO order: 5,4,3,2,1 */ + ASSERT_EQ(cbm_da_pop(&arr), 5); + ASSERT_EQ(cbm_da_pop(&arr), 4); + ASSERT_EQ(cbm_da_pop(&arr), 3); + ASSERT_EQ(cbm_da_pop(&arr), 2); + ASSERT_EQ(cbm_da_pop(&arr), 1); + ASSERT_EQ(arr.count, 0); + + /* Push again after full pop */ + cbm_da_push(&arr, 99); + ASSERT_EQ(arr.count, 1); + ASSERT_EQ(arr.items[0], 99); + cbm_da_free(&arr); + PASS(); +} + +TEST(da_mixed_operations) { + CBM_DYN_ARRAY(int) arr = {0}; + + /* push 10, 20, 30 */ + cbm_da_push(&arr, 10); + cbm_da_push(&arr, 20); + cbm_da_push(&arr, 30); + ASSERT_EQ(arr.count, 3); + + /* insert 15 at index 1 -> 10,15,20,30 */ + cbm_da_insert(&arr, 1, 15); + ASSERT_EQ(arr.count, 4); + ASSERT_EQ(arr.items[1], 15); + ASSERT_EQ(arr.items[2], 20); + + /* remove index 0 -> 15,20,30 */ + cbm_da_remove(&arr, 0); + ASSERT_EQ(arr.count, 3); + ASSERT_EQ(arr.items[0], 15); + + /* push 40 -> 15,20,30,40 */ + cbm_da_push(&arr, 40); + ASSERT_EQ(arr.count, 4); + + /* pop -> 40, remaining: 15,20,30 */ + int v = cbm_da_pop(&arr); + ASSERT_EQ(v, 40); + ASSERT_EQ(arr.count, 3); + + /* last should be 30 */ + ASSERT_EQ(cbm_da_last(&arr), 30); + + /* insert at end -> 15,20,30,50 */ + cbm_da_insert(&arr, 3, 50); + ASSERT_EQ(arr.count, 4); + ASSERT_EQ(arr.items[3], 50); + + /* remove middle -> 15,30,50 */ + cbm_da_remove(&arr, 1); + ASSERT_EQ(arr.count, 3); + ASSERT_EQ(arr.items[0], 15); + ASSERT_EQ(arr.items[1], 30); + ASSERT_EQ(arr.items[2], 50); + + cbm_da_free(&arr); + PASS(); +} + +TEST(da_push_ptr_growth) { + /* Verify push_ptr triggers growth correctly */ + CBM_DYN_ARRAY(int) arr = {0}; + for (int i = 0; i < 100; i++) { + int *p = cbm_da_push_ptr(&arr); + *p = i * 7; + } + ASSERT_EQ(arr.count, 100); + ASSERT_GTE(arr.cap, 100); + for (int i = 0; i < 100; i++) + ASSERT_EQ(arr.items[i], i * 7); + cbm_da_free(&arr); + PASS(); +} + SUITE(dyn_array) { RUN_TEST(da_push_pop); RUN_TEST(da_last); @@ -140,4 +450,20 @@ SUITE(dyn_array) { RUN_TEST(da_string_ptrs); RUN_TEST(da_remove); RUN_TEST(da_insert); + RUN_TEST(da_push_ptr_struct); + RUN_TEST(da_insert_at_beginning); + RUN_TEST(da_insert_at_end); + RUN_TEST(da_remove_first); + RUN_TEST(da_remove_last); + RUN_TEST(da_remove_sequence); + RUN_TEST(da_reserve_then_push); + RUN_TEST(da_reserve_zero); + RUN_TEST(da_reserve_less_than_cap); + RUN_TEST(da_clear_then_push); + RUN_TEST(da_free_then_push); + RUN_TEST(da_large_array_10k); + RUN_TEST(da_nested_struct_with_ptrs); + RUN_TEST(da_push_pop_lifo); + RUN_TEST(da_mixed_operations); + RUN_TEST(da_push_ptr_growth); } diff --git a/tests/test_fqn.c b/tests/test_fqn.c new file mode 100644 index 000000000..c2306a2c6 --- /dev/null +++ b/tests/test_fqn.c @@ -0,0 +1,592 @@ +/* + * test_fqn.c -- Tests for FQN (Fully Qualified Name) computation. + * + * Covers: cbm_pipeline_fqn_compute, cbm_pipeline_fqn_module, + * cbm_pipeline_fqn_folder, cbm_project_name_from_path. + */ +#include "test_framework.h" +#include "../src/pipeline/pipeline.h" + +#include +#include + +/* ── Helper: assert FQN result and free ────────────────────────── */ + +#define ASSERT_FQN(expr, expected) \ + do { \ + char *_r = (expr); \ + ASSERT_NOT_NULL(_r); \ + ASSERT_STR_EQ(_r, expected); \ + free(_r); \ + } while (0) + +/* ================================================================ + * cbm_pipeline_fqn_compute + * ================================================================ */ + +/* ── Basic: project + path + name ─────────────────────────────── */ + +TEST(fqn_compute_basic_go) { + ASSERT_FQN(cbm_pipeline_fqn_compute("myproj", "main.go", "main"), "myproj.main.main"); + PASS(); +} + +TEST(fqn_compute_basic_py) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app.py", "run"), "proj.app.run"); + PASS(); +} + +TEST(fqn_compute_basic_ts) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "server.ts", "handler"), "proj.server.handler"); + PASS(); +} + +TEST(fqn_compute_basic_js) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "util.js", "parse"), "proj.util.parse"); + PASS(); +} + +TEST(fqn_compute_basic_c) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "core.c", "init"), "proj.core.init"); + PASS(); +} + +TEST(fqn_compute_basic_rs) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "lib.rs", "new"), "proj.lib.new"); + PASS(); +} + +/* ── Nested paths ─────────────────────────────────────────────── */ + +TEST(fqn_compute_nested_two_levels) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "src/pkg/module.go", "FuncName"), + "proj.src.pkg.module.FuncName"); + PASS(); +} + +TEST(fqn_compute_nested_three_levels) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "a/b/c/file.py", "Class"), + "proj.a.b.c.file.Class"); + PASS(); +} + +TEST(fqn_compute_nested_deep) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "a/b/c/d/e/f/g.ts", "fn"), + "proj.a.b.c.d.e.f.g.fn"); + PASS(); +} + +/* ── Python __init__.py ───────────────────────────────────────── */ + +TEST(fqn_compute_init_py_with_name) { + /* __init__ stripped when name is provided */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "pkg/__init__.py", "MyClass"), + "proj.pkg.MyClass"); + PASS(); +} + +TEST(fqn_compute_init_py_without_name) { + /* __init__ kept when no name (module QN for the file itself) */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "pkg/__init__.py", NULL), + "proj.pkg.__init__"); + PASS(); +} + +TEST(fqn_compute_init_py_empty_name) { + /* Empty string name also keeps __init__ */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "pkg/__init__.py", ""), + "proj.pkg.__init__"); + PASS(); +} + +TEST(fqn_compute_init_py_nested) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "a/b/__init__.py", "Foo"), + "proj.a.b.Foo"); + PASS(); +} + +TEST(fqn_compute_init_py_root) { + /* __init__.py at root with name */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "__init__.py", "X"), + "proj.X"); + PASS(); +} + +TEST(fqn_compute_init_py_root_no_name) { + /* __init__.py at root without name -- only project + __init__ (seg_count=2 > 1) */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "__init__.py", NULL), + "proj.__init__"); + PASS(); +} + +/* ── JS/TS index files ────────────────────────────────────────── */ + +TEST(fqn_compute_index_js_with_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "pkg/index.js", "render"), + "proj.pkg.render"); + PASS(); +} + +TEST(fqn_compute_index_js_without_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "pkg/index.js", NULL), + "proj.pkg.index"); + PASS(); +} + +TEST(fqn_compute_index_ts_with_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "src/index.ts", "App"), + "proj.src.App"); + PASS(); +} + +TEST(fqn_compute_index_ts_without_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "src/index.ts", NULL), + "proj.src.index"); + PASS(); +} + +TEST(fqn_compute_index_ts_empty_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "lib/index.ts", ""), + "proj.lib.index"); + PASS(); +} + +TEST(fqn_compute_index_root_with_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "index.js", "main"), + "proj.main"); + PASS(); +} + +TEST(fqn_compute_index_root_no_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "index.js", NULL), + "proj.index"); + PASS(); +} + +/* ── Empty / NULL parameters ──────────────────────────────────── */ + +TEST(fqn_compute_empty_rel_path) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "", "func"), "proj.func"); + PASS(); +} + +TEST(fqn_compute_empty_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "mod.go", ""), "proj.mod"); + PASS(); +} + +TEST(fqn_compute_both_empty) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "", ""), "proj"); + PASS(); +} + +TEST(fqn_compute_null_project) { + ASSERT_FQN(cbm_pipeline_fqn_compute(NULL, "foo.go", "bar"), ""); + PASS(); +} + +TEST(fqn_compute_null_rel_path) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", NULL, "fn"), "proj.fn"); + PASS(); +} + +TEST(fqn_compute_null_name) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "mod.go", NULL), "proj.mod"); + PASS(); +} + +TEST(fqn_compute_all_null) { + ASSERT_FQN(cbm_pipeline_fqn_compute(NULL, NULL, NULL), ""); + PASS(); +} + +TEST(fqn_compute_null_project_null_path) { + ASSERT_FQN(cbm_pipeline_fqn_compute(NULL, NULL, "fn"), ""); + PASS(); +} + +/* ── Backslash paths (Windows) ────────────────────────────────── */ + +TEST(fqn_compute_backslash_simple) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "src\\main.go", "run"), + "proj.src.main.run"); + PASS(); +} + +TEST(fqn_compute_backslash_nested) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "a\\b\\c\\file.py", "X"), + "proj.a.b.c.file.X"); + PASS(); +} + +TEST(fqn_compute_backslash_mixed) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "a/b\\c/d.ts", "fn"), + "proj.a.b.c.d.fn"); + PASS(); +} + +/* ── Multiple extensions ──────────────────────────────────────── */ + +TEST(fqn_compute_double_ext) { + /* Only last extension stripped: foo.test.ts -> foo.test */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "foo.test.ts", "bar"), + "proj.foo.test.bar"); + PASS(); +} + +TEST(fqn_compute_spec_ext) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "util.spec.js", "it"), + "proj.util.spec.it"); + PASS(); +} + +/* ── Leading / trailing slashes ───────────────────────────────── */ + +TEST(fqn_compute_leading_slash) { + /* Leading slash produces empty segment which is skipped */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "/src/main.go", "fn"), + "proj.src.main.fn"); + PASS(); +} + +TEST(fqn_compute_trailing_slash) { + /* Trailing slash: path becomes empty after last /, extension strip is no-op */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "src/", "fn"), + "proj.src.fn"); + PASS(); +} + +TEST(fqn_compute_double_slash) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "a//b.go", "fn"), + "proj.a.b.fn"); + PASS(); +} + +/* ── No extension ─────────────────────────────────────────────── */ + +TEST(fqn_compute_no_ext) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "Makefile", "target"), + "proj.Makefile.target"); + PASS(); +} + +/* ── Project-only (no path, no name) ──────────────────────────── */ + +TEST(fqn_compute_project_only) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", NULL, NULL), "proj"); + PASS(); +} + +/* ── Non-init/index filenames that start similarly ────────────── */ + +TEST(fqn_compute_init_not_stripped) { + /* __init_data__ is NOT __init__, should not be stripped */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "pkg/__init_data__.py", "F"), + "proj.pkg.__init_data__.F"); + PASS(); +} + +TEST(fqn_compute_index2_not_stripped) { + /* "indexer" is NOT "index", should not be stripped */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "pkg/indexer.ts", "F"), + "proj.pkg.indexer.F"); + PASS(); +} + +/* ================================================================ + * cbm_pipeline_fqn_module + * ================================================================ */ + +TEST(fqn_module_basic) { + ASSERT_FQN(cbm_pipeline_fqn_module("proj", "src/app.py"), "proj.src.app"); + PASS(); +} + +TEST(fqn_module_go) { + ASSERT_FQN(cbm_pipeline_fqn_module("proj", "cmd/server.go"), "proj.cmd.server"); + PASS(); +} + +TEST(fqn_module_init_py) { + /* fqn_module passes NULL name -> __init__ kept */ + ASSERT_FQN(cbm_pipeline_fqn_module("proj", "pkg/__init__.py"), "proj.pkg.__init__"); + PASS(); +} + +TEST(fqn_module_index_js) { + ASSERT_FQN(cbm_pipeline_fqn_module("proj", "components/index.js"), "proj.components.index"); + PASS(); +} + +TEST(fqn_module_empty_path) { + ASSERT_FQN(cbm_pipeline_fqn_module("proj", ""), "proj"); + PASS(); +} + +TEST(fqn_module_null_path) { + ASSERT_FQN(cbm_pipeline_fqn_module("proj", NULL), "proj"); + PASS(); +} + +TEST(fqn_module_null_project) { + ASSERT_FQN(cbm_pipeline_fqn_module(NULL, "foo.go"), ""); + PASS(); +} + +TEST(fqn_module_deep) { + ASSERT_FQN(cbm_pipeline_fqn_module("proj", "a/b/c/d/e.rs"), "proj.a.b.c.d.e"); + PASS(); +} + +/* ================================================================ + * cbm_pipeline_fqn_folder + * ================================================================ */ + +TEST(fqn_folder_basic) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", "src"), "proj.src"); + PASS(); +} + +TEST(fqn_folder_nested) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", "src/pkg/util"), "proj.src.pkg.util"); + PASS(); +} + +TEST(fqn_folder_empty_dir) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", ""), "proj"); + PASS(); +} + +TEST(fqn_folder_null_dir) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", NULL), "proj"); + PASS(); +} + +TEST(fqn_folder_null_project) { + ASSERT_FQN(cbm_pipeline_fqn_folder(NULL, "src"), ""); + PASS(); +} + +TEST(fqn_folder_backslash) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", "src\\pkg\\util"), "proj.src.pkg.util"); + PASS(); +} + +TEST(fqn_folder_backslash_mixed) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", "src/pkg\\util"), "proj.src.pkg.util"); + PASS(); +} + +TEST(fqn_folder_trailing_slash) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", "src/pkg/"), "proj.src.pkg"); + PASS(); +} + +TEST(fqn_folder_leading_slash) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", "/src/pkg"), "proj.src.pkg"); + PASS(); +} + +TEST(fqn_folder_double_slash) { + ASSERT_FQN(cbm_pipeline_fqn_folder("proj", "a//b"), "proj.a.b"); + PASS(); +} + +/* ================================================================ + * cbm_project_name_from_path + * ================================================================ */ + +TEST(project_name_unix_path) { + ASSERT_FQN(cbm_project_name_from_path("/Users/dev/my-project"), + "Users-dev-my-project"); + PASS(); +} + +TEST(project_name_windows_path) { + ASSERT_FQN(cbm_project_name_from_path("C:\\Users\\dev\\project"), + "C-Users-dev-project"); + PASS(); +} + +TEST(project_name_with_colons) { + /* Colons replaced with dashes (e.g., C: drive) */ + ASSERT_FQN(cbm_project_name_from_path("C:/dev/proj"), + "C-dev-proj"); + PASS(); +} + +TEST(project_name_multiple_slashes) { + /* Consecutive slashes become one dash */ + ASSERT_FQN(cbm_project_name_from_path("/home///user//code"), + "home-user-code"); + PASS(); +} + +TEST(project_name_leading_trailing_slashes) { + /* Leading/trailing dashes trimmed */ + ASSERT_FQN(cbm_project_name_from_path("/foo/bar/"), + "foo-bar"); + PASS(); +} + +TEST(project_name_empty) { + ASSERT_FQN(cbm_project_name_from_path(""), "root"); + PASS(); +} + +TEST(project_name_null) { + ASSERT_FQN(cbm_project_name_from_path(NULL), "root"); + PASS(); +} + +TEST(project_name_all_slashes) { + /* All separators become dashes -> all trimmed -> "root" */ + ASSERT_FQN(cbm_project_name_from_path("///"), "root"); + PASS(); +} + +TEST(project_name_single_segment) { + ASSERT_FQN(cbm_project_name_from_path("myproject"), "myproject"); + PASS(); +} + +TEST(project_name_mixed_separators) { + /* Mix of forward slash, backslash, colon */ + ASSERT_FQN(cbm_project_name_from_path("C:\\Users/dev:proj"), + "C-Users-dev-proj"); + PASS(); +} + +TEST(project_name_already_dashed) { + /* Dashes are preserved, not collapsed unless from separator conversion */ + ASSERT_FQN(cbm_project_name_from_path("/my-great-project"), + "my-great-project"); + PASS(); +} + +TEST(project_name_deep_path) { + ASSERT_FQN(cbm_project_name_from_path("/a/b/c/d/e/f/g"), + "a-b-c-d-e-f-g"); + PASS(); +} + +TEST(project_name_colon_only) { + /* Single colon -> single dash -> trimmed -> root */ + ASSERT_FQN(cbm_project_name_from_path(":"), "root"); + PASS(); +} + +TEST(project_name_backslash_only) { + ASSERT_FQN(cbm_project_name_from_path("\\"), "root"); + PASS(); +} + +TEST(project_name_consecutive_colons) { + ASSERT_FQN(cbm_project_name_from_path("a::b"), "a-b"); + PASS(); +} + +/* ================================================================ + * Suite + * ================================================================ */ + +SUITE(fqn) { + /* fqn_compute: basic extensions */ + RUN_TEST(fqn_compute_basic_go); + RUN_TEST(fqn_compute_basic_py); + RUN_TEST(fqn_compute_basic_ts); + RUN_TEST(fqn_compute_basic_js); + RUN_TEST(fqn_compute_basic_c); + RUN_TEST(fqn_compute_basic_rs); + + /* fqn_compute: nested paths */ + RUN_TEST(fqn_compute_nested_two_levels); + RUN_TEST(fqn_compute_nested_three_levels); + RUN_TEST(fqn_compute_nested_deep); + + /* fqn_compute: Python __init__.py */ + RUN_TEST(fqn_compute_init_py_with_name); + RUN_TEST(fqn_compute_init_py_without_name); + RUN_TEST(fqn_compute_init_py_empty_name); + RUN_TEST(fqn_compute_init_py_nested); + RUN_TEST(fqn_compute_init_py_root); + RUN_TEST(fqn_compute_init_py_root_no_name); + + /* fqn_compute: JS/TS index files */ + RUN_TEST(fqn_compute_index_js_with_name); + RUN_TEST(fqn_compute_index_js_without_name); + RUN_TEST(fqn_compute_index_ts_with_name); + RUN_TEST(fqn_compute_index_ts_without_name); + RUN_TEST(fqn_compute_index_ts_empty_name); + RUN_TEST(fqn_compute_index_root_with_name); + RUN_TEST(fqn_compute_index_root_no_name); + + /* fqn_compute: empty / NULL parameters */ + RUN_TEST(fqn_compute_empty_rel_path); + RUN_TEST(fqn_compute_empty_name); + RUN_TEST(fqn_compute_both_empty); + RUN_TEST(fqn_compute_null_project); + RUN_TEST(fqn_compute_null_rel_path); + RUN_TEST(fqn_compute_null_name); + RUN_TEST(fqn_compute_all_null); + RUN_TEST(fqn_compute_null_project_null_path); + + /* fqn_compute: backslash (Windows) */ + RUN_TEST(fqn_compute_backslash_simple); + RUN_TEST(fqn_compute_backslash_nested); + RUN_TEST(fqn_compute_backslash_mixed); + + /* fqn_compute: multiple extensions */ + RUN_TEST(fqn_compute_double_ext); + RUN_TEST(fqn_compute_spec_ext); + + /* fqn_compute: leading / trailing slashes */ + RUN_TEST(fqn_compute_leading_slash); + RUN_TEST(fqn_compute_trailing_slash); + RUN_TEST(fqn_compute_double_slash); + + /* fqn_compute: edge cases */ + RUN_TEST(fqn_compute_no_ext); + RUN_TEST(fqn_compute_project_only); + RUN_TEST(fqn_compute_init_not_stripped); + RUN_TEST(fqn_compute_index2_not_stripped); + + /* fqn_module */ + RUN_TEST(fqn_module_basic); + RUN_TEST(fqn_module_go); + RUN_TEST(fqn_module_init_py); + RUN_TEST(fqn_module_index_js); + RUN_TEST(fqn_module_empty_path); + RUN_TEST(fqn_module_null_path); + RUN_TEST(fqn_module_null_project); + RUN_TEST(fqn_module_deep); + + /* fqn_folder */ + RUN_TEST(fqn_folder_basic); + RUN_TEST(fqn_folder_nested); + RUN_TEST(fqn_folder_empty_dir); + RUN_TEST(fqn_folder_null_dir); + RUN_TEST(fqn_folder_null_project); + RUN_TEST(fqn_folder_backslash); + RUN_TEST(fqn_folder_backslash_mixed); + RUN_TEST(fqn_folder_trailing_slash); + RUN_TEST(fqn_folder_leading_slash); + RUN_TEST(fqn_folder_double_slash); + + /* project_name_from_path */ + RUN_TEST(project_name_unix_path); + RUN_TEST(project_name_windows_path); + RUN_TEST(project_name_with_colons); + RUN_TEST(project_name_multiple_slashes); + RUN_TEST(project_name_leading_trailing_slashes); + RUN_TEST(project_name_empty); + RUN_TEST(project_name_null); + RUN_TEST(project_name_all_slashes); + RUN_TEST(project_name_single_segment); + RUN_TEST(project_name_mixed_separators); + RUN_TEST(project_name_already_dashed); + RUN_TEST(project_name_deep_path); + RUN_TEST(project_name_colon_only); + RUN_TEST(project_name_backslash_only); + RUN_TEST(project_name_consecutive_colons); +} diff --git a/tests/test_graph_buffer.c b/tests/test_graph_buffer.c index e34387e98..d5d7ac616 100644 --- a/tests/test_graph_buffer.c +++ b/tests/test_graph_buffer.c @@ -337,9 +337,600 @@ TEST(gbuf_many_nodes) { PASS(); } +/* ── Node edge cases ───────────────────────────────────────────── */ + +TEST(gbuf_upsert_null_qn) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + /* NULL qualified_name → returns 0 (error), no node inserted */ + int64_t id = cbm_gbuf_upsert_node(gb, "Function", "foo", NULL, "f.go", 1, 5, "{}"); + ASSERT_EQ(id, 0); + ASSERT_EQ(cbm_gbuf_node_count(gb), 0); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_upsert_empty_qn) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + /* Empty string QN is valid — it's a non-NULL key */ + int64_t id = cbm_gbuf_upsert_node(gb, "Function", "anon", "", "f.go", 1, 5, "{}"); + ASSERT_GT(id, 0); + ASSERT_EQ(cbm_gbuf_node_count(gb), 1); + + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(gb, ""); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->qualified_name, ""); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_upsert_same_qn_updates_all_fields) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t id1 = cbm_gbuf_upsert_node(gb, "Function", "old_name", "pkg.fn", "old.go", 1, 10, + "{\"k\":\"v1\"}"); + int64_t id2 = cbm_gbuf_upsert_node(gb, "Method", "new_name", "pkg.fn", "new.go", 20, 30, + "{\"k\":\"v2\"}"); + ASSERT_EQ(id1, id2); + ASSERT_EQ(cbm_gbuf_node_count(gb), 1); + + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(gb, "pkg.fn"); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->label, "Method"); + ASSERT_STR_EQ(n->name, "new_name"); + ASSERT_STR_EQ(n->file_path, "new.go"); + ASSERT_EQ(n->start_line, 20); + ASSERT_EQ(n->end_line, 30); + ASSERT_STR_EQ(n->properties_json, "{\"k\":\"v2\"}"); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_upsert_long_qn) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + + /* Build a 1200-char QN */ + char long_qn[1201]; + memset(long_qn, 'a', 1200); + long_qn[1200] = '\0'; + + int64_t id = cbm_gbuf_upsert_node(gb, "Function", "long", long_qn, "f.go", 1, 5, "{}"); + ASSERT_GT(id, 0); + + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(gb, long_qn); + ASSERT_NOT_NULL(n); + ASSERT_EQ(strlen(n->qualified_name), 1200); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_find_by_qn_missing) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_upsert_node(gb, "Function", "foo", "pkg.foo", "f.go", 1, 5, "{}"); + + ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "does.not.exist")); + ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "")); + ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "pkg.FOO")); /* case sensitive */ + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_find_by_id_missing) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_upsert_node(gb, "Function", "foo", "pkg.foo", "f.go", 1, 5, "{}"); + + ASSERT_NULL(cbm_gbuf_find_by_id(gb, 0)); + ASSERT_NULL(cbm_gbuf_find_by_id(gb, -1)); + ASSERT_NULL(cbm_gbuf_find_by_id(gb, 999)); + ASSERT_NULL(cbm_gbuf_find_by_id(gb, INT64_MAX)); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_find_by_label_no_matches) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_upsert_node(gb, "Function", "foo", "pkg.foo", "f.go", 1, 5, "{}"); + + const cbm_gbuf_node_t **nodes = NULL; + int count = 0; + int rc = cbm_gbuf_find_by_label(gb, "Route", &nodes, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 0); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_find_by_name_multiple) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_upsert_node(gb, "Function", "init", "a.init", "a.go", 1, 5, "{}"); + cbm_gbuf_upsert_node(gb, "Function", "init", "b.init", "b.go", 1, 5, "{}"); + cbm_gbuf_upsert_node(gb, "Method", "init", "c.S.init", "c.go", 1, 5, "{}"); + cbm_gbuf_upsert_node(gb, "Function", "other", "d.other", "d.go", 1, 5, "{}"); + + const cbm_gbuf_node_t **nodes = NULL; + int count = 0; + int rc = cbm_gbuf_find_by_name(gb, "init", &nodes, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 3); + + rc = cbm_gbuf_find_by_name(gb, "other", &nodes, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 1); + + rc = cbm_gbuf_find_by_name(gb, "missing", &nodes, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 0); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_delete_by_label_cascades_edges) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t f = cbm_gbuf_upsert_node(gb, "Function", "fn", "pkg.fn", "f.go", 1, 5, "{}"); + int64_t c = cbm_gbuf_upsert_node(gb, "Class", "Cls", "pkg.Cls", "f.go", 10, 20, "{}"); + int64_t m = cbm_gbuf_upsert_node(gb, "Method", "meth", "pkg.Cls.meth", "f.go", 12, 18, "{}"); + + /* Edges: fn→Cls, fn→meth, meth→fn */ + cbm_gbuf_insert_edge(gb, f, c, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, f, m, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, m, f, "CALLS", "{}"); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 3); + + /* Delete all Class nodes — should remove fn→Cls edge only */ + cbm_gbuf_delete_by_label(gb, "Class"); + ASSERT_EQ(cbm_gbuf_node_count(gb), 2); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 2); /* fn→meth and meth→fn survive */ + + /* Verify edge source/target type lookups are consistent after cascade */ + const cbm_gbuf_edge_t **edges = NULL; + int ecount = 0; + cbm_gbuf_find_edges_by_source_type(gb, f, "CALLS", &edges, &ecount); + ASSERT_EQ(ecount, 1); /* only fn→meth remains */ + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_node_count_empty) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + ASSERT_EQ(cbm_gbuf_node_count(gb), 0); + ASSERT_EQ(cbm_gbuf_node_count(NULL), 0); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_upsert_100_nodes_stress) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + for (int i = 0; i < 100; i++) { + char name[32], qn[64]; + snprintf(name, sizeof(name), "f%d", i); + snprintf(qn, sizeof(qn), "pkg.f%d", i); + int64_t id = cbm_gbuf_upsert_node(gb, "Function", name, qn, "f.go", i, i + 1, "{}"); + ASSERT_GT(id, 0); + } + ASSERT_EQ(cbm_gbuf_node_count(gb), 100); + + /* Verify each node is findable */ + for (int i = 0; i < 100; i++) { + char qn[64]; + snprintf(qn, sizeof(qn), "pkg.f%d", i); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(gb, qn)); + } + + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Edge edge cases ──────────────────────────────────────────── */ + +TEST(gbuf_edge_nonexistent_endpoints) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + /* Edges with non-existent source/target IDs are accepted (no FK validation + * in the buffer — validation happens at flush time when remapping IDs) */ + int64_t eid = cbm_gbuf_insert_edge(gb, 9999, 8888, "CALLS", "{}"); + ASSERT_GT(eid, 0); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 1); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_edge_dedup_merges_properties) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(gb, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + + int64_t eid1 = cbm_gbuf_insert_edge(gb, a, b, "CALLS", "{\"weight\":1}"); + int64_t eid2 = cbm_gbuf_insert_edge(gb, a, b, "CALLS", "{\"weight\":5}"); + ASSERT_EQ(eid1, eid2); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 1); + + /* Verify second insert's properties win (merge = replace) */ + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_source_type(gb, a, "CALLS", &edges, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(edges[0]->properties_json, "{\"weight\":5}"); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_edge_count_empty) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 0); + ASSERT_EQ(cbm_gbuf_edge_count(NULL), 0); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_edge_count_by_type_missing) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "CALLS"), 0); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "UNKNOWN"), 0); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, ""), 0); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_delete_edges_preserves_other_types) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(gb, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + int64_t c = cbm_gbuf_upsert_node(gb, "Function", "c", "pkg.c", "f.go", 11, 15, "{}"); + + cbm_gbuf_insert_edge(gb, a, b, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, b, c, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, a, b, "IMPORTS", "{}"); + cbm_gbuf_insert_edge(gb, a, c, "HTTP_CALLS", "{}"); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 4); + + cbm_gbuf_delete_edges_by_type(gb, "CALLS"); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 2); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "CALLS"), 0); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "IMPORTS"), 1); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "HTTP_CALLS"), 1); + + /* Verify secondary indexes are consistent after rebuild */ + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_source_type(gb, a, "IMPORTS", &edges, &count); + ASSERT_EQ(count, 1); + cbm_gbuf_find_edges_by_target_type(gb, c, "HTTP_CALLS", &edges, &count); + ASSERT_EQ(count, 1); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_find_edges_by_target_type_multiple) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(gb, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + int64_t c = cbm_gbuf_upsert_node(gb, "Function", "c", "pkg.c", "f.go", 11, 15, "{}"); + + /* Both a and c call b */ + cbm_gbuf_insert_edge(gb, a, b, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, c, b, "CALLS", "{}"); + + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_target_type(gb, b, "CALLS", &edges, &count); + ASSERT_EQ(count, 2); + + /* No IMPORTS edges targeting b */ + cbm_gbuf_find_edges_by_target_type(gb, b, "IMPORTS", &edges, &count); + ASSERT_EQ(count, 0); + + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Merge tests ──────────────────────────────────────────────── */ + +TEST(gbuf_merge_overlapping_qns) { + cbm_gbuf_t *dst = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp"); + + /* dst has node with QN "pkg.fn" */ + cbm_gbuf_upsert_node(dst, "Function", "fn_old", "pkg.fn", "old.go", 1, 10, "{\"from\":\"dst\"}"); + cbm_gbuf_upsert_node(dst, "Function", "unique_dst", "pkg.unique_dst", "u.go", 1, 5, "{}"); + + /* src has same QN with different fields — src should win */ + cbm_gbuf_upsert_node(src, "Method", "fn_new", "pkg.fn", "new.go", 20, 30, "{\"from\":\"src\"}"); + cbm_gbuf_upsert_node(src, "Function", "unique_src", "pkg.unique_src", "s.go", 1, 5, "{}"); + + int rc = cbm_gbuf_merge(dst, src); + ASSERT_EQ(rc, 0); + + /* Total: 3 nodes (1 merged + 1 dst-only + 1 src-only) */ + ASSERT_EQ(cbm_gbuf_node_count(dst), 3); + + /* Verify src fields won for the overlapping QN */ + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(dst, "pkg.fn"); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->label, "Method"); + ASSERT_STR_EQ(n->name, "fn_new"); + ASSERT_STR_EQ(n->file_path, "new.go"); + ASSERT_EQ(n->start_line, 20); + ASSERT_STR_EQ(n->properties_json, "{\"from\":\"src\"}"); + + /* Both unique nodes present */ + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(dst, "pkg.unique_dst")); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(dst, "pkg.unique_src")); + + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + PASS(); +} + +TEST(gbuf_merge_edge_dedup) { + _Atomic int64_t shared = 1; + cbm_gbuf_t *dst = cbm_gbuf_new_shared_ids("test", "/tmp", &shared); + cbm_gbuf_t *src = cbm_gbuf_new_shared_ids("test", "/tmp", &shared); + + int64_t a = cbm_gbuf_upsert_node(dst, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(dst, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + cbm_gbuf_insert_edge(dst, a, b, "CALLS", "{}"); + + /* src has same nodes (by QN) and same edge */ + cbm_gbuf_upsert_node(src, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + cbm_gbuf_upsert_node(src, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + /* This edge's src/target IDs differ from dst's, but after merge remap + * it becomes the same (src, tgt, type) tuple → deduped */ + int64_t sa = cbm_gbuf_find_by_qn(src, "pkg.a")->id; + int64_t sb = cbm_gbuf_find_by_qn(src, "pkg.b")->id; + cbm_gbuf_insert_edge(src, sa, sb, "CALLS", "{}"); + + int rc = cbm_gbuf_merge(dst, src); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(cbm_gbuf_node_count(dst), 2); + ASSERT_EQ(cbm_gbuf_edge_count(dst), 1); /* deduped */ + + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + PASS(); +} + +TEST(gbuf_merge_empty_src_into_populated_dst) { + cbm_gbuf_t *dst = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp"); + + cbm_gbuf_upsert_node(dst, "Function", "foo", "pkg.foo", "f.go", 1, 5, "{}"); + cbm_gbuf_upsert_node(dst, "Function", "bar", "pkg.bar", "f.go", 6, 10, "{}"); + + int rc = cbm_gbuf_merge(dst, src); + ASSERT_EQ(rc, 0); + ASSERT_EQ(cbm_gbuf_node_count(dst), 2); /* unchanged */ + + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + PASS(); +} + +TEST(gbuf_merge_populated_src_into_empty_dst) { + cbm_gbuf_t *dst = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp"); + + int64_t a = cbm_gbuf_upsert_node(src, "Function", "foo", "pkg.foo", "f.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(src, "Function", "bar", "pkg.bar", "f.go", 6, 10, "{}"); + cbm_gbuf_insert_edge(src, a, b, "CALLS", "{}"); + + int rc = cbm_gbuf_merge(dst, src); + ASSERT_EQ(rc, 0); + ASSERT_EQ(cbm_gbuf_node_count(dst), 2); + ASSERT_EQ(cbm_gbuf_edge_count(dst), 1); + + /* Verify nodes are findable in dst */ + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(dst, "pkg.foo")); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(dst, "pkg.bar")); + + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + PASS(); +} + +TEST(gbuf_merge_null_args) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + ASSERT_EQ(cbm_gbuf_merge(NULL, gb), -1); + ASSERT_EQ(cbm_gbuf_merge(gb, NULL), -1); + ASSERT_EQ(cbm_gbuf_merge(NULL, NULL), -1); + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Flush / merge-into-store tests ───────────────────────────── */ + +TEST(gbuf_flush_to_store_null) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + /* NULL store returns -1 */ + ASSERT_EQ(cbm_gbuf_flush_to_store(gb, NULL), -1); + /* NULL gbuf returns -1 */ + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_EQ(cbm_gbuf_flush_to_store(NULL, store), -1); + cbm_store_close(store); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_flush_verify_store_data) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/repo"); + int64_t n1 = cbm_gbuf_upsert_node(gb, "Function", "alpha", "proj::alpha", "a.go", 1, 10, "{}"); + int64_t n2 = cbm_gbuf_upsert_node(gb, "Class", "Beta", "proj::Beta", "b.go", 1, 20, "{}"); + int64_t n3 = cbm_gbuf_upsert_node(gb, "Function", "gamma", "proj::gamma", "c.go", 1, 5, "{}"); + cbm_gbuf_insert_edge(gb, n1, n2, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, n1, n3, "IMPORTS", "{}"); + + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + int rc = cbm_gbuf_flush_to_store(gb, store); + ASSERT_EQ(rc, 0); + + /* Verify counts */ + ASSERT_EQ(cbm_store_count_nodes(store, "proj"), 3); + ASSERT_EQ(cbm_store_count_edges(store, "proj"), 2); + + /* Verify node lookup by QN */ + cbm_node_t out; + rc = cbm_store_find_node_by_qn(store, "proj", "proj::alpha", &out); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(out.name, "alpha"); + ASSERT_STR_EQ(out.label, "Function"); + cbm_node_free_fields(&out); + + rc = cbm_store_find_node_by_qn(store, "proj", "proj::Beta", &out); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(out.label, "Class"); + cbm_node_free_fields(&out); + + cbm_store_close(store); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_merge_into_store_preserves) { + /* First, flush initial data via flush_to_store */ + cbm_gbuf_t *gb1 = cbm_gbuf_new("proj", "/tmp/repo"); + cbm_gbuf_upsert_node(gb1, "Function", "existing", "proj::existing", "e.go", 1, 10, "{}"); + + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "proj", "/tmp/repo"); + int rc = cbm_gbuf_flush_to_store(gb1, store); + ASSERT_EQ(rc, 0); + ASSERT_EQ(cbm_store_count_nodes(store, "proj"), 1); + + /* Now merge_into_store with new data — should NOT delete existing */ + cbm_gbuf_t *gb2 = cbm_gbuf_new("proj", "/tmp/repo"); + cbm_gbuf_upsert_node(gb2, "Function", "newone", "proj::newone", "n.go", 1, 5, "{}"); + + rc = cbm_gbuf_merge_into_store(gb2, store); + ASSERT_EQ(rc, 0); + + /* Both nodes should exist */ + ASSERT_EQ(cbm_store_count_nodes(store, "proj"), 2); + + cbm_node_t out; + rc = cbm_store_find_node_by_qn(store, "proj", "proj::existing", &out); + ASSERT_EQ(rc, CBM_STORE_OK); + cbm_node_free_fields(&out); + + rc = cbm_store_find_node_by_qn(store, "proj", "proj::newone", &out); + ASSERT_EQ(rc, CBM_STORE_OK); + cbm_node_free_fields(&out); + + cbm_store_close(store); + cbm_gbuf_free(gb1); + cbm_gbuf_free(gb2); + PASS(); +} + +/* ── Shared ID tests ──────────────────────────────────────────── */ + +TEST(gbuf_shared_ids_unique) { + _Atomic int64_t shared = 1; + cbm_gbuf_t *gb1 = cbm_gbuf_new_shared_ids("test", "/tmp", &shared); + cbm_gbuf_t *gb2 = cbm_gbuf_new_shared_ids("test", "/tmp", &shared); + + int64_t id1 = cbm_gbuf_upsert_node(gb1, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + int64_t id2 = cbm_gbuf_upsert_node(gb2, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + int64_t id3 = cbm_gbuf_upsert_node(gb1, "Function", "c", "pkg.c", "f.go", 11, 15, "{}"); + int64_t id4 = cbm_gbuf_upsert_node(gb2, "Function", "d", "pkg.d", "f.go", 16, 20, "{}"); + + /* All IDs must be unique */ + ASSERT_NEQ(id1, id2); + ASSERT_NEQ(id1, id3); + ASSERT_NEQ(id1, id4); + ASSERT_NEQ(id2, id3); + ASSERT_NEQ(id2, id4); + ASSERT_NEQ(id3, id4); + + /* IDs should be sequential from the shared source */ + ASSERT_EQ(id1, 1); + ASSERT_EQ(id2, 2); + ASSERT_EQ(id3, 3); + ASSERT_EQ(id4, 4); + + cbm_gbuf_free(gb1); + cbm_gbuf_free(gb2); + PASS(); +} + +TEST(gbuf_shared_ids_null_fallback) { + /* NULL id_source → behaves like cbm_gbuf_new() */ + cbm_gbuf_t *gb = cbm_gbuf_new_shared_ids("test", "/tmp", NULL); + ASSERT_NOT_NULL(gb); + int64_t id = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + ASSERT_EQ(id, 1); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_next_id_set_next_id_roundtrip) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + + /* Initial next_id is 1 */ + ASSERT_EQ(cbm_gbuf_next_id(gb), 1); + + /* Insert a node, next_id advances */ + cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + ASSERT_EQ(cbm_gbuf_next_id(gb), 2); + + /* Set next_id to an arbitrary value */ + cbm_gbuf_set_next_id(gb, 100); + ASSERT_EQ(cbm_gbuf_next_id(gb), 100); + + /* Next insert uses the new base */ + int64_t id = cbm_gbuf_upsert_node(gb, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + ASSERT_EQ(id, 100); + ASSERT_EQ(cbm_gbuf_next_id(gb), 101); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_next_id_null_safe) { + /* next_id on NULL returns 1 (default) */ + ASSERT_EQ(cbm_gbuf_next_id(NULL), 1); + /* set_next_id on NULL should not crash */ + cbm_gbuf_set_next_id(NULL, 42); + PASS(); +} + +/* ── Flush edge case: orphan edges skipped ────────────────────── */ + +TEST(gbuf_flush_skips_orphan_edges) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/repo"); + int64_t n1 = cbm_gbuf_upsert_node(gb, "Function", "real", "proj::real", "f.go", 1, 5, "{}"); + + /* Valid edge */ + cbm_gbuf_insert_edge(gb, n1, n1, "CALLS", "{}"); + /* Orphan edge — target ID 9999 does not map to any node */ + cbm_gbuf_insert_edge(gb, n1, 9999, "CALLS", "{}"); + + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + int rc = cbm_gbuf_flush_to_store(gb, store); + ASSERT_EQ(rc, 0); + + /* Only the valid edge should land in the store */ + ASSERT_EQ(cbm_store_count_nodes(store, "proj"), 1); + ASSERT_EQ(cbm_store_count_edges(store, "proj"), 1); + + cbm_store_close(store); + cbm_gbuf_free(gb); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ SUITE(graph_buffer) { + /* Original tests */ RUN_TEST(gbuf_create_free); RUN_TEST(gbuf_free_null); RUN_TEST(gbuf_upsert_node); @@ -358,4 +949,44 @@ SUITE(graph_buffer) { RUN_TEST(gbuf_dump_empty); RUN_TEST(gbuf_flush_to_store); RUN_TEST(gbuf_many_nodes); + + /* Node edge cases */ + RUN_TEST(gbuf_upsert_null_qn); + RUN_TEST(gbuf_upsert_empty_qn); + RUN_TEST(gbuf_upsert_same_qn_updates_all_fields); + RUN_TEST(gbuf_upsert_long_qn); + RUN_TEST(gbuf_find_by_qn_missing); + RUN_TEST(gbuf_find_by_id_missing); + RUN_TEST(gbuf_find_by_label_no_matches); + RUN_TEST(gbuf_find_by_name_multiple); + RUN_TEST(gbuf_delete_by_label_cascades_edges); + RUN_TEST(gbuf_node_count_empty); + RUN_TEST(gbuf_upsert_100_nodes_stress); + + /* Edge edge cases */ + RUN_TEST(gbuf_edge_nonexistent_endpoints); + RUN_TEST(gbuf_edge_dedup_merges_properties); + RUN_TEST(gbuf_edge_count_empty); + RUN_TEST(gbuf_edge_count_by_type_missing); + RUN_TEST(gbuf_delete_edges_preserves_other_types); + RUN_TEST(gbuf_find_edges_by_target_type_multiple); + + /* Merge tests */ + RUN_TEST(gbuf_merge_overlapping_qns); + RUN_TEST(gbuf_merge_edge_dedup); + RUN_TEST(gbuf_merge_empty_src_into_populated_dst); + RUN_TEST(gbuf_merge_populated_src_into_empty_dst); + RUN_TEST(gbuf_merge_null_args); + + /* Flush/merge-into-store tests */ + RUN_TEST(gbuf_flush_to_store_null); + RUN_TEST(gbuf_flush_verify_store_data); + RUN_TEST(gbuf_merge_into_store_preserves); + RUN_TEST(gbuf_flush_skips_orphan_edges); + + /* Shared ID tests */ + RUN_TEST(gbuf_shared_ids_unique); + RUN_TEST(gbuf_shared_ids_null_fallback); + RUN_TEST(gbuf_next_id_set_next_id_roundtrip); + RUN_TEST(gbuf_next_id_null_safe); } diff --git a/tests/test_hash_table.c b/tests/test_hash_table.c index eee9a56bc..c452b5896 100644 --- a/tests/test_hash_table.c +++ b/tests/test_hash_table.c @@ -174,6 +174,214 @@ TEST(ht_power_of_two_capacity) { PASS(); } +/* ── Edge case tests ───────────────────────────────────────────── */ + +TEST(ht_get_key_returns_stored_pointer) { + CBMHashTable *ht = cbm_ht_create(8); + /* Use a heap key so we can verify the stored pointer differs from lookup */ + char stored_key[] = "mykey"; + int v = 1; + cbm_ht_set(ht, stored_key, &v); + /* Look up with a different buffer containing same string */ + char lookup[] = "mykey"; + const char *got = cbm_ht_get_key(ht, lookup); + ASSERT_NOT_NULL(got); + ASSERT_STR_EQ(got, "mykey"); + /* Should return the pointer we inserted with, not the lookup buffer */ + ASSERT_EQ((uintptr_t)got, (uintptr_t)stored_key); + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_get_key_missing) { + CBMHashTable *ht = cbm_ht_create(8); + const char *got = cbm_ht_get_key(ht, "nonexistent"); + ASSERT_NULL(got); + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_get_key_null_ht) { + const char *got = cbm_ht_get_key(NULL, "key"); + ASSERT_NULL(got); + PASS(); +} + +TEST(ht_get_key_null_key) { + CBMHashTable *ht = cbm_ht_create(8); + const char *got = cbm_ht_get_key(ht, NULL); + ASSERT_NULL(got); + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_count_null) { + ASSERT_EQ(cbm_ht_count(NULL), 0); + PASS(); +} + +TEST(ht_free_null) { + /* Should not crash */ + cbm_ht_free(NULL); + PASS(); +} + +TEST(ht_clear_null) { + /* Should not crash */ + cbm_ht_clear(NULL); + PASS(); +} + +TEST(ht_foreach_null_ht) { + /* Should not crash */ + cbm_ht_foreach(NULL, sum_values, NULL); + PASS(); +} + +TEST(ht_foreach_null_fn) { + CBMHashTable *ht = cbm_ht_create(8); + int v = 1; + cbm_ht_set(ht, "a", &v); + /* Should not crash with NULL function pointer */ + cbm_ht_foreach(ht, NULL, NULL); + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_delete_readd_stress) { + /* Stress backward-shift deletion: add 100, delete all, re-add all */ + CBMHashTable *ht = cbm_ht_create(8); + char keys[100][16]; + int vals[100]; + for (int i = 0; i < 100; i++) { + snprintf(keys[i], sizeof(keys[i]), "key_%03d", i); + vals[i] = i; + cbm_ht_set(ht, keys[i], &vals[i]); + } + ASSERT_EQ(cbm_ht_count(ht), 100); + + /* Delete all */ + for (int i = 0; i < 100; i++) { + void *removed = cbm_ht_delete(ht, keys[i]); + ASSERT_EQ(removed, &vals[i]); + } + ASSERT_EQ(cbm_ht_count(ht), 0); + + /* Re-add all — exercises slots freed by backward shift */ + for (int i = 0; i < 100; i++) { + cbm_ht_set(ht, keys[i], &vals[i]); + } + ASSERT_EQ(cbm_ht_count(ht), 100); + for (int i = 0; i < 100; i++) { + ASSERT_EQ(*(int *)cbm_ht_get(ht, keys[i]), i); + } + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_interleaved_insert_delete) { + /* Insert 100, delete odd-indexed, verify even-indexed survive */ + CBMHashTable *ht = cbm_ht_create(8); + char keys[100][16]; + int vals[100]; + for (int i = 0; i < 100; i++) { + snprintf(keys[i], sizeof(keys[i]), "entry_%03d", i); + vals[i] = i * 7; + cbm_ht_set(ht, keys[i], &vals[i]); + } + ASSERT_EQ(cbm_ht_count(ht), 100); + + /* Delete all odd-indexed entries */ + for (int i = 1; i < 100; i += 2) { + cbm_ht_delete(ht, keys[i]); + } + ASSERT_EQ(cbm_ht_count(ht), 50); + + /* Verify even entries intact, odd entries gone */ + for (int i = 0; i < 100; i++) { + void *got = cbm_ht_get(ht, keys[i]); + if (i % 2 == 0) { + ASSERT_NOT_NULL(got); + ASSERT_EQ(*(int *)got, i * 7); + } else { + ASSERT_NULL(got); + } + } + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_create_capacity_zero) { + /* Capacity 0 should be rounded up to minimum (8) */ + CBMHashTable *ht = cbm_ht_create(0); + ASSERT_NOT_NULL(ht); + ASSERT_GTE(ht->capacity, 8); + ASSERT_EQ(ht->capacity & (ht->capacity - 1), 0); + /* Should be usable */ + int v = 42; + cbm_ht_set(ht, "test", &v); + ASSERT_EQ(*(int *)cbm_ht_get(ht, "test"), 42); + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_create_capacity_one) { + /* Capacity 1 should be rounded up to minimum (8) */ + CBMHashTable *ht = cbm_ht_create(1); + ASSERT_NOT_NULL(ht); + ASSERT_GTE(ht->capacity, 8); + int v = 99; + cbm_ht_set(ht, "k", &v); + ASSERT_EQ(*(int *)cbm_ht_get(ht, "k"), 99); + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_stress_10000) { + CBMHashTable *ht = cbm_ht_create(16); + char keys[10000][24]; + int vals[10000]; + for (int i = 0; i < 10000; i++) { + snprintf(keys[i], sizeof(keys[i]), "stress_key_%06d", i); + vals[i] = i; + cbm_ht_set(ht, keys[i], &vals[i]); + } + ASSERT_EQ(cbm_ht_count(ht), 10000); + /* Verify all */ + for (int i = 0; i < 10000; i++) { + void *got = cbm_ht_get(ht, keys[i]); + ASSERT_NOT_NULL(got); + ASSERT_EQ(*(int *)got, i); + } + /* Capacity should still be power of 2 */ + ASSERT_EQ(ht->capacity & (ht->capacity - 1), 0); + cbm_ht_free(ht); + PASS(); +} + +TEST(ht_robin_hood_bounded_psl) { + /* Max PSL should be bounded — Robin Hood keeps probes short. + * With 10000 entries and power-of-2 sizing, max PSL should be < 32. */ + CBMHashTable *ht = cbm_ht_create(16); + char key[24]; + int val = 0; + for (int i = 0; i < 10000; i++) { + snprintf(key, sizeof(key), "rh_%06d", i); + cbm_ht_set(ht, key, &val); + } + /* Scan entries, find max PSL */ + uint32_t max_psl = 0; + for (uint32_t i = 0; i < ht->capacity; i++) { + if (ht->entries[i].psl > max_psl) { + max_psl = ht->entries[i].psl; + } + } + /* Robin Hood guarantees bounded PSL — should be well under 32 */ + ASSERT_LT(max_psl, 32); + cbm_ht_free(ht); + PASS(); +} + SUITE(hash_table) { RUN_TEST(ht_create_free); RUN_TEST(ht_set_get); @@ -189,4 +397,20 @@ SUITE(hash_table) { RUN_TEST(ht_empty_string_key); RUN_TEST(ht_long_key); RUN_TEST(ht_power_of_two_capacity); + /* Edge cases */ + RUN_TEST(ht_get_key_returns_stored_pointer); + RUN_TEST(ht_get_key_missing); + RUN_TEST(ht_get_key_null_ht); + RUN_TEST(ht_get_key_null_key); + RUN_TEST(ht_count_null); + RUN_TEST(ht_free_null); + RUN_TEST(ht_clear_null); + RUN_TEST(ht_foreach_null_ht); + RUN_TEST(ht_foreach_null_fn); + RUN_TEST(ht_delete_readd_stress); + RUN_TEST(ht_interleaved_insert_delete); + RUN_TEST(ht_create_capacity_zero); + RUN_TEST(ht_create_capacity_one); + RUN_TEST(ht_stress_10000); + RUN_TEST(ht_robin_hood_bounded_psl); } diff --git a/tests/test_main.c b/tests/test_main.c index c6f1f05ef..0667d32a6 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -32,6 +32,7 @@ extern void suite_discover(void); extern void suite_graph_buffer(void); extern void suite_registry(void); extern void suite_pipeline(void); +extern void suite_fqn(void); extern void suite_watcher(void); extern void suite_lz4(void); extern void suite_sqlite_writer(void); @@ -50,6 +51,7 @@ extern void suite_parallel(void); extern void suite_mem(void); extern void suite_ui(void); extern void suite_security(void); +extern void suite_yaml(void); extern void suite_integration(void); int main(void) { @@ -92,6 +94,7 @@ int main(void) { /* Pipeline (M8) */ RUN_SUITE(registry); RUN_SUITE(pipeline); + RUN_SUITE(fqn); /* Watcher (M10) */ RUN_SUITE(watcher); @@ -138,6 +141,9 @@ int main(void) { /* Security defenses */ RUN_SUITE(security); + /* YAML parser */ + RUN_SUITE(yaml); + /* Integration (end-to-end) */ RUN_SUITE(integration); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index bb7238ad9..77ec9c996 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1276,6 +1276,279 @@ TEST(snippet_include_neighbors_enabled) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * JSON-RPC PARSING — EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(jsonrpc_parse_empty_string) { + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse("", &req); + ASSERT_EQ(rc, -1); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_missing_jsonrpc_field) { + /* jsonrpc field absent — parser defaults to "2.0" if method present */ + const char *line = "{\"id\":1,\"method\":\"initialize\",\"params\":{}}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_STR_EQ(req.jsonrpc, "2.0"); + ASSERT_STR_EQ(req.method, "initialize"); + ASSERT_TRUE(req.has_id); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_missing_method) { + /* method is required — should fail */ + const char *line = "{\"jsonrpc\":\"2.0\",\"id\":1,\"params\":{}}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, -1); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_string_id) { + /* JSON-RPC spec allows string IDs; parser converts via strtol */ + const char *line = "{\"jsonrpc\":\"2.0\",\"id\":\"99\",\"method\":\"tools/list\"}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(req.has_id); + ASSERT_EQ(req.id, 99); + ASSERT_STR_EQ(req.method, "tools/list"); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_no_params) { + /* Request with no params field — params_raw should be NULL */ + const char *line = "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/list\"}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_NULL(req.params_raw); + ASSERT_EQ(req.id, 5); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_extra_whitespace) { + /* Leading/trailing whitespace and internal spacing in JSON */ + const char *line = " { \"jsonrpc\" : \"2.0\" , \"id\" : 7 , \"method\" : \"ping\" } "; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_EQ(req.id, 7); + ASSERT_STR_EQ(req.method, "ping"); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_array_not_object) { + /* JSON array at root — not a valid JSON-RPC request */ + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse("[1,2,3]", &req); + ASSERT_EQ(rc, -1); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * ARGUMENT EXTRACTION — EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(mcp_get_string_arg_empty_json) { + /* Empty JSON string — yyjson_read fails → NULL */ + char *val = cbm_mcp_get_string_arg("", "key"); + ASSERT_NULL(val); + PASS(); +} + +TEST(mcp_get_string_arg_empty_object) { + /* Valid JSON with no keys → NULL for any key */ + char *val = cbm_mcp_get_string_arg("{}", "key"); + ASSERT_NULL(val); + PASS(); +} + +TEST(mcp_get_string_arg_nested_value) { + /* Value is an object, not a string → should return NULL */ + const char *args = "{\"config\":{\"nested\":true},\"name\":\"hello\"}"; + char *val = cbm_mcp_get_string_arg(args, "config"); + ASSERT_NULL(val); /* not a string type */ + val = cbm_mcp_get_string_arg(args, "name"); + ASSERT_NOT_NULL(val); + ASSERT_STR_EQ(val, "hello"); + free(val); + PASS(); +} + +TEST(mcp_get_string_arg_int_value) { + /* Value is an integer, not a string → NULL */ + char *val = cbm_mcp_get_string_arg("{\"count\":42}", "count"); + ASSERT_NULL(val); + PASS(); +} + +TEST(mcp_get_int_arg_empty_json) { + int val = cbm_mcp_get_int_arg("", "key", 99); + ASSERT_EQ(val, 99); + PASS(); +} + +TEST(mcp_get_int_arg_string_value) { + /* Value is a string, not int → should return default */ + int val = cbm_mcp_get_int_arg("{\"limit\":\"ten\"}", "limit", 5); + ASSERT_EQ(val, 5); + PASS(); +} + +TEST(mcp_get_int_arg_bool_value) { + /* Value is a bool, not int → default */ + int val = cbm_mcp_get_int_arg("{\"flag\":true}", "flag", -1); + ASSERT_EQ(val, -1); + PASS(); +} + +TEST(mcp_get_bool_arg_empty_json) { + bool val = cbm_mcp_get_bool_arg("", "key"); + ASSERT_FALSE(val); + PASS(); +} + +TEST(mcp_get_bool_arg_int_value) { + /* Value is int 1, not bool → should return false */ + bool val = cbm_mcp_get_bool_arg("{\"flag\":1}", "flag"); + ASSERT_FALSE(val); + PASS(); +} + +TEST(mcp_get_tool_name_empty_json) { + char *name = cbm_mcp_get_tool_name(""); + ASSERT_NULL(name); + PASS(); +} + +TEST(mcp_get_tool_name_missing_name) { + char *name = cbm_mcp_get_tool_name("{\"arguments\":{}}"); + ASSERT_NULL(name); + PASS(); +} + +TEST(mcp_get_arguments_empty_json) { + char *args = cbm_mcp_get_arguments(""); + ASSERT_NULL(args); + PASS(); +} + +TEST(mcp_get_arguments_no_arguments_key) { + /* No "arguments" key → returns "{}" */ + char *args = cbm_mcp_get_arguments("{\"name\":\"tool\"}"); + ASSERT_NOT_NULL(args); + ASSERT_STR_EQ(args, "{}"); + free(args); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * FILE URI PARSING — EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(parse_file_uri_http_scheme) { + char path[256]; + ASSERT_FALSE(cbm_parse_file_uri("http://example.com/path", path, sizeof(path))); + ASSERT_STR_EQ(path, ""); + PASS(); +} + +TEST(parse_file_uri_ftp_scheme) { + char path[256]; + ASSERT_FALSE(cbm_parse_file_uri("ftp://server/file.txt", path, sizeof(path))); + ASSERT_STR_EQ(path, ""); + PASS(); +} + +TEST(parse_file_uri_buffer_too_small) { + char path[5]; /* only 5 bytes — path gets truncated */ + ASSERT_TRUE(cbm_parse_file_uri("file:///usr/local/bin", path, sizeof(path))); + /* snprintf truncates to 4 chars + NUL */ + ASSERT_EQ(strlen(path), 4); + ASSERT_STR_EQ(path, "/usr"); + PASS(); +} + +TEST(parse_file_uri_spaces_in_path) { + char path[256]; + ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/my%20project", path, sizeof(path))); + /* Raw percent-encoding is preserved (not decoded) */ + ASSERT_STR_EQ(path, "/home/user/my%20project"); + PASS(); +} + +TEST(parse_file_uri_null_out_path) { + /* NULL out_path — should not crash */ + ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", NULL, 256)); + PASS(); +} + +TEST(parse_file_uri_zero_size) { + char path[256] = "garbage"; + /* out_size=0 → should fail safely */ + ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", path, 0)); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * SERVER HANDLE — EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(server_handle_invalid_json) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = cbm_mcp_server_handle(srv, "this is not json at all"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "-32700")); /* Parse error */ + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(server_handle_empty_object) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + /* Valid JSON but no method field → parse error */ + char *resp = cbm_mcp_server_handle(srv, "{}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(server_handle_tools_call_missing_name) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + /* tools/call with no tool name in params */ + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\"," + "\"params\":{\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + /* Should return error about unknown/missing tool */ + ASSERT_NOT_NULL(strstr(resp, "\"id\":50")); + ASSERT_TRUE(strstr(resp, "error") || strstr(resp, "isError") || strstr(resp, "unknown")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * POLL/GETLINE FILE* BUFFERING FIX * ══════════════════════════════════════════════════════════════════ */ @@ -1364,6 +1637,15 @@ SUITE(mcp) { RUN_TEST(jsonrpc_parse_invalid); RUN_TEST(jsonrpc_parse_tools_call); + /* JSON-RPC parsing — edge cases */ + RUN_TEST(jsonrpc_parse_empty_string); + RUN_TEST(jsonrpc_parse_missing_jsonrpc_field); + RUN_TEST(jsonrpc_parse_missing_method); + RUN_TEST(jsonrpc_parse_string_id); + RUN_TEST(jsonrpc_parse_no_params); + RUN_TEST(jsonrpc_parse_extra_whitespace); + RUN_TEST(jsonrpc_parse_array_not_object); + /* JSON-RPC formatting */ RUN_TEST(jsonrpc_format_response); RUN_TEST(jsonrpc_format_error); @@ -1382,12 +1664,32 @@ SUITE(mcp) { RUN_TEST(mcp_get_int_arg); RUN_TEST(mcp_get_bool_arg); + /* Argument extraction — edge cases */ + RUN_TEST(mcp_get_string_arg_empty_json); + RUN_TEST(mcp_get_string_arg_empty_object); + RUN_TEST(mcp_get_string_arg_nested_value); + RUN_TEST(mcp_get_string_arg_int_value); + RUN_TEST(mcp_get_int_arg_empty_json); + RUN_TEST(mcp_get_int_arg_string_value); + RUN_TEST(mcp_get_int_arg_bool_value); + RUN_TEST(mcp_get_bool_arg_empty_json); + RUN_TEST(mcp_get_bool_arg_int_value); + RUN_TEST(mcp_get_tool_name_empty_json); + RUN_TEST(mcp_get_tool_name_missing_name); + RUN_TEST(mcp_get_arguments_empty_json); + RUN_TEST(mcp_get_arguments_no_arguments_key); + /* Server protocol handling */ RUN_TEST(server_handle_initialize); RUN_TEST(server_handle_initialized_notification); RUN_TEST(server_handle_tools_list); RUN_TEST(server_handle_unknown_method); + /* Server handle — edge cases */ + RUN_TEST(server_handle_invalid_json); + RUN_TEST(server_handle_empty_object); + RUN_TEST(server_handle_tools_call_missing_name); + /* Tool handlers */ RUN_TEST(tool_list_projects_empty); RUN_TEST(tool_get_graph_schema_empty); @@ -1426,6 +1728,14 @@ SUITE(mcp) { RUN_TEST(parse_file_uri_windows); RUN_TEST(parse_file_uri_invalid); + /* URI helpers — edge cases */ + RUN_TEST(parse_file_uri_http_scheme); + RUN_TEST(parse_file_uri_ftp_scheme); + RUN_TEST(parse_file_uri_buffer_too_small); + RUN_TEST(parse_file_uri_spaces_in_path); + RUN_TEST(parse_file_uri_null_out_path); + RUN_TEST(parse_file_uri_zero_size); + /* Poll/getline FILE* buffering fix */ #ifndef _WIN32 RUN_TEST(mcp_server_run_rapid_messages); diff --git a/tests/test_mem.c b/tests/test_mem.c index 16d90e88e..a9385516f 100644 --- a/tests/test_mem.c +++ b/tests/test_mem.c @@ -92,6 +92,203 @@ TEST(mem_budget_check) { PASS(); } +/* ── mem budget edge-case tests ─────────────────────────────── */ + +TEST(mem_worker_budget_zero_workers) { + cbm_mem_init(0.5); + size_t budget = cbm_mem_budget(); + /* 0 workers clamps to 1 → worker_budget == full budget */ + size_t wb = cbm_mem_worker_budget(0); + ASSERT_EQ(wb, budget); + PASS(); +} + +TEST(mem_worker_budget_negative_workers) { + cbm_mem_init(0.5); + size_t budget = cbm_mem_budget(); + /* Negative workers clamps to 1 → worker_budget == full budget */ + size_t wb = cbm_mem_worker_budget(-5); + ASSERT_EQ(wb, budget); + PASS(); +} + +TEST(mem_worker_budget_one_worker) { + cbm_mem_init(0.5); + size_t budget = cbm_mem_budget(); + /* 1 worker → equals full budget */ + size_t wb = cbm_mem_worker_budget(1); + ASSERT_EQ(wb, budget); + PASS(); +} + +TEST(mem_worker_budget_many_workers) { + cbm_mem_init(0.5); + /* 1000 workers → should produce non-zero result (budget is huge) */ + size_t wb = cbm_mem_worker_budget(1000); + ASSERT_GT(wb, 0); + /* Must be budget / 1000 */ + ASSERT_EQ(wb, cbm_mem_budget() / 1000); + PASS(); +} + +TEST(mem_over_budget_low_rss) { + cbm_mem_init(0.5); + /* We're a test process with tiny RSS — should not be over budget */ + bool over = cbm_mem_over_budget(); + ASSERT_FALSE(over); + PASS(); +} + +/* ── RSS tracking tests ───────────────────────────────────────── */ + +TEST(mem_rss_positive) { + cbm_mem_init(0.5); + /* A running process always has nonzero RSS */ + size_t rss = cbm_mem_rss(); + ASSERT_GT(rss, 0); + PASS(); +} + +TEST(mem_peak_rss_gte_rss) { + cbm_mem_init(0.5); + size_t rss = cbm_mem_rss(); + size_t peak = cbm_mem_peak_rss(); + /* Peak must be >= current RSS */ + ASSERT_GTE(peak, rss); + PASS(); +} + +TEST(mem_rss_increases_after_alloc) { + cbm_mem_init(0.5); + + /* Allocate 10 MB and touch all pages */ + size_t alloc_size = 10 * 1024 * 1024; + char *p = (char *)malloc(alloc_size); + ASSERT_NOT_NULL(p); + memset(p, 0xBE, alloc_size); + + size_t rss_after = cbm_mem_rss(); + /* RSS must be non-zero after allocating 10MB */ + ASSERT_GT(rss_after, 0); + + free(p); + PASS(); +} + +TEST(mem_collect_no_crash) { + cbm_mem_init(0.5); + /* collect() must not crash even with nothing to collect */ + cbm_mem_collect(); + PASS(); +} + +TEST(mem_collect_rss_still_positive) { + cbm_mem_init(0.5); + cbm_mem_collect(); + /* After collect, RSS must still be > 0 (we're alive) */ + size_t rss = cbm_mem_rss(); + ASSERT_GT(rss, 0); + PASS(); +} + +/* ── Memory pressure simulation ───────────────────────────────── */ + +TEST(mem_progressive_alloc_rss_increases) { + cbm_mem_init(0.5); + + size_t chunk_size = 2 * 1024 * 1024; /* 2 MB chunks */ + int nchunks = 5; + char *chunks[5]; + + for (int i = 0; i < nchunks; i++) { + chunks[i] = (char *)malloc(chunk_size); + ASSERT_NOT_NULL(chunks[i]); + memset(chunks[i], (unsigned char)(0xA0 + i), chunk_size); + } + + size_t rss_peak = cbm_mem_rss(); + ASSERT_GT(rss_peak, 0); + + for (int i = 0; i < nchunks; i++) { + free(chunks[i]); + } + cbm_mem_collect(); + + /* After free + collect, RSS may or may not drop, but must not crash */ + size_t rss_end = cbm_mem_rss(); + ASSERT_GT(rss_end, 0); + PASS(); +} + +TEST(mem_free_and_collect_no_crash) { + cbm_mem_init(0.5); + + /* Allocate, free, collect — verify no crash */ + size_t sz = 4 * 1024 * 1024; + char *p = (char *)malloc(sz); + ASSERT_NOT_NULL(p); + memset(p, 0xCC, sz); + free(p); + cbm_mem_collect(); + + /* RSS must remain positive */ + ASSERT_GT(cbm_mem_rss(), 0); + PASS(); +} + +TEST(mem_multiple_collect_idempotent) { + cbm_mem_init(0.5); + + /* Multiple collect() calls must be idempotent and not crash */ + cbm_mem_collect(); + cbm_mem_collect(); + cbm_mem_collect(); + + size_t rss = cbm_mem_rss(); + ASSERT_GT(rss, 0); + PASS(); +} + +/* ── Init edge cases ──────────────────────────────────────────── */ +/* NOTE: cbm_mem_init uses atomic CAS — only the very first call in the + * process takes effect. Since mem_rss_tracking runs first with 0.5, + * all subsequent init calls are no-ops. We verify that they don't + * crash and that the budget remains unchanged. */ + +TEST(mem_init_zero_fraction) { + /* First init already happened with 0.5 — this is a no-op */ + size_t budget_before = cbm_mem_budget(); + cbm_mem_init(0.0); + size_t budget_after = cbm_mem_budget(); + /* Budget must not change (second init is no-op) */ + ASSERT_EQ(budget_before, budget_after); + PASS(); +} + +TEST(mem_init_negative_fraction) { + size_t budget_before = cbm_mem_budget(); + cbm_mem_init(-1.0); + size_t budget_after = cbm_mem_budget(); + ASSERT_EQ(budget_before, budget_after); + PASS(); +} + +TEST(mem_init_over_one_fraction) { + size_t budget_before = cbm_mem_budget(); + cbm_mem_init(1.5); + size_t budget_after = cbm_mem_budget(); + ASSERT_EQ(budget_before, budget_after); + PASS(); +} + +TEST(mem_init_second_call_noop) { + size_t budget_before = cbm_mem_budget(); + cbm_mem_init(0.9); /* different fraction — but it's a no-op */ + size_t budget_after = cbm_mem_budget(); + ASSERT_EQ(budget_before, budget_after); + PASS(); +} + /* ── Arena integration tests ──────────────────────────────────── */ TEST(arena_alloc_and_destroy) { @@ -436,6 +633,27 @@ SUITE(mem) { RUN_TEST(mem_rss_tracking); RUN_TEST(mem_collect_reclaims); RUN_TEST(mem_budget_check); + /* Budget edge cases */ + RUN_TEST(mem_worker_budget_zero_workers); + RUN_TEST(mem_worker_budget_negative_workers); + RUN_TEST(mem_worker_budget_one_worker); + RUN_TEST(mem_worker_budget_many_workers); + RUN_TEST(mem_over_budget_low_rss); + /* RSS tracking */ + RUN_TEST(mem_rss_positive); + RUN_TEST(mem_peak_rss_gte_rss); + RUN_TEST(mem_rss_increases_after_alloc); + RUN_TEST(mem_collect_no_crash); + RUN_TEST(mem_collect_rss_still_positive); + /* Memory pressure simulation */ + RUN_TEST(mem_progressive_alloc_rss_increases); + RUN_TEST(mem_free_and_collect_no_crash); + RUN_TEST(mem_multiple_collect_idempotent); + /* Init edge cases */ + RUN_TEST(mem_init_zero_fraction); + RUN_TEST(mem_init_negative_fraction); + RUN_TEST(mem_init_over_one_fraction); + RUN_TEST(mem_init_second_call_noop); /* Arena integration */ RUN_TEST(arena_alloc_and_destroy); RUN_TEST(arena_grow_tracks_sizes); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 21b38ca07..569fa04e2 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5291,6 +5291,437 @@ TEST(pipeline_lock_release_allows_contender) { PASS(); } +/* ── Resource management & internal helper tests ─────────────────── */ + +TEST(pipeline_empty_path) { + /* Empty string repo path — should handle gracefully */ + cbm_pipeline_t *p = cbm_pipeline_new("", NULL, CBM_MODE_FULL); + /* Implementation may return NULL or a valid pipeline with empty project name. + * Either behavior is acceptable — the key is no crash. */ + if (p) { + cbm_pipeline_free(p); + } + PASS(); +} + +TEST(pipeline_project_name_content) { + /* Verify project name is derived from the repo_path */ + cbm_pipeline_t *p = cbm_pipeline_new("/home/user/my-project", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + const char *name = cbm_pipeline_project_name(p); + ASSERT_NOT_NULL(name); + ASSERT_TRUE(strlen(name) > 0); + /* Should contain "my-project" as part of the derived name */ + ASSERT_TRUE(strstr(name, "my-project") != NULL); + cbm_pipeline_free(p); + PASS(); +} + +TEST(pipeline_cancel_sets_flag) { + /* Verify cancel sets the flag so subsequent run exits early */ + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + /* Cancel before run */ + cbm_pipeline_cancel(p); + /* Cancelled pipeline should return quickly (either -1 from cancel or from + * missing path — both are acceptable; key is no hang) */ + int rc = cbm_pipeline_run(p); + ASSERT_EQ(rc, -1); + cbm_pipeline_free(p); + PASS(); +} + +TEST(pipeline_double_cancel) { + /* Calling cancel twice should not crash */ + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_cancel(p); + cbm_pipeline_cancel(p); + cbm_pipeline_free(p); + PASS(); +} + +TEST(pipeline_double_free_prevention) { + /* free(NULL) after free should not crash. We can't truly double-free + * the same pointer, but we verify NULL is safe as documented. */ + cbm_pipeline_free(NULL); + cbm_pipeline_free(NULL); + PASS(); +} + +TEST(trackable_source_files) { + /* Common source extensions are trackable */ + ASSERT_TRUE(cbm_is_trackable_file("main.go")); + ASSERT_TRUE(cbm_is_trackable_file("src/handler.py")); + ASSERT_TRUE(cbm_is_trackable_file("lib/server.js")); + ASSERT_TRUE(cbm_is_trackable_file("components/App.tsx")); + ASSERT_TRUE(cbm_is_trackable_file("pkg/svc.rs")); + ASSERT_TRUE(cbm_is_trackable_file("config.yaml")); + PASS(); +} + +TEST(trackable_rejects_images) { + /* Image and binary files are not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("logo.png")); + ASSERT_FALSE(cbm_is_trackable_file("photo.jpg")); + ASSERT_FALSE(cbm_is_trackable_file("icon.gif")); + ASSERT_FALSE(cbm_is_trackable_file("diagram.svg")); + PASS(); +} + +TEST(trackable_rejects_minified) { + /* Minified files are not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("bundle.min.js")); + ASSERT_FALSE(cbm_is_trackable_file("style.min.css")); + PASS(); +} + +TEST(trackable_rejects_vendor_dirs) { + /* Files in vendor/node_modules/__pycache__ are not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("vendor/github.com/lib/dep.go")); + ASSERT_FALSE(cbm_is_trackable_file("node_modules/lodash/index.js")); + ASSERT_FALSE(cbm_is_trackable_file("__pycache__/module.pyc")); + ASSERT_FALSE(cbm_is_trackable_file(".git/objects/pack/data")); + PASS(); +} + +TEST(trackable_rejects_lock_files) { + /* Lock files are not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("package-lock.json")); + ASSERT_FALSE(cbm_is_trackable_file("go.sum")); + ASSERT_FALSE(cbm_is_trackable_file("yarn.lock")); + PASS(); +} + +TEST(test_path_directory_patterns) { + /* Test directory patterns: __tests__, test/, tests/, spec/ */ + ASSERT_TRUE(cbm_is_test_path("__tests__/Component.js")); + ASSERT_TRUE(cbm_is_test_path("tests/test_main.py")); + ASSERT_TRUE(cbm_is_test_path("spec/handler_spec.rb")); + PASS(); +} + +TEST(test_path_suffix_patterns) { + /* Various language-specific test suffix patterns */ + ASSERT_TRUE(cbm_is_test_path("handler.spec.ts")); + ASSERT_TRUE(cbm_is_test_path("handler.test.tsx")); + ASSERT_TRUE(cbm_is_test_path("handler_test.go")); + ASSERT_TRUE(cbm_is_test_path("test_handler.py")); + /* Non-test files */ + ASSERT_FALSE(cbm_is_test_path("handler.ts")); + ASSERT_FALSE(cbm_is_test_path("contest.go")); + ASSERT_FALSE(cbm_is_test_path("latest.py")); + PASS(); +} + +TEST(test_func_name_go_patterns) { + /* Go test function names: Test + uppercase char */ + ASSERT_TRUE(cbm_is_test_func_name("TestFoo")); + ASSERT_TRUE(cbm_is_test_func_name("TestHTTPHandler")); + /* Non-test: "Test" alone or Test + lowercase */ + ASSERT_FALSE(cbm_is_test_func_name("Testable")); /* lowercase 'a' after Test */ + PASS(); +} + +TEST(test_func_name_js_helpers) { + /* JS/TS test helper function names */ + ASSERT_TRUE(cbm_is_test_func_name("it")); + ASSERT_TRUE(cbm_is_test_func_name("describe")); + ASSERT_TRUE(cbm_is_test_func_name("test")); + ASSERT_TRUE(cbm_is_test_func_name("beforeEach")); + ASSERT_TRUE(cbm_is_test_func_name("afterEach")); + PASS(); +} + +TEST(env_var_name_valid) { + /* Valid env var names: uppercase + underscores, at least 2 chars with uppercase */ + ASSERT_TRUE(cbm_is_env_var_name("DATABASE_URL")); + ASSERT_TRUE(cbm_is_env_var_name("API_KEY")); + ASSERT_TRUE(cbm_is_env_var_name("PORT")); + ASSERT_TRUE(cbm_is_env_var_name("DB_2")); + ASSERT_TRUE(cbm_is_env_var_name("MY_VAR_123")); + PASS(); +} + +TEST(env_var_name_invalid) { + /* Invalid: too short, lowercase, mixed case, empty */ + ASSERT_FALSE(cbm_is_env_var_name("A")); /* single char */ + ASSERT_FALSE(cbm_is_env_var_name("port")); /* lowercase */ + ASSERT_FALSE(cbm_is_env_var_name("apiKey")); /* camelCase */ + ASSERT_FALSE(cbm_is_env_var_name("__")); /* no uppercase */ + ASSERT_FALSE(cbm_is_env_var_name("")); /* empty */ + ASSERT_FALSE(cbm_is_env_var_name("123")); /* digits only */ + PASS(); +} + +TEST(config_ext_positive) { + /* Config file extensions recognized */ + ASSERT_TRUE(cbm_has_config_extension(".env")); + ASSERT_TRUE(cbm_has_config_extension("config.yaml")); + ASSERT_TRUE(cbm_has_config_extension("config.yml")); + ASSERT_TRUE(cbm_has_config_extension("settings.toml")); + ASSERT_TRUE(cbm_has_config_extension("app.ini")); + ASSERT_TRUE(cbm_has_config_extension("data.json")); + ASSERT_TRUE(cbm_has_config_extension("app.properties")); + PASS(); +} + +TEST(config_ext_negative) { + /* Non-config extensions rejected */ + ASSERT_FALSE(cbm_has_config_extension("main.go")); + ASSERT_FALSE(cbm_has_config_extension("app.py")); + ASSERT_FALSE(cbm_has_config_extension("handler.rs")); + ASSERT_FALSE(cbm_has_config_extension("data.csv")); + ASSERT_FALSE(cbm_has_config_extension("README.md")); + PASS(); +} + +TEST(split_camel_basic) { + /* Basic camelCase splitting */ + char *parts[16]; + int n; + + n = cbm_split_camel_case("getCamelCase", parts, 16); + ASSERT_EQ(n, 3); + ASSERT_STR_EQ(parts[0], "get"); + ASSERT_STR_EQ(parts[1], "Camel"); + ASSERT_STR_EQ(parts[2], "Case"); + for (int i = 0; i < n; i++) free(parts[i]); + + PASS(); +} + +TEST(split_camel_single_word) { + /* Single lowercase word — no splits */ + char *parts[16]; + int n = cbm_split_camel_case("hello", parts, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(parts[0], "hello"); + for (int i = 0; i < n; i++) free(parts[i]); + PASS(); +} + +TEST(split_camel_empty) { + /* Empty string — should return 0 or 1 empty part */ + char *parts[16]; + int n = cbm_split_camel_case("", parts, 16); + for (int i = 0; i < n; i++) free(parts[i]); + /* Either 0 parts or 1 empty part is acceptable */ + ASSERT_TRUE(n >= 0 && n <= 1); + PASS(); +} + +TEST(tokenize_decorator_login_required) { + /* @login_required → ["login", "required"] */ + char *tokens[16]; + int n = cbm_tokenize_decorator("@login_required", tokens, 16); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(tokens[0], "login"); + ASSERT_STR_EQ(tokens[1], "required"); + for (int i = 0; i < n; i++) free(tokens[i]); + PASS(); +} + +TEST(tokenize_decorator_single) { + /* @Override → ["override"] */ + char *tokens[16]; + int n = cbm_tokenize_decorator("@Override", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "override"); + for (int i = 0; i < n; i++) free(tokens[i]); + PASS(); +} + +TEST(split_command_basic) { + /* Basic command splitting with spaces */ + char *args[16]; + int n = cbm_split_command("gcc -c main.c -o main.o", args, 16); + ASSERT_EQ(n, 5); + ASSERT_STR_EQ(args[0], "gcc"); + ASSERT_STR_EQ(args[1], "-c"); + ASSERT_STR_EQ(args[2], "main.c"); + ASSERT_STR_EQ(args[3], "-o"); + ASSERT_STR_EQ(args[4], "main.o"); + for (int i = 0; i < n; i++) free(args[i]); + PASS(); +} + +TEST(split_command_quoted) { + /* Command with quoted arguments */ + char *args[16]; + int n = cbm_split_command("gcc -DFOO=\"bar baz\" main.c", args, 16); + ASSERT_GTE(n, 3); + ASSERT_STR_EQ(args[0], "gcc"); + for (int i = 0; i < n; i++) free(args[i]); + PASS(); +} + +TEST(split_command_empty) { + /* Empty command string */ + char *args[16]; + int n = cbm_split_command("", args, 16); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(parse_range_with_count) { + /* "10,5" → start=10, count=5 */ + int start, count; + cbm_parse_range("10,5", &start, &count); + ASSERT_EQ(start, 10); + ASSERT_EQ(count, 5); + PASS(); +} + +TEST(parse_range_single) { + /* "42" → start=42, count=1 */ + int start, count; + cbm_parse_range("42", &start, &count); + ASSERT_EQ(start, 42); + ASSERT_EQ(count, 1); + PASS(); +} + +TEST(parse_range_zero_count) { + /* "100,0" → start=100, count=0 */ + int start, count; + cbm_parse_range("100,0", &start, &count); + ASSERT_EQ(start, 100); + ASSERT_EQ(count, 0); + PASS(); +} + +TEST(parse_name_status_basic) { + /* Parse standard git diff --name-status output */ + const char *input = "M\tsrc/main.go\n" + "A\tsrc/new.go\n" + "D\tsrc/old.go\n"; + cbm_changed_file_t files[16]; + int n = cbm_parse_name_status(input, files, 16); + /* Exact count depends on which files pass cbm_is_trackable_file */ + ASSERT_GTE(n, 0); + ASSERT_LTE(n, 3); + PASS(); +} + +TEST(parse_name_status_empty) { + /* Empty input */ + cbm_changed_file_t files[16]; + int n = cbm_parse_name_status("", files, 16); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(parse_hunks_empty) { + /* Empty input */ + cbm_changed_hunk_t hunks[16]; + int n = cbm_parse_hunks("", hunks, 16); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(coupling_empty_commits) { + /* Zero commits → zero couplings */ + cbm_change_coupling_t results[16]; + int n = cbm_compute_change_coupling(NULL, 0, results, 16); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(coupling_single_file_commit) { + /* Commits with single files → no pairs → zero couplings */ + char *f1[] = {"a.go"}; + char *f2[] = {"b.go"}; + cbm_commit_files_t commits[] = {{f1, 1}, {f2, 1}}; + cbm_change_coupling_t results[16]; + int n = cbm_compute_change_coupling(commits, 2, results, 16); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(clean_json_brackets_array) { + /* JSON array → space-joined */ + char out[256]; + cbm_clean_json_brackets("[\"./app\", \"--flag\"]", out, sizeof(out)); + ASSERT_TRUE(strstr(out, "./app") != NULL); + ASSERT_TRUE(strstr(out, "--flag") != NULL); + PASS(); +} + +TEST(clean_json_brackets_plain) { + /* Plain string → unchanged */ + char out[256]; + cbm_clean_json_brackets("./app --flag", out, sizeof(out)); + ASSERT_STR_EQ(out, "./app --flag"); + PASS(); +} + +TEST(clean_json_brackets_empty) { + /* Empty string → empty output */ + char out[256]; + cbm_clean_json_brackets("", out, sizeof(out)); + ASSERT_STR_EQ(out, ""); + PASS(); +} + +TEST(fqn_compute_basic) { + /* Basic FQN: project.dir.name */ + char *fqn = cbm_pipeline_fqn_compute("proj", "pkg/handler.go", "Serve"); + ASSERT_NOT_NULL(fqn); + ASSERT_TRUE(strstr(fqn, "proj") != NULL); + ASSERT_TRUE(strstr(fqn, "Serve") != NULL); + free(fqn); + PASS(); +} + +TEST(fqn_compute_strips_ext) { + /* FQN should strip file extension */ + char *fqn = cbm_pipeline_fqn_compute("proj", "main.go", "main"); + ASSERT_NOT_NULL(fqn); + /* Should not contain ".go" */ + ASSERT_TRUE(strstr(fqn, ".go") == NULL); + ASSERT_TRUE(strstr(fqn, "main") != NULL); + free(fqn); + PASS(); +} + +TEST(fqn_module_basic) { + /* Module QN: project.dir.parts */ + char *mod = cbm_pipeline_fqn_module("proj", "pkg/util/helper.go"); + ASSERT_NOT_NULL(mod); + ASSERT_TRUE(strstr(mod, "proj") != NULL); + free(mod); + PASS(); +} + +TEST(fqn_folder_basic) { + /* Folder QN from directory path */ + char *folder = cbm_pipeline_fqn_folder("proj", "pkg/util"); + ASSERT_NOT_NULL(folder); + ASSERT_TRUE(strstr(folder, "proj") != NULL); + free(folder); + PASS(); +} + +TEST(project_name_special_chars) { + /* Project name with colons, slashes — should be normalized */ + char *name = cbm_project_name_from_path("/home/user/my:project"); + ASSERT_NOT_NULL(name); + /* Colons should be converted to dashes */ + ASSERT_TRUE(strchr(name, ':') == NULL); + ASSERT_TRUE(strchr(name, '/') == NULL); + free(name); + PASS(); +} + +TEST(project_name_trailing_slash) { + /* Trailing slash should not affect result */ + char *a = cbm_project_name_from_path("/home/user/project"); + ASSERT_NOT_NULL(a); + free(a); + PASS(); +} + SUITE(pipeline) { /* Index lock */ RUN_TEST(pipeline_lock_try_acquire); @@ -5488,4 +5919,63 @@ SUITE(pipeline) { RUN_TEST(incremental_new_file_added); RUN_TEST(incremental_k8s_manifest_indexed); RUN_TEST(incremental_kustomize_module_indexed); + /* Resource management & internal helper tests */ + RUN_TEST(pipeline_empty_path); + RUN_TEST(pipeline_project_name_content); + RUN_TEST(pipeline_cancel_sets_flag); + RUN_TEST(pipeline_double_cancel); + RUN_TEST(pipeline_double_free_prevention); + /* Trackable file tests */ + RUN_TEST(trackable_source_files); + RUN_TEST(trackable_rejects_images); + RUN_TEST(trackable_rejects_minified); + RUN_TEST(trackable_rejects_vendor_dirs); + RUN_TEST(trackable_rejects_lock_files); + /* Test path detection */ + RUN_TEST(test_path_directory_patterns); + RUN_TEST(test_path_suffix_patterns); + /* Test function name detection */ + RUN_TEST(test_func_name_go_patterns); + RUN_TEST(test_func_name_js_helpers); + /* Env var name detection */ + RUN_TEST(env_var_name_valid); + RUN_TEST(env_var_name_invalid); + /* Config extension detection */ + RUN_TEST(config_ext_positive); + RUN_TEST(config_ext_negative); + /* Camel case splitting */ + RUN_TEST(split_camel_basic); + RUN_TEST(split_camel_single_word); + RUN_TEST(split_camel_empty); + /* Decorator tokenization */ + RUN_TEST(tokenize_decorator_login_required); + RUN_TEST(tokenize_decorator_single); + /* Command splitting */ + RUN_TEST(split_command_basic); + RUN_TEST(split_command_quoted); + RUN_TEST(split_command_empty); + /* Range parsing */ + RUN_TEST(parse_range_with_count); + RUN_TEST(parse_range_single); + RUN_TEST(parse_range_zero_count); + /* Name status parsing */ + RUN_TEST(parse_name_status_basic); + RUN_TEST(parse_name_status_empty); + /* Hunk parsing */ + RUN_TEST(parse_hunks_empty); + /* Change coupling */ + RUN_TEST(coupling_empty_commits); + RUN_TEST(coupling_single_file_commit); + /* JSON bracket cleaning */ + RUN_TEST(clean_json_brackets_array); + RUN_TEST(clean_json_brackets_plain); + RUN_TEST(clean_json_brackets_empty); + /* FQN computation */ + RUN_TEST(fqn_compute_basic); + RUN_TEST(fqn_compute_strips_ext); + RUN_TEST(fqn_module_basic); + RUN_TEST(fqn_folder_basic); + /* Project name edge cases */ + RUN_TEST(project_name_special_chars); + RUN_TEST(project_name_trailing_slash); } diff --git a/tests/test_store_edges.c b/tests/test_store_edges.c index d28420da1..dcd8d349b 100644 --- a/tests/test_store_edges.c +++ b/tests/test_store_edges.c @@ -294,6 +294,290 @@ TEST(store_edge_cascade_on_node_delete) { PASS(); } +/* ── Batch insert with count=0 (non-NULL array) ────────────────── */ + +TEST(store_edge_batch_insert_zero_count) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_edge_t dummy = {.project = "test", .source_id = 1, .target_id = 2, .type = "CALLS"}; + int rc = cbm_store_insert_edge_batch(s, &dummy, 0); + ASSERT_EQ(rc, CBM_STORE_OK); + + int ecnt = cbm_store_count_edges(s, "test"); + ASSERT_EQ(ecnt, 0); + + cbm_store_close(s); + PASS(); +} + +/* ── Batch insert stress: 50 edges ─────────────────────────────── */ + +TEST(store_edge_batch_insert_50) { + /* Create 51 nodes so we can have 50 A→B edges with distinct targets */ + int64_t ids[51]; + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + for (int i = 0; i < 51; i++) { + char name[8], qn[32]; + snprintf(name, sizeof(name), "N%d", i); + snprintf(qn, sizeof(qn), "test.N%d", i); + cbm_node_t node = { + .project = "test", .label = "Function", .name = name, .qualified_name = qn}; + ids[i] = cbm_store_upsert_node(s, &node); + } + + /* 50 edges: N0→N1, N1→N2, ..., N49→N50 */ + cbm_edge_t edges[50]; + for (int i = 0; i < 50; i++) { + edges[i] = (cbm_edge_t){ + .project = "test", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; + } + + int rc = cbm_store_insert_edge_batch(s, edges, 50); + ASSERT_EQ(rc, CBM_STORE_OK); + + int ecnt = cbm_store_count_edges(s, "test"); + ASSERT_EQ(ecnt, 50); + + cbm_store_close(s); + PASS(); +} + +/* ── find_edges_by_source with non-existent source ─────────────── */ + +TEST(store_edge_find_source_nonexistent) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_edge_t *edges = NULL; + int count = 0; + int rc = cbm_store_find_edges_by_source(s, 999999, &edges, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_edges(edges, count); + + cbm_store_close(s); + PASS(); +} + +/* ── find_edges_by_target with non-existent target ─────────────── */ + +TEST(store_edge_find_target_nonexistent) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_edge_t *edges = NULL; + int count = 0; + int rc = cbm_store_find_edges_by_target(s, 999999, &edges, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_edges(edges, count); + + cbm_store_close(s); + PASS(); +} + +/* ── find_edges_by_type for non-existent type ──────────────────── */ + +TEST(store_edge_find_type_nonexistent) { + int64_t ids[2]; + cbm_store_t *s = setup_store_with_nodes(2, ids); + + cbm_edge_t e = {.project = "test", .source_id = ids[0], .target_id = ids[1], .type = "CALLS"}; + cbm_store_insert_edge(s, &e); + + cbm_edge_t *edges = NULL; + int count = 0; + int rc = cbm_store_find_edges_by_type(s, "test", "DOES_NOT_EXIST", &edges, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_edges(edges, count); + + cbm_store_close(s); + PASS(); +} + +/* ── count_edges on empty project ──────────────────────────────── */ + +TEST(store_edge_count_empty_project) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "empty", "/tmp/empty"); + + int ecnt = cbm_store_count_edges(s, "empty"); + ASSERT_EQ(ecnt, 0); + + cbm_store_close(s); + PASS(); +} + +/* ── count_edges_by_type for missing type ──────────────────────── */ + +TEST(store_edge_count_by_type_missing) { + int64_t ids[2]; + cbm_store_t *s = setup_store_with_nodes(2, ids); + + cbm_edge_t e = {.project = "test", .source_id = ids[0], .target_id = ids[1], .type = "CALLS"}; + cbm_store_insert_edge(s, &e); + + int cnt = cbm_store_count_edges_by_type(s, "test", "NEVER_EXISTS"); + ASSERT_EQ(cnt, 0); + + cbm_store_close(s); + PASS(); +} + +/* ── delete_edges_by_type preserves other types ────────────────── */ + +TEST(store_edge_delete_by_type_preserves_others) { + int64_t ids[3]; + cbm_store_t *s = setup_store_with_nodes(3, ids); + + cbm_edge_t e1 = {.project = "test", .source_id = ids[0], .target_id = ids[1], .type = "CALLS"}; + cbm_edge_t e2 = { + .project = "test", .source_id = ids[0], .target_id = ids[2], .type = "IMPORTS"}; + cbm_edge_t e3 = { + .project = "test", .source_id = ids[1], .target_id = ids[2], .type = "HTTP_CALLS"}; + cbm_store_insert_edge(s, &e1); + cbm_store_insert_edge(s, &e2); + cbm_store_insert_edge(s, &e3); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 3); + + /* Delete only CALLS */ + cbm_store_delete_edges_by_type(s, "test", "CALLS"); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 2); + + /* IMPORTS and HTTP_CALLS still exist */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, "test", "IMPORTS"), 1); + ASSERT_EQ(cbm_store_count_edges_by_type(s, "test", "HTTP_CALLS"), 1); + ASSERT_EQ(cbm_store_count_edges_by_type(s, "test", "CALLS"), 0); + + cbm_store_close(s); + PASS(); +} + +/* ── delete_edges_by_project preserves other projects ──────────── */ + +TEST(store_edge_delete_by_project_preserves_others) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "alpha", "/tmp/alpha"); + cbm_store_upsert_project(s, "beta", "/tmp/beta"); + + /* Create nodes in both projects */ + cbm_node_t na = { + .project = "alpha", .label = "Function", .name = "A", .qualified_name = "alpha.A"}; + cbm_node_t nb = { + .project = "alpha", .label = "Function", .name = "B", .qualified_name = "alpha.B"}; + cbm_node_t nc = { + .project = "beta", .label = "Function", .name = "C", .qualified_name = "beta.C"}; + cbm_node_t nd = { + .project = "beta", .label = "Function", .name = "D", .qualified_name = "beta.D"}; + int64_t idA = cbm_store_upsert_node(s, &na); + int64_t idB = cbm_store_upsert_node(s, &nb); + int64_t idC = cbm_store_upsert_node(s, &nc); + int64_t idD = cbm_store_upsert_node(s, &nd); + + cbm_edge_t e1 = { + .project = "alpha", .source_id = idA, .target_id = idB, .type = "CALLS"}; + cbm_edge_t e2 = { + .project = "beta", .source_id = idC, .target_id = idD, .type = "CALLS"}; + cbm_store_insert_edge(s, &e1); + cbm_store_insert_edge(s, &e2); + + ASSERT_EQ(cbm_store_count_edges(s, "alpha"), 1); + ASSERT_EQ(cbm_store_count_edges(s, "beta"), 1); + + /* Delete alpha edges only */ + cbm_store_delete_edges_by_project(s, "alpha"); + ASSERT_EQ(cbm_store_count_edges(s, "alpha"), 0); + ASSERT_EQ(cbm_store_count_edges(s, "beta"), 1); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge with special chars in properties_json ────────────────── */ + +TEST(store_edge_properties_special_chars) { + int64_t ids[2]; + cbm_store_t *s = setup_store_with_nodes(2, ids); + + /* JSON with unicode, quotes, backslashes */ + const char *json = "{\"desc\":\"line1\\nline2\",\"path\":\"/api/v1/users?q=foo&bar=baz\"," + "\"emoji\":\"\\u2603\"}"; + cbm_edge_t e = {.project = "test", + .source_id = ids[0], + .target_id = ids[1], + .type = "HTTP_CALLS", + .properties_json = json}; + cbm_store_insert_edge(s, &e); + + cbm_edge_t *edges = NULL; + int count = 0; + cbm_store_find_edges_by_source(s, ids[0], &edges, &count); + ASSERT_EQ(count, 1); + ASSERT_NOT_NULL(edges[0].properties_json); + /* Round-trip should preserve the JSON string */ + ASSERT(strstr(edges[0].properties_json, "line1\\nline2") != NULL); + ASSERT(strstr(edges[0].properties_json, "/api/v1/users") != NULL); + cbm_store_free_edges(edges, count); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge with very long type string ───────────────────────────── */ + +TEST(store_edge_long_type_string) { + int64_t ids[2]; + cbm_store_t *s = setup_store_with_nodes(2, ids); + + /* 200-char type string */ + char long_type[201]; + memset(long_type, 'X', 200); + long_type[200] = '\0'; + + cbm_edge_t e = {.project = "test", + .source_id = ids[0], + .target_id = ids[1], + .type = long_type}; + int64_t eid = cbm_store_insert_edge(s, &e); + ASSERT_GT(eid, 0); + + /* Verify it round-trips */ + cbm_edge_t *edges = NULL; + int count = 0; + int rc = cbm_store_find_edges_by_source(s, ids[0], &edges, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(edges[0].type, long_type); + cbm_store_free_edges(edges, count); + + cbm_store_close(s); + PASS(); +} + +/* ── find_edges_by_source_type with non-existent type ──────────── */ + +TEST(store_edge_find_source_type_nonexistent) { + int64_t ids[2]; + cbm_store_t *s = setup_store_with_nodes(2, ids); + + cbm_edge_t e = {.project = "test", .source_id = ids[0], .target_id = ids[1], .type = "CALLS"}; + cbm_store_insert_edge(s, &e); + + cbm_edge_t *edges = NULL; + int count = 0; + int rc = cbm_store_find_edges_by_source_type(s, ids[0], "NOPE", &edges, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_edges(edges, count); + + cbm_store_close(s); + PASS(); +} + SUITE(store_edges) { RUN_TEST(store_edge_insert_find); RUN_TEST(store_edge_dedup); @@ -307,4 +591,17 @@ SUITE(store_edges) { RUN_TEST(store_edge_batch_insert); RUN_TEST(store_edge_batch_empty); RUN_TEST(store_edge_cascade_on_node_delete); + /* Edge case tests */ + RUN_TEST(store_edge_batch_insert_zero_count); + RUN_TEST(store_edge_batch_insert_50); + RUN_TEST(store_edge_find_source_nonexistent); + RUN_TEST(store_edge_find_target_nonexistent); + RUN_TEST(store_edge_find_type_nonexistent); + RUN_TEST(store_edge_count_empty_project); + RUN_TEST(store_edge_count_by_type_missing); + RUN_TEST(store_edge_delete_by_type_preserves_others); + RUN_TEST(store_edge_delete_by_project_preserves_others); + RUN_TEST(store_edge_properties_special_chars); + RUN_TEST(store_edge_long_type_string); + RUN_TEST(store_edge_find_source_type_nonexistent); } diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 4160a15aa..b433ff2a6 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -956,6 +956,549 @@ TEST(store_integrity_null_check) { PASS(); } +/* ── Edge case: NULL / empty field handling ────────────────────── */ + +TEST(store_node_null_project) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + + /* Upsert with NULL project — should fail gracefully */ + cbm_node_t n = {.project = NULL, + .label = "Function", + .name = "Foo", + .qualified_name = "null.Foo"}; + int64_t id = cbm_store_upsert_node(s, &n); + /* Either returns error or silently succeeds; must not crash */ + (void)id; + + cbm_store_close(s); + PASS(); +} + +TEST(store_node_null_qn) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Upsert with NULL qualified_name */ + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "Bar", + .qualified_name = NULL}; + int64_t id = cbm_store_upsert_node(s, &n); + /* Must not crash regardless of return value */ + (void)id; + + cbm_store_close(s); + PASS(); +} + +TEST(store_node_empty_strings) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Upsert with all fields as empty strings */ + cbm_node_t n = {.project = "test", + .label = "", + .name = "", + .qualified_name = "", + .file_path = "", + .start_line = 0, + .end_line = 0, + .properties_json = ""}; + int64_t id = cbm_store_upsert_node(s, &n); + /* Should succeed — empty strings are valid */ + ASSERT_GT(id, 0); + + cbm_node_t found = {0}; + int rc = cbm_store_find_node_by_qn(s, "test", "", &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.name, ""); + ASSERT_STR_EQ(found.label, ""); + cbm_node_free_fields(&found); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: not-found lookups ──────────────────────────────── */ + +TEST(store_find_by_id_not_found) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t found = {0}; + int rc = cbm_store_find_node_by_id(s, 999999, &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + /* Negative ID should also be not found */ + rc = cbm_store_find_node_by_id(s, -1, &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + cbm_store_close(s); + PASS(); +} + +TEST(store_find_by_qn_not_found) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Insert a node so the store is non-empty */ + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "Exists", + .qualified_name = "test.Exists"}; + cbm_store_upsert_node(s, &n); + + /* Search for a non-existent QN */ + cbm_node_t found = {0}; + int rc = cbm_store_find_node_by_qn(s, "test", "test.DoesNotExist", &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + /* Wrong project */ + rc = cbm_store_find_node_by_qn(s, "other-project", "test.Exists", &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: cross-project lookups ──────────────────────────── */ + +TEST(store_find_by_qn_any_cross_project) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj-a", "/tmp/a"); + cbm_store_upsert_project(s, "proj-b", "/tmp/b"); + + cbm_node_t na = {.project = "proj-a", + .label = "Function", + .name = "SharedFunc", + .qualified_name = "proj-a.main.SharedFunc"}; + cbm_node_t nb = {.project = "proj-b", + .label = "Class", + .name = "Widget", + .qualified_name = "proj-b.pkg.Widget"}; + cbm_store_upsert_node(s, &na); + cbm_store_upsert_node(s, &nb); + + /* find_node_by_qn_any finds without project filter */ + cbm_node_t found = {0}; + int rc = cbm_store_find_node_by_qn_any(s, "proj-a.main.SharedFunc", &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.name, "SharedFunc"); + ASSERT_STR_EQ(found.project, "proj-a"); + cbm_node_free_fields(&found); + + rc = cbm_store_find_node_by_qn_any(s, "proj-b.pkg.Widget", &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.name, "Widget"); + ASSERT_STR_EQ(found.project, "proj-b"); + cbm_node_free_fields(&found); + + /* Non-existent QN */ + rc = cbm_store_find_node_by_qn_any(s, "nonexistent.Nope", &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + cbm_store_close(s); + PASS(); +} + +TEST(store_find_by_name_any_cross_project) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj-a", "/tmp/a"); + cbm_store_upsert_project(s, "proj-b", "/tmp/b"); + + /* Same name in two projects */ + cbm_node_t na = {.project = "proj-a", + .label = "Function", + .name = "Init", + .qualified_name = "proj-a.main.Init"}; + cbm_node_t nb = {.project = "proj-b", + .label = "Function", + .name = "Init", + .qualified_name = "proj-b.main.Init"}; + cbm_node_t nc = {.project = "proj-b", + .label = "Function", + .name = "Other", + .qualified_name = "proj-b.main.Other"}; + cbm_store_upsert_node(s, &na); + cbm_store_upsert_node(s, &nb); + cbm_store_upsert_node(s, &nc); + + /* find_nodes_by_name_any should find both "Init" across projects */ + cbm_node_t *nodes = NULL; + int count = 0; + int rc = cbm_store_find_nodes_by_name_any(s, "Init", &nodes, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 2); + cbm_store_free_nodes(nodes, count); + + /* Name that doesn't exist */ + rc = cbm_store_find_nodes_by_name_any(s, "Nonexistent", &nodes, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_nodes(nodes, count); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: empty result sets ──────────────────────────────── */ + +TEST(store_find_by_file_no_match) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "Foo", + .qualified_name = "test.Foo", + .file_path = "main.go"}; + cbm_store_upsert_node(s, &n); + + /* Search for a file that has no nodes */ + cbm_node_t *nodes = NULL; + int count = 0; + int rc = cbm_store_find_nodes_by_file(s, "test", "nonexistent.go", &nodes, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_nodes(nodes, count); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: batch upsert boundary ─────────────────────────── */ + +TEST(store_node_batch_upsert_zero) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Batch upsert with count=0 should succeed, do nothing */ + int rc = cbm_store_upsert_node_batch(s, NULL, 0, NULL); + ASSERT_EQ(rc, CBM_STORE_OK); + + int cnt = cbm_store_count_nodes(s, "test"); + ASSERT_EQ(cnt, 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_node_batch_upsert_100) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t nodes[100]; + int64_t ids[100]; + char names[100][32]; + char qns[100][64]; + + for (int i = 0; i < 100; i++) { + snprintf(names[i], sizeof(names[i]), "stress_%d", i); + snprintf(qns[i], sizeof(qns[i]), "test.stress.stress_%d", i); + nodes[i] = (cbm_node_t){.project = "test", + .label = "Function", + .name = names[i], + .qualified_name = qns[i], + .file_path = "stress.go", + .start_line = i, + .end_line = i + 1}; + } + + int rc = cbm_store_upsert_node_batch(s, nodes, 100, ids); + ASSERT_EQ(rc, CBM_STORE_OK); + + /* All IDs should be positive */ + for (int i = 0; i < 100; i++) + ASSERT_GT(ids[i], 0); + + /* IDs should all be unique */ + for (int i = 0; i < 100; i++) + for (int j = i + 1; j < 100; j++) + ASSERT_NEQ(ids[i], ids[j]); + + int cnt = cbm_store_count_nodes(s, "test"); + ASSERT_EQ(cnt, 100); + + /* Verify a few random lookups */ + cbm_node_t found = {0}; + rc = cbm_store_find_node_by_qn(s, "test", "test.stress.stress_0", &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.name, "stress_0"); + cbm_node_free_fields(&found); + + rc = cbm_store_find_node_by_qn(s, "test", "test.stress.stress_99", &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.name, "stress_99"); + cbm_node_free_fields(&found); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: delete then verify remaining ──────────────────── */ + +TEST(store_delete_by_label_verify_remaining) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t n1 = {.project = "test", + .label = "Function", + .name = "FuncA", + .qualified_name = "test.FuncA"}; + cbm_node_t n2 = {.project = "test", + .label = "Class", + .name = "ClassB", + .qualified_name = "test.ClassB"}; + cbm_node_t n3 = {.project = "test", + .label = "Function", + .name = "FuncC", + .qualified_name = "test.FuncC"}; + cbm_node_t n4 = {.project = "test", + .label = "Method", + .name = "MethodD", + .qualified_name = "test.MethodD"}; + cbm_store_upsert_node(s, &n1); + cbm_store_upsert_node(s, &n2); + cbm_store_upsert_node(s, &n3); + cbm_store_upsert_node(s, &n4); + + /* Delete all Functions */ + int rc = cbm_store_delete_nodes_by_label(s, "test", "Function"); + ASSERT_EQ(rc, CBM_STORE_OK); + + int cnt = cbm_store_count_nodes(s, "test"); + ASSERT_EQ(cnt, 2); + + /* Class and Method should remain */ + cbm_node_t found = {0}; + rc = cbm_store_find_node_by_qn(s, "test", "test.ClassB", &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.label, "Class"); + cbm_node_free_fields(&found); + + rc = cbm_store_find_node_by_qn(s, "test", "test.MethodD", &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.label, "Method"); + cbm_node_free_fields(&found); + + /* Deleted ones should be gone */ + rc = cbm_store_find_node_by_qn(s, "test", "test.FuncA", &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + rc = cbm_store_find_node_by_qn(s, "test", "test.FuncC", &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + cbm_store_close(s); + PASS(); +} + +TEST(store_delete_by_file_verify_remaining) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t n1 = {.project = "test", + .label = "Function", + .name = "A", + .qualified_name = "test.A", + .file_path = "delete_me.go"}; + cbm_node_t n2 = {.project = "test", + .label = "Function", + .name = "B", + .qualified_name = "test.B", + .file_path = "keep_me.go"}; + cbm_node_t n3 = {.project = "test", + .label = "Function", + .name = "C", + .qualified_name = "test.C", + .file_path = "delete_me.go"}; + cbm_store_upsert_node(s, &n1); + cbm_store_upsert_node(s, &n2); + cbm_store_upsert_node(s, &n3); + + int rc = cbm_store_delete_nodes_by_file(s, "test", "delete_me.go"); + ASSERT_EQ(rc, CBM_STORE_OK); + + int cnt = cbm_store_count_nodes(s, "test"); + ASSERT_EQ(cnt, 1); + + /* Only keep_me.go node should remain */ + cbm_node_t found = {0}; + rc = cbm_store_find_node_by_qn(s, "test", "test.B", &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.file_path, "keep_me.go"); + cbm_node_free_fields(&found); + + /* Deleted nodes should be gone */ + rc = cbm_store_find_node_by_qn(s, "test", "test.A", &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + rc = cbm_store_find_node_by_qn(s, "test", "test.C", &found); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: upsert dedup with field changes ───────────────── */ + +TEST(store_node_upsert_updates_fields) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Insert initial node */ + cbm_node_t n1 = {.project = "test", + .label = "Function", + .name = "MyFunc", + .qualified_name = "test.MyFunc", + .file_path = "old.go", + .start_line = 1, + .end_line = 10, + .properties_json = "{\"version\":1}"}; + int64_t id1 = cbm_store_upsert_node(s, &n1); + ASSERT_GT(id1, 0); + + /* Upsert same QN with changed fields */ + cbm_node_t n2 = {.project = "test", + .label = "Method", + .name = "MyFunc", + .qualified_name = "test.MyFunc", + .file_path = "new.go", + .start_line = 50, + .end_line = 60, + .properties_json = "{\"version\":2}"}; + int64_t id2 = cbm_store_upsert_node(s, &n2); + ASSERT_EQ(id1, id2); /* Same ID — updated, not duplicated */ + + /* Count should still be 1 */ + int cnt = cbm_store_count_nodes(s, "test"); + ASSERT_EQ(cnt, 1); + + /* Verify fields were updated */ + cbm_node_t found = {0}; + int rc = cbm_store_find_node_by_id(s, id1, &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.label, "Method"); + ASSERT_STR_EQ(found.file_path, "new.go"); + ASSERT_EQ(found.start_line, 50); + ASSERT_EQ(found.end_line, 60); + ASSERT(strstr(found.properties_json, "version") != NULL); + ASSERT(strstr(found.properties_json, "2") != NULL); + cbm_node_free_fields(&found); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: long qualified name ───────────────────────────── */ + +TEST(store_node_long_qn) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Build a 1200-char qualified name */ + char long_qn[1201]; + memset(long_qn, 'a', 1200); + long_qn[0] = 't'; /* make it look like a dotted path */ + for (int i = 50; i < 1200; i += 50) + long_qn[i] = '.'; + long_qn[1200] = '\0'; + + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "LongName", + .qualified_name = long_qn, + .file_path = "big.go"}; + int64_t id = cbm_store_upsert_node(s, &n); + ASSERT_GT(id, 0); + + /* Should be retrievable by QN */ + cbm_node_t found = {0}; + int rc = cbm_store_find_node_by_qn(s, "test", long_qn, &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(found.qualified_name, long_qn); + ASSERT_STR_EQ(found.name, "LongName"); + cbm_node_free_fields(&found); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: properties JSON with special characters ────────── */ + +TEST(store_node_properties_special_chars) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* JSON with quotes, backslashes, unicode, newlines */ + const char *props = + "{\"desc\":\"line1\\nline2\"," + "\"path\":\"C:\\\\Users\\\\test\"," + "\"emoji\":\"\\u2603\"," + "\"nested\":{\"key\":\"val with \\\"quotes\\\"\"}}"; + + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "SpecialFunc", + .qualified_name = "test.SpecialFunc", + .properties_json = props}; + int64_t id = cbm_store_upsert_node(s, &n); + ASSERT_GT(id, 0); + + cbm_node_t found = {0}; + int rc = cbm_store_find_node_by_id(s, id, &found); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_NOT_NULL(found.properties_json); + /* Round-trip should preserve the JSON exactly */ + ASSERT_STR_EQ(found.properties_json, props); + cbm_node_free_fields(&found); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: delete from non-existent project/file ─────────── */ + +TEST(store_delete_nodes_nonexistent) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Insert one node */ + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "Survivor", + .qualified_name = "test.Survivor", + .file_path = "main.go"}; + cbm_store_upsert_node(s, &n); + + /* Delete by non-existent file — should succeed but delete nothing */ + int rc = cbm_store_delete_nodes_by_file(s, "test", "ghost.go"); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1); + + /* Delete by non-existent label */ + rc = cbm_store_delete_nodes_by_label(s, "test", "Interface"); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1); + + /* Delete by non-existent project */ + rc = cbm_store_delete_nodes_by_project(s, "no-such-project"); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1); + + cbm_store_close(s); + PASS(); +} + +/* ── Edge case: count nodes for unknown project ───────────────── */ + +TEST(store_count_nodes_unknown_project) { + cbm_store_t *s = cbm_store_open_memory(); + /* No project created — count should be 0 */ + int cnt = cbm_store_count_nodes(s, "ghost-project"); + ASSERT_EQ(cnt, 0); + cbm_store_close(s); + PASS(); +} + SUITE(store_nodes) { RUN_TEST(store_open_memory); RUN_TEST(store_close_null); @@ -993,4 +1536,21 @@ SUITE(store_nodes) { RUN_TEST(store_restore_from); RUN_TEST(store_pragma_settings); RUN_TEST(store_find_node_ids_by_qns); + RUN_TEST(store_node_null_project); + RUN_TEST(store_node_null_qn); + RUN_TEST(store_node_empty_strings); + RUN_TEST(store_find_by_id_not_found); + RUN_TEST(store_find_by_qn_not_found); + RUN_TEST(store_find_by_qn_any_cross_project); + RUN_TEST(store_find_by_name_any_cross_project); + RUN_TEST(store_find_by_file_no_match); + RUN_TEST(store_node_batch_upsert_zero); + RUN_TEST(store_node_batch_upsert_100); + RUN_TEST(store_delete_by_label_verify_remaining); + RUN_TEST(store_delete_by_file_verify_remaining); + RUN_TEST(store_node_upsert_updates_fields); + RUN_TEST(store_node_long_qn); + RUN_TEST(store_node_properties_special_chars); + RUN_TEST(store_delete_nodes_nonexistent); + RUN_TEST(store_count_nodes_unknown_project); } diff --git a/tests/test_store_search.c b/tests/test_store_search.c index 573830760..05df86806 100644 --- a/tests/test_store_search.c +++ b/tests/test_store_search.c @@ -948,6 +948,266 @@ TEST(store_batch_count_degrees) { PASS(); } +/* ── GlobToLike edge cases ──────────────────────────────────────── */ + +TEST(store_glob_to_like_empty) { + char *got = cbm_glob_to_like(""); + ASSERT_NOT_NULL(got); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +TEST(store_glob_to_like_only_star) { + char *got = cbm_glob_to_like("*"); + ASSERT_NOT_NULL(got); + ASSERT_STR_EQ(got, "%"); + free(got); + PASS(); +} + +TEST(store_glob_to_like_consecutive_doublestar) { + /* double-star slash double-star should collapse to %% */ + char *got = cbm_glob_to_like("**/**"); + ASSERT_NOT_NULL(got); + ASSERT_STR_EQ(got, "%%"); + free(got); + PASS(); +} + +TEST(store_glob_to_like_dot_and_brackets) { + /* Dots and brackets are literal in glob-to-LIKE — passed through */ + char *got = cbm_glob_to_like("src/[abc]/*.ts"); + ASSERT_NOT_NULL(got); + ASSERT_STR_EQ(got, "src/[abc]/%.ts"); + free(got); + PASS(); +} + +TEST(store_glob_to_like_question_marks) { + /* Multiple ? should produce multiple _ */ + char *got = cbm_glob_to_like("f???.txt"); + ASSERT_NOT_NULL(got); + ASSERT_STR_EQ(got, "f___.txt"); + free(got); + PASS(); +} + +/* ── ExtractLikeHints edge cases ───────────────────────────────── */ + +TEST(store_extract_like_hints_null_out) { + /* NULL out array */ + int n = cbm_extract_like_hints(".*handler.*", NULL, 16); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(store_extract_like_hints_zero_max) { + char *hints[4]; + int n = cbm_extract_like_hints(".*handler.*", hints, 0); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(store_extract_like_hints_alternation_complex) { + char *hints[16]; + /* Alternation with multiple segments on each side */ + int n = cbm_extract_like_hints("(foo|bar)baz", hints, 16); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(store_extract_like_hints_short_segments) { + char *hints[16]; + /* All segments < 3 chars — no hints */ + int n = cbm_extract_like_hints(".*ab.*cd.*", hints, 16); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(store_extract_like_hints_complex_multi_segment) { + char *hints[16]; + /* .*Foo.*Bar.* should extract both */ + int n = cbm_extract_like_hints(".*Foo.*Bar.*", hints, 16); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(hints[0], "Foo"); + ASSERT_STR_EQ(hints[1], "Bar"); + free(hints[0]); + free(hints[1]); + PASS(); +} + +TEST(store_extract_like_hints_max_out_limit) { + char *hints[2]; + /* More segments than max_out — should stop at max */ + int n = cbm_extract_like_hints(".*aaa.*bbb.*ccc.*ddd.*", hints, 2); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(hints[0], "aaa"); + ASSERT_STR_EQ(hints[1], "bbb"); + free(hints[0]); + free(hints[1]); + PASS(); +} + +TEST(store_extract_like_hints_escaped_chars) { + char *hints[16]; + /* Backslash escaping: \. makes the dot literal, so it becomes part of the + * accumulated literal string. ".handler" is the extracted hint. */ + int n = cbm_extract_like_hints("\\.handler", hints, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(hints[0], ".handler"); + free(hints[0]); + PASS(); +} + +/* ── Case helper edge cases ────────────────────────────────────── */ + +TEST(store_ensure_case_insensitive_null) { + const char *result = cbm_ensure_case_insensitive(NULL); + ASSERT_STR_EQ(result, ""); + PASS(); +} + +TEST(store_ensure_case_insensitive_already_ci) { + /* Already case-insensitive — should NOT double-prefix */ + ASSERT_STR_EQ(cbm_ensure_case_insensitive("(?i).*Order.*"), "(?i).*Order.*"); + PASS(); +} + +TEST(store_ensure_case_insensitive_plain) { + ASSERT_STR_EQ(cbm_ensure_case_insensitive("FooBar"), "(?i)FooBar"); + PASS(); +} + +TEST(store_strip_case_flag_null) { + const char *result = cbm_strip_case_flag(NULL); + ASSERT_STR_EQ(result, ""); + PASS(); +} + +TEST(store_strip_case_flag_no_flag) { + ASSERT_STR_EQ(cbm_strip_case_flag("plain_pattern"), "plain_pattern"); + PASS(); +} + +TEST(store_strip_case_flag_empty) { + ASSERT_STR_EQ(cbm_strip_case_flag(""), ""); + PASS(); +} + +/* ── Architecture helper edge cases ────────────────────────────── */ + +TEST(store_qn_to_package_single_segment) { + /* No dots — returns empty string */ + ASSERT_STR_EQ(cbm_qn_to_package("nodots"), ""); + PASS(); +} + +TEST(store_qn_to_package_two_segments) { + /* project.name — returns segment[1] */ + ASSERT_STR_EQ(cbm_qn_to_package("proj.name"), "name"); + PASS(); +} + +TEST(store_qn_to_package_many_segments) { + /* project.dir.pkg.Func — 4+ segments returns segment[2] */ + ASSERT_STR_EQ(cbm_qn_to_package("myproj.dir.pkg.Func"), "pkg"); + PASS(); +} + +TEST(store_qn_to_package_null) { + ASSERT_STR_EQ(cbm_qn_to_package(NULL), ""); + PASS(); +} + +TEST(store_qn_to_package_empty) { + ASSERT_STR_EQ(cbm_qn_to_package(""), ""); + PASS(); +} + +TEST(store_qn_to_top_package_single_segment) { + ASSERT_STR_EQ(cbm_qn_to_top_package("nodots"), ""); + PASS(); +} + +TEST(store_qn_to_top_package_two_segments) { + /* project.dir — returns "dir" */ + ASSERT_STR_EQ(cbm_qn_to_top_package("proj.dir"), "dir"); + PASS(); +} + +TEST(store_qn_to_top_package_many_segments) { + /* Always returns segment[1] regardless of depth */ + ASSERT_STR_EQ(cbm_qn_to_top_package("proj.dir.sub.Func"), "dir"); + PASS(); +} + +TEST(store_qn_to_top_package_null) { + ASSERT_STR_EQ(cbm_qn_to_top_package(NULL), ""); + PASS(); +} + +TEST(store_is_test_file_various) { + /* Positive cases */ + ASSERT_TRUE(cbm_is_test_file_path("test_handler.py")); + ASSERT_TRUE(cbm_is_test_file_path("handler_test.go")); + ASSERT_TRUE(cbm_is_test_file_path("handler.test.ts")); + ASSERT_FALSE(cbm_is_test_file_path("handler.spec.ts")); /* "spec" not "test" — no match */ + ASSERT_TRUE(cbm_is_test_file_path("src/__tests__/handler.js")); + ASSERT_TRUE(cbm_is_test_file_path("tests/unit/handler.py")); + + /* Negative cases */ + ASSERT_FALSE(cbm_is_test_file_path("handler.go")); + ASSERT_FALSE(cbm_is_test_file_path("main.py")); + ASSERT_FALSE(cbm_is_test_file_path("service.ts")); + + /* Edge: NULL and empty */ + ASSERT_FALSE(cbm_is_test_file_path(NULL)); + ASSERT_FALSE(cbm_is_test_file_path("")); + PASS(); +} + +/* ── Risk/impact edge cases ────────────────────────────────────── */ + +TEST(store_hop_to_risk_all_levels) { + /* hop 0 hits the default case → LOW */ + ASSERT_EQ(cbm_hop_to_risk(0), CBM_RISK_LOW); + /* hop 1 → CRITICAL */ + ASSERT_EQ(cbm_hop_to_risk(1), CBM_RISK_CRITICAL); + /* hop 2 → HIGH */ + ASSERT_EQ(cbm_hop_to_risk(2), CBM_RISK_HIGH); + /* hop 3 → MEDIUM */ + ASSERT_EQ(cbm_hop_to_risk(3), CBM_RISK_MEDIUM); + /* hop 4+ → LOW */ + ASSERT_EQ(cbm_hop_to_risk(4), CBM_RISK_LOW); + ASSERT_EQ(cbm_hop_to_risk(100), CBM_RISK_LOW); + /* negative → LOW (default) */ + ASSERT_EQ(cbm_hop_to_risk(-1), CBM_RISK_LOW); + PASS(); +} + +TEST(store_risk_label_all_levels) { + ASSERT_STR_EQ(cbm_risk_label(CBM_RISK_CRITICAL), "CRITICAL"); + ASSERT_STR_EQ(cbm_risk_label(CBM_RISK_HIGH), "HIGH"); + ASSERT_STR_EQ(cbm_risk_label(CBM_RISK_MEDIUM), "MEDIUM"); + ASSERT_STR_EQ(cbm_risk_label(CBM_RISK_LOW), "LOW"); + /* Out-of-range enum value falls to default → LOW */ + ASSERT_STR_EQ(cbm_risk_label((cbm_risk_level_t)99), "LOW"); + PASS(); +} + +TEST(store_impact_summary_empty) { + /* Zero hops and edges */ + cbm_impact_summary_t s = cbm_build_impact_summary(NULL, 0, NULL, 0); + ASSERT_EQ(s.total, 0); + ASSERT_EQ(s.critical, 0); + ASSERT_EQ(s.high, 0); + ASSERT_EQ(s.medium, 0); + ASSERT_EQ(s.low, 0); + ASSERT_FALSE(s.has_cross_service); + PASS(); +} + SUITE(store_search) { RUN_TEST(store_search_by_label); RUN_TEST(store_search_by_name_pattern); @@ -977,4 +1237,36 @@ SUITE(store_search) { RUN_TEST(store_ensure_case_insensitive); RUN_TEST(store_strip_case_flag); RUN_TEST(store_batch_count_degrees); + /* Edge case tests */ + RUN_TEST(store_glob_to_like_empty); + RUN_TEST(store_glob_to_like_only_star); + RUN_TEST(store_glob_to_like_consecutive_doublestar); + RUN_TEST(store_glob_to_like_dot_and_brackets); + RUN_TEST(store_glob_to_like_question_marks); + RUN_TEST(store_extract_like_hints_null_out); + RUN_TEST(store_extract_like_hints_zero_max); + RUN_TEST(store_extract_like_hints_alternation_complex); + RUN_TEST(store_extract_like_hints_short_segments); + RUN_TEST(store_extract_like_hints_complex_multi_segment); + RUN_TEST(store_extract_like_hints_max_out_limit); + RUN_TEST(store_extract_like_hints_escaped_chars); + RUN_TEST(store_ensure_case_insensitive_null); + RUN_TEST(store_ensure_case_insensitive_already_ci); + RUN_TEST(store_ensure_case_insensitive_plain); + RUN_TEST(store_strip_case_flag_null); + RUN_TEST(store_strip_case_flag_no_flag); + RUN_TEST(store_strip_case_flag_empty); + RUN_TEST(store_qn_to_package_single_segment); + RUN_TEST(store_qn_to_package_two_segments); + RUN_TEST(store_qn_to_package_many_segments); + RUN_TEST(store_qn_to_package_null); + RUN_TEST(store_qn_to_package_empty); + RUN_TEST(store_qn_to_top_package_single_segment); + RUN_TEST(store_qn_to_top_package_two_segments); + RUN_TEST(store_qn_to_top_package_many_segments); + RUN_TEST(store_qn_to_top_package_null); + RUN_TEST(store_is_test_file_various); + RUN_TEST(store_hop_to_risk_all_levels); + RUN_TEST(store_risk_label_all_levels); + RUN_TEST(store_impact_summary_empty); } diff --git a/tests/test_str_intern.c b/tests/test_str_intern.c index 0cea9ec0f..33607a5ba 100644 --- a/tests/test_str_intern.c +++ b/tests/test_str_intern.c @@ -110,6 +110,151 @@ TEST(intern_survives_stack_buffer) { PASS(); } +/* ── Edge case tests ───────────────────────────────────────────── */ + +TEST(intern_null_returns_null) { + CBMInternPool *pool = cbm_intern_create(); + const char *s = cbm_intern(pool, NULL); + ASSERT_NULL(s); + ASSERT_EQ(cbm_intern_count(pool), 0); + cbm_intern_free(pool); + PASS(); +} + +TEST(intern_n_zero_len) { + /* intern_n with len=0 should produce an empty string */ + CBMInternPool *pool = cbm_intern_create(); + const char *s = cbm_intern_n(pool, "anything", 0); + ASSERT_NOT_NULL(s); + ASSERT_STR_EQ(s, ""); + ASSERT_EQ(cbm_intern_count(pool), 1); + /* Should dedup with intern("") */ + const char *s2 = cbm_intern(pool, ""); + ASSERT_EQ((uintptr_t)s, (uintptr_t)s2); + ASSERT_EQ(cbm_intern_count(pool), 1); + cbm_intern_free(pool); + PASS(); +} + +TEST(intern_very_long_string) { + CBMInternPool *pool = cbm_intern_create(); + /* 2000 char string */ + char buf[2001]; + memset(buf, 'A', 2000); + buf[2000] = '\0'; + const char *s1 = cbm_intern(pool, buf); + ASSERT_NOT_NULL(s1); + ASSERT_EQ(strlen(s1), 2000); + ASSERT_STR_EQ(s1, buf); + /* Dedup works for long strings too */ + const char *s2 = cbm_intern(pool, buf); + ASSERT_EQ((uintptr_t)s1, (uintptr_t)s2); + ASSERT_EQ(cbm_intern_count(pool), 1); + cbm_intern_free(pool); + PASS(); +} + +TEST(intern_same_prefix_different_lengths) { + /* intern_n of same buffer with different lengths produces different strings */ + CBMInternPool *pool = cbm_intern_create(); + const char *src = "abcdefgh"; + const char *s3 = cbm_intern_n(pool, src, 3); /* "abc" */ + const char *s5 = cbm_intern_n(pool, src, 5); /* "abcde" */ + const char *s8 = cbm_intern_n(pool, src, 8); /* "abcdefgh" */ + ASSERT_STR_EQ(s3, "abc"); + ASSERT_STR_EQ(s5, "abcde"); + ASSERT_STR_EQ(s8, "abcdefgh"); + /* All different pointers */ + ASSERT_NEQ((uintptr_t)s3, (uintptr_t)s5); + ASSERT_NEQ((uintptr_t)s5, (uintptr_t)s8); + ASSERT_NEQ((uintptr_t)s3, (uintptr_t)s8); + ASSERT_EQ(cbm_intern_count(pool), 3); + cbm_intern_free(pool); + PASS(); +} + +TEST(intern_pointer_stability) { + /* Intern a string, then intern 10000 more, original pointer must survive */ + CBMInternPool *pool = cbm_intern_create(); + const char *original = cbm_intern(pool, "sentinel_value"); + ASSERT_NOT_NULL(original); + + /* Force many hash table resizes */ + char buf[64]; + for (int i = 0; i < 10000; i++) { + snprintf(buf, sizeof(buf), "filler_%06d", i); + cbm_intern(pool, buf); + } + ASSERT_EQ(cbm_intern_count(pool), 10001); + + /* Original pointer must still be valid and unchanged */ + ASSERT_STR_EQ(original, "sentinel_value"); + /* Re-interning must return the exact same pointer */ + const char *again = cbm_intern(pool, "sentinel_value"); + ASSERT_EQ((uintptr_t)original, (uintptr_t)again); + cbm_intern_free(pool); + PASS(); +} + +TEST(intern_two_pools_independent) { + /* Two pools must return different pointers for the same string */ + CBMInternPool *p1 = cbm_intern_create(); + CBMInternPool *p2 = cbm_intern_create(); + const char *s1 = cbm_intern(p1, "shared"); + const char *s2 = cbm_intern(p2, "shared"); + ASSERT_STR_EQ(s1, "shared"); + ASSERT_STR_EQ(s2, "shared"); + /* Different pools own different copies — different pointers */ + ASSERT_NEQ((uintptr_t)s1, (uintptr_t)s2); + cbm_intern_free(p1); + cbm_intern_free(p2); + PASS(); +} + +TEST(intern_free_empty_pool) { + /* Free a pool with no strings interned — should not crash */ + CBMInternPool *pool = cbm_intern_create(); + ASSERT_EQ(cbm_intern_count(pool), 0); + ASSERT_EQ(cbm_intern_bytes(pool), 0); + cbm_intern_free(pool); + PASS(); +} + +TEST(intern_bytes_tracking) { + CBMInternPool *pool = cbm_intern_create(); + ASSERT_EQ(cbm_intern_bytes(pool), 0); + + cbm_intern(pool, "abc"); /* 3 bytes */ + ASSERT_EQ(cbm_intern_bytes(pool), 3); + + cbm_intern(pool, "defgh"); /* 5 bytes */ + ASSERT_EQ(cbm_intern_bytes(pool), 8); + + /* Duplicate should not add bytes */ + cbm_intern(pool, "abc"); + ASSERT_EQ(cbm_intern_bytes(pool), 8); + + cbm_intern(pool, ""); /* 0 bytes */ + ASSERT_EQ(cbm_intern_bytes(pool), 8); + + cbm_intern(pool, "x"); /* 1 byte */ + ASSERT_EQ(cbm_intern_bytes(pool), 9); + + cbm_intern_free(pool); + PASS(); +} + +TEST(intern_free_null_pool) { + /* Free NULL pool — should not crash */ + cbm_intern_free(NULL); + PASS(); +} + +TEST(intern_count_null_pool) { + ASSERT_EQ(cbm_intern_count(NULL), 0); + PASS(); +} + SUITE(str_intern) { RUN_TEST(intern_create_free); RUN_TEST(intern_basic); @@ -120,4 +265,15 @@ SUITE(str_intern) { RUN_TEST(intern_many_strings); RUN_TEST(intern_bytes); RUN_TEST(intern_survives_stack_buffer); + /* Edge cases */ + RUN_TEST(intern_null_returns_null); + RUN_TEST(intern_n_zero_len); + RUN_TEST(intern_very_long_string); + RUN_TEST(intern_same_prefix_different_lengths); + RUN_TEST(intern_pointer_stability); + RUN_TEST(intern_two_pools_independent); + RUN_TEST(intern_free_empty_pool); + RUN_TEST(intern_bytes_tracking); + RUN_TEST(intern_free_null_pool); + RUN_TEST(intern_count_null_pool); } diff --git a/tests/test_str_util.c b/tests/test_str_util.c index ddb72f5f8..7f925a085 100644 --- a/tests/test_str_util.c +++ b/tests/test_str_util.c @@ -162,6 +162,307 @@ TEST(str_split_no_delim) { PASS(); } +/* ── NULL safety tests ─────────────────────────────────────────── */ + +TEST(path_join_null_base) { + setup(); + ASSERT_NULL(cbm_path_join(&a, NULL, "main.c")); + teardown(); + PASS(); +} + +TEST(path_join_null_name) { + setup(); + ASSERT_NULL(cbm_path_join(&a, "src", NULL)); + teardown(); + PASS(); +} + +TEST(path_join_both_null) { + setup(); + ASSERT_NULL(cbm_path_join(&a, NULL, NULL)); + teardown(); + PASS(); +} + +TEST(path_dir_null) { + setup(); + ASSERT_STR_EQ(cbm_path_dir(&a, NULL), "."); + teardown(); + PASS(); +} + +TEST(str_starts_with_null_s) { + ASSERT_FALSE(cbm_str_starts_with(NULL, "hello")); + PASS(); +} + +TEST(str_starts_with_null_prefix) { + ASSERT_FALSE(cbm_str_starts_with("hello", NULL)); + PASS(); +} + +TEST(str_ends_with_null) { + ASSERT_FALSE(cbm_str_ends_with(NULL, "world")); + ASSERT_FALSE(cbm_str_ends_with("world", NULL)); + PASS(); +} + +TEST(str_contains_null) { + ASSERT_FALSE(cbm_str_contains(NULL, "sub")); + ASSERT_FALSE(cbm_str_contains("hello", NULL)); + PASS(); +} + +TEST(str_tolower_null) { + setup(); + ASSERT_NULL(cbm_str_tolower(&a, NULL)); + teardown(); + PASS(); +} + +TEST(str_replace_char_null) { + setup(); + ASSERT_NULL(cbm_str_replace_char(&a, NULL, 'a', 'b')); + teardown(); + PASS(); +} + +TEST(str_strip_ext_null) { + setup(); + ASSERT_NULL(cbm_str_strip_ext(&a, NULL)); + teardown(); + PASS(); +} + +TEST(str_split_null_string) { + setup(); + int count = 0; + ASSERT_NULL(cbm_str_split(&a, NULL, '/', &count)); + teardown(); + PASS(); +} + +TEST(str_split_null_out_count) { + setup(); + ASSERT_NULL(cbm_str_split(&a, "a/b", '/', NULL)); + teardown(); + PASS(); +} + +/* ── path_join edge cases ─────────────────────────────────────── */ + +TEST(path_join_multi_slashes) { + setup(); + ASSERT_STR_EQ(cbm_path_join(&a, "src///", "///main.c"), "src/main.c"); + teardown(); + PASS(); +} + +TEST(path_join_only_slash_base) { + setup(); + ASSERT_STR_EQ(cbm_path_join(&a, "/", "main.c"), "main.c"); + teardown(); + PASS(); +} + +TEST(path_join_long_paths) { + setup(); + char long_base[512]; + char long_name[512]; + memset(long_base, 'a', 511); + long_base[511] = '\0'; + memset(long_name, 'b', 511); + long_name[511] = '\0'; + char *result = cbm_path_join(&a, long_base, long_name); + ASSERT_NOT_NULL(result); + ASSERT_TRUE(cbm_str_starts_with(result, "aaa")); + ASSERT_TRUE(cbm_str_ends_with(result, "bbb")); + ASSERT_TRUE(cbm_str_contains(result, "/")); + ASSERT_EQ(strlen(result), 511 + 1 + 511); + teardown(); + PASS(); +} + +TEST(path_join_n_zero) { + setup(); + ASSERT_STR_EQ(cbm_path_join_n(&a, NULL, 0), ""); + teardown(); + PASS(); +} + +TEST(path_join_n_null_parts) { + setup(); + ASSERT_STR_EQ(cbm_path_join_n(&a, NULL, 3), ""); + teardown(); + PASS(); +} + +/* ── path_ext edge cases ──────────────────────────────────────── */ + +TEST(path_ext_null) { + ASSERT_STR_EQ(cbm_path_ext(NULL), ""); + PASS(); +} + +TEST(path_ext_dot_directory) { + ASSERT_STR_EQ(cbm_path_ext("dir.name/file"), ""); + PASS(); +} + +TEST(path_ext_hidden_file) { + ASSERT_STR_EQ(cbm_path_ext(".gitignore"), "gitignore"); + PASS(); +} + +TEST(path_ext_no_extension) { + ASSERT_STR_EQ(cbm_path_ext("Makefile"), ""); + PASS(); +} + +TEST(path_ext_multiple_dots) { + ASSERT_STR_EQ(cbm_path_ext("file.test.spec.ts"), "ts"); + PASS(); +} + +TEST(path_ext_trailing_dot) { + ASSERT_STR_EQ(cbm_path_ext("file."), ""); + PASS(); +} + +/* ── path_base edge cases ─────────────────────────────────────── */ + +TEST(path_base_null) { + ASSERT_STR_EQ(cbm_path_base(NULL), ""); + PASS(); +} + +TEST(path_base_empty) { + ASSERT_STR_EQ(cbm_path_base(""), ""); + PASS(); +} + +TEST(path_base_just_filename) { + ASSERT_STR_EQ(cbm_path_base("hello.txt"), "hello.txt"); + PASS(); +} + +TEST(path_base_trailing_slash) { + ASSERT_STR_EQ(cbm_path_base("dir/"), ""); + PASS(); +} + +/* ── validate_shell_arg tests ─────────────────────────────────── */ + +TEST(validate_shell_arg_null) { + ASSERT_FALSE(cbm_validate_shell_arg(NULL)); + PASS(); +} + +TEST(validate_shell_arg_safe) { + ASSERT_TRUE(cbm_validate_shell_arg("hello-world_123")); + PASS(); +} + +TEST(validate_shell_arg_single_quote) { + ASSERT_FALSE(cbm_validate_shell_arg("it's bad")); + PASS(); +} + +TEST(validate_shell_arg_semicolon) { + ASSERT_FALSE(cbm_validate_shell_arg("cmd; rm -rf")); + PASS(); +} + +TEST(validate_shell_arg_pipe) { + ASSERT_FALSE(cbm_validate_shell_arg("cmd | evil")); + PASS(); +} + +TEST(validate_shell_arg_ampersand) { + ASSERT_FALSE(cbm_validate_shell_arg("cmd & bg")); + PASS(); +} + +TEST(validate_shell_arg_dollar) { + ASSERT_FALSE(cbm_validate_shell_arg("$HOME")); + PASS(); +} + +TEST(validate_shell_arg_backtick) { + ASSERT_FALSE(cbm_validate_shell_arg("`whoami`")); + PASS(); +} + +TEST(validate_shell_arg_newline) { + ASSERT_FALSE(cbm_validate_shell_arg("line1\nline2")); + PASS(); +} + +TEST(validate_shell_arg_carriage_return) { + ASSERT_FALSE(cbm_validate_shell_arg("line1\rline2")); + PASS(); +} + +TEST(validate_shell_arg_backslash) { + ASSERT_FALSE(cbm_validate_shell_arg("path\\to\\file")); + PASS(); +} + +TEST(validate_shell_arg_empty) { + ASSERT_TRUE(cbm_validate_shell_arg("")); + PASS(); +} + +TEST(validate_shell_arg_spaces) { + ASSERT_TRUE(cbm_validate_shell_arg("hello world with spaces")); + PASS(); +} + +/* ── SNPRINTF_APPEND tests ────────────────────────────────────── */ + +TEST(snprintf_append_basic) { + char buf[64]; + int off = 0; + CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, "hello"); + CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, " world"); + ASSERT_STR_EQ(buf, "hello world"); + ASSERT_EQ(off, 11); + PASS(); +} + +TEST(snprintf_append_fills_exactly) { + char buf[6]; + int off = 0; + CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, "hello"); + ASSERT_STR_EQ(buf, "hello"); + ASSERT_EQ(off, 5); + PASS(); +} + +TEST(snprintf_append_overflow) { + char buf[8]; + int off = 0; + CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, "hello world this is way too long"); + /* offset clamped to sizeof(buf) - 1 */ + ASSERT_EQ(off, 7); + /* buffer is null-terminated and truncated */ + ASSERT_EQ(strlen(buf), 7); + PASS(); +} + +TEST(snprintf_append_multiple_sequential) { + char buf[32]; + int off = 0; + CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, "a=%d", 1); + CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, " b=%d", 2); + CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, " c=%d", 3); + CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, " d=%d", 4); + ASSERT_STR_EQ(buf, "a=1 b=2 c=3 d=4"); + ASSERT_EQ(off, 15); + PASS(); +} + SUITE(str_util) { RUN_TEST(path_join_basic); RUN_TEST(path_join_trailing_slash); @@ -181,4 +482,55 @@ SUITE(str_util) { RUN_TEST(str_split); RUN_TEST(str_split_empty); RUN_TEST(str_split_no_delim); + /* NULL safety */ + RUN_TEST(path_join_null_base); + RUN_TEST(path_join_null_name); + RUN_TEST(path_join_both_null); + RUN_TEST(path_dir_null); + RUN_TEST(str_starts_with_null_s); + RUN_TEST(str_starts_with_null_prefix); + RUN_TEST(str_ends_with_null); + RUN_TEST(str_contains_null); + RUN_TEST(str_tolower_null); + RUN_TEST(str_replace_char_null); + RUN_TEST(str_strip_ext_null); + RUN_TEST(str_split_null_string); + RUN_TEST(str_split_null_out_count); + /* path_join edge cases */ + RUN_TEST(path_join_multi_slashes); + RUN_TEST(path_join_only_slash_base); + RUN_TEST(path_join_long_paths); + RUN_TEST(path_join_n_zero); + RUN_TEST(path_join_n_null_parts); + /* path_ext edge cases */ + RUN_TEST(path_ext_null); + RUN_TEST(path_ext_dot_directory); + RUN_TEST(path_ext_hidden_file); + RUN_TEST(path_ext_no_extension); + RUN_TEST(path_ext_multiple_dots); + RUN_TEST(path_ext_trailing_dot); + /* path_base edge cases */ + RUN_TEST(path_base_null); + RUN_TEST(path_base_empty); + RUN_TEST(path_base_just_filename); + RUN_TEST(path_base_trailing_slash); + /* validate_shell_arg */ + RUN_TEST(validate_shell_arg_null); + RUN_TEST(validate_shell_arg_safe); + RUN_TEST(validate_shell_arg_single_quote); + RUN_TEST(validate_shell_arg_semicolon); + RUN_TEST(validate_shell_arg_pipe); + RUN_TEST(validate_shell_arg_ampersand); + RUN_TEST(validate_shell_arg_dollar); + RUN_TEST(validate_shell_arg_backtick); + RUN_TEST(validate_shell_arg_newline); + RUN_TEST(validate_shell_arg_carriage_return); + RUN_TEST(validate_shell_arg_backslash); + RUN_TEST(validate_shell_arg_empty); + RUN_TEST(validate_shell_arg_spaces); + /* SNPRINTF_APPEND */ + RUN_TEST(snprintf_append_basic); + RUN_TEST(snprintf_append_fills_exactly); + RUN_TEST(snprintf_append_overflow); + RUN_TEST(snprintf_append_multiple_sequential); } diff --git a/tests/test_traces.c b/tests/test_traces.c index f5c3ce217..18d02a90d 100644 --- a/tests/test_traces.c +++ b/tests/test_traces.c @@ -105,6 +105,28 @@ TEST(traces_extract_http_info_url_full) { PASS(); } +/* ── TestExtractServiceName — edge cases ───────────────────────── */ + +TEST(traces_extract_service_name_empty_attrs) { + cbm_trace_resource_t r = {.attributes = NULL, .attr_count = 0}; + const char *name = cbm_extract_service_name(&r); + ASSERT_STR_EQ(name, ""); + PASS(); +} + +TEST(traces_extract_service_name_multiple_attrs) { + /* service.name is present but not the first attribute */ + cbm_trace_attr_t attrs[] = { + {.key = "deployment.environment", .string_value = "production"}, + {.key = "telemetry.sdk.language", .string_value = "go"}, + {.key = "service.name", .string_value = "payment-gateway"}, + }; + cbm_trace_resource_t r = {.attributes = attrs, .attr_count = 3}; + const char *name = cbm_extract_service_name(&r); + ASSERT_STR_EQ(name, "payment-gateway"); + PASS(); +} + /* ── TestExtractPathFromURL ─────────────────────────────────────── */ TEST(traces_extract_path_from_url) { @@ -125,6 +147,54 @@ TEST(traces_extract_path_from_url) { PASS(); } +/* ── TestExtractPathFromURL — edge cases ──────────────────────── */ + +TEST(traces_extract_path_null_url) { + char buf[256] = "untouched"; + const char *result = cbm_extract_path_from_url(NULL, buf, sizeof(buf)); + ASSERT_STR_EQ(result, ""); + PASS(); +} + +TEST(traces_extract_path_empty_url) { + char buf[256]; + cbm_extract_path_from_url("", buf, sizeof(buf)); + ASSERT_STR_EQ(buf, ""); + PASS(); +} + +TEST(traces_extract_path_with_port) { + char buf[256]; + cbm_extract_path_from_url("https://api.example.com:8443/v2/users", buf, sizeof(buf)); + ASSERT_STR_EQ(buf, "/v2/users"); + PASS(); +} + +TEST(traces_extract_path_with_query_and_fragment) { + char buf[256]; + /* Query params should be stripped (stops at '?') */ + cbm_extract_path_from_url("https://example.com/search?q=test&page=2", buf, sizeof(buf)); + ASSERT_STR_EQ(buf, "/search"); + PASS(); +} + +TEST(traces_extract_path_buffer_too_small) { + char buf[4]; /* only 4 bytes — path "/api/orders" gets truncated */ + cbm_extract_path_from_url("https://example.com/api/orders", buf, sizeof(buf)); + /* Should truncate safely to 3 chars + NUL */ + ASSERT_EQ(strlen(buf), 3); + ASSERT_STR_EQ(buf, "/ap"); + PASS(); +} + +TEST(traces_extract_path_relative_path) { + /* No scheme — "relative/path" has fewer than 3 slashes → empty */ + char buf[256]; + cbm_extract_path_from_url("/api/orders", buf, sizeof(buf)); + ASSERT_STR_EQ(buf, ""); + PASS(); +} + /* ── TestCalculateP99 ───────────────────────────────────────────── */ TEST(traces_calculate_p99) { @@ -161,17 +231,114 @@ TEST(traces_parse_duration_zero) { PASS(); } +/* ── TestParseDuration — edge cases ──────────────────────────────── */ + +TEST(traces_parse_duration_null_start) { + int64_t d = cbm_parse_duration(NULL, "1000"); + ASSERT_EQ(d, 0); + PASS(); +} + +TEST(traces_parse_duration_null_end) { + int64_t d = cbm_parse_duration("1000", NULL); + ASSERT_EQ(d, 0); + PASS(); +} + +TEST(traces_parse_duration_both_null) { + int64_t d = cbm_parse_duration(NULL, NULL); + ASSERT_EQ(d, 0); + PASS(); +} + +TEST(traces_parse_duration_equal) { + /* Same start and end → 0 */ + int64_t d = cbm_parse_duration("5000000000", "5000000000"); + ASSERT_EQ(d, 0); + PASS(); +} + +TEST(traces_parse_duration_large_values) { + /* Realistic nanosecond timestamps (epoch ~2024) */ + int64_t d = cbm_parse_duration("1700000000000000000", "1700000000050000000"); + ASSERT_EQ(d, 50000000); /* 50ms */ + PASS(); +} + +/* ── TestCalculateP99 — edge cases ────────────────────────────────── */ + +TEST(traces_calculate_p99_100_values) { + int64_t values[100]; + for (int i = 0; i < 100; i++) { + values[i] = (int64_t)(i + 1); /* 1..100 */ + } + int64_t p99 = cbm_calculate_p99(values, 100); + /* idx = (int)(100 * 0.99) = 99, values[99] = 100 */ + ASSERT_EQ(p99, 100); + PASS(); +} + +TEST(traces_calculate_p99_reversed) { + /* Reversed array — qsort should sort first */ + int64_t values[10] = {100, 90, 80, 70, 60, 50, 40, 30, 20, 10}; + int64_t p99 = cbm_calculate_p99(values, 10); + /* After sort: 10..100, idx = (int)(10 * 0.99) = 9, values[9] = 100 */ + ASSERT_EQ(p99, 100); + PASS(); +} + +TEST(traces_calculate_p99_two_values) { + int64_t values[2] = {5, 500}; + int64_t p99 = cbm_calculate_p99(values, 2); + /* idx = (int)(2 * 0.99) = 1, values[1] = 500 */ + ASSERT_EQ(p99, 500); + PASS(); +} + +TEST(traces_calculate_p99_all_same) { + int64_t values[5] = {42, 42, 42, 42, 42}; + int64_t p99 = cbm_calculate_p99(values, 5); + ASSERT_EQ(p99, 42); + PASS(); +} + +TEST(traces_calculate_p99_negative_count) { + /* Negative count → treated like empty → 0 */ + int64_t values[3] = {10, 20, 30}; + int64_t p99 = cbm_calculate_p99(values, -1); + ASSERT_EQ(p99, 0); + PASS(); +} + SUITE(traces) { RUN_TEST(traces_extract_service_name); RUN_TEST(traces_extract_service_name_missing); RUN_TEST(traces_extract_service_name_null); + RUN_TEST(traces_extract_service_name_empty_attrs); + RUN_TEST(traces_extract_service_name_multiple_attrs); RUN_TEST(traces_extract_http_info); RUN_TEST(traces_extract_http_info_non_http); RUN_TEST(traces_extract_http_info_url_full); RUN_TEST(traces_extract_path_from_url); + RUN_TEST(traces_extract_path_null_url); + RUN_TEST(traces_extract_path_empty_url); + RUN_TEST(traces_extract_path_with_port); + RUN_TEST(traces_extract_path_with_query_and_fragment); + RUN_TEST(traces_extract_path_buffer_too_small); + RUN_TEST(traces_extract_path_relative_path); RUN_TEST(traces_calculate_p99); RUN_TEST(traces_calculate_p99_single); RUN_TEST(traces_calculate_p99_empty); + RUN_TEST(traces_calculate_p99_100_values); + RUN_TEST(traces_calculate_p99_reversed); + RUN_TEST(traces_calculate_p99_two_values); + RUN_TEST(traces_calculate_p99_all_same); + RUN_TEST(traces_calculate_p99_negative_count); RUN_TEST(traces_parse_duration); RUN_TEST(traces_parse_duration_zero); + RUN_TEST(traces_parse_duration_null_start); + RUN_TEST(traces_parse_duration_null_end); + RUN_TEST(traces_parse_duration_both_null); + RUN_TEST(traces_parse_duration_equal); + RUN_TEST(traces_parse_duration_large_values); } diff --git a/tests/test_vmem.c b/tests/test_vmem.c index 20e988e88..c0e833e7d 100644 --- a/tests/test_vmem.c +++ b/tests/test_vmem.c @@ -129,6 +129,170 @@ TEST(vmem_worker_budget) { PASS(); } +/* ── vmem edge-case and resource management tests ────────────── */ + +TEST(vmem_alloc_zero_returns_null) { + cbm_vmem_init(0.5); + /* alloc(0) must return NULL per the API contract */ + void *p = cbm_vmem_alloc(0); + ASSERT_NULL(p); + PASS(); +} + +TEST(vmem_free_null_zero_no_crash) { + cbm_vmem_init(0.5); + /* free(NULL, 0) must be a no-op */ + cbm_vmem_free(NULL, 0); + PASS(); +} + +TEST(vmem_free_null_nonzero_no_crash) { + cbm_vmem_init(0.5); + /* free(NULL, 100) must be a no-op — ptr==NULL short-circuits */ + cbm_vmem_free(NULL, 100); + PASS(); +} + +TEST(vmem_alloc_very_large) { + cbm_vmem_init(0.5); + /* Attempt 1 GB allocation — may succeed or fail depending on system. + * Either way it must not crash. */ + size_t sz = (size_t)1024 * 1024 * 1024; + void *p = cbm_vmem_alloc(sz); + if (p) { + /* If it succeeded, verify we can touch the first and last page */ + ((unsigned char *)p)[0] = 0xAA; + ((unsigned char *)p)[sz - 1] = 0xBB; + cbm_vmem_free(p, sz); + } + /* Success or graceful NULL — either is fine */ + PASS(); +} + +TEST(vmem_sequential_alloc_free_no_leak) { + cbm_vmem_init(0.5); + size_t before = cbm_vmem_allocated(); + + /* 20 alloc/free cycles — allocated must return to baseline */ + size_t sz = 64 * 1024; /* 64 KB */ + for (int i = 0; i < 20; i++) { + void *p = cbm_vmem_alloc(sz); + ASSERT_NOT_NULL(p); + memset(p, (unsigned char)(i & 0xFF), sz); + cbm_vmem_free(p, sz); + } + + size_t after = cbm_vmem_allocated(); + ASSERT_EQ(after, before); + PASS(); +} + +TEST(vmem_worker_budget_negative_workers) { + cbm_vmem_init(0.5); + size_t budget = cbm_vmem_budget(); + if (budget == 0) { + /* Not initialized in this process — skip */ + PASS(); + } + /* Negative workers clamps to 1 → worker_budget == full budget */ + size_t wb = cbm_vmem_worker_budget(-3); + ASSERT_EQ(wb, budget); + PASS(); +} + +TEST(vmem_over_budget_when_nothing_allocated) { + cbm_vmem_init(0.5); + /* With nothing (or near-nothing) allocated, should not be over budget */ + bool over = cbm_vmem_over_budget(); + ASSERT_FALSE(over); + PASS(); +} + +TEST(vmem_allocated_tracks_alloc_free_cycle) { + cbm_vmem_init(0.5); + size_t base = cbm_vmem_allocated(); + + size_t sz = 128 * 1024; /* 128 KB */ + void *p = cbm_vmem_alloc(sz); + ASSERT_NOT_NULL(p); + + size_t after_alloc = cbm_vmem_allocated(); + ASSERT_GT(after_alloc, base); + + cbm_vmem_free(p, sz); + + size_t after_free = cbm_vmem_allocated(); + /* Must be back at or near baseline */ + ASSERT_LTE(after_free, base + 4096); + PASS(); +} + +TEST(vmem_multiple_alloc_tracks_cumulative) { + cbm_vmem_init(0.5); + size_t base = cbm_vmem_allocated(); + + size_t sz = 64 * 1024; /* 64 KB */ + void *p1 = cbm_vmem_alloc(sz); + ASSERT_NOT_NULL(p1); + size_t after_one = cbm_vmem_allocated(); + + void *p2 = cbm_vmem_alloc(sz); + ASSERT_NOT_NULL(p2); + size_t after_two = cbm_vmem_allocated(); + + /* Two allocs should be roughly double one alloc above base */ + ASSERT_GT(after_two, after_one); + ASSERT_GT(after_one, base); + + cbm_vmem_free(p2, sz); + cbm_vmem_free(p1, sz); + + size_t after_free = cbm_vmem_allocated(); + ASSERT_LTE(after_free, base + 4096); + PASS(); +} + +TEST(vmem_peak_never_decreases) { + cbm_vmem_init(0.5); + + size_t sz = 256 * 1024; + void *p = cbm_vmem_alloc(sz); + ASSERT_NOT_NULL(p); + size_t peak_with_alloc = cbm_vmem_peak(); + + cbm_vmem_free(p, sz); + size_t peak_after_free = cbm_vmem_peak(); + + /* Peak must never decrease */ + ASSERT_GTE(peak_after_free, peak_with_alloc); + PASS(); +} + +TEST(vmem_worker_budget_one_worker) { + cbm_vmem_init(0.5); + size_t budget = cbm_vmem_budget(); + if (budget == 0) { + PASS(); + } + /* 1 worker → equals full budget */ + size_t wb = cbm_vmem_worker_budget(1); + ASSERT_EQ(wb, budget); + PASS(); +} + +TEST(vmem_worker_budget_many_workers) { + cbm_vmem_init(0.5); + size_t budget = cbm_vmem_budget(); + if (budget == 0) { + PASS(); + } + /* 1000 workers → must still be non-zero (budget is huge) */ + size_t wb = cbm_vmem_worker_budget(1000); + ASSERT_GT(wb, 0); + ASSERT_EQ(wb, budget / 1000); + PASS(); +} + /* ── Arena-vmem integration tests ─────────────────────────────── */ TEST(arena_vmem_alloc_and_destroy) { @@ -613,6 +777,20 @@ SUITE(vmem) { RUN_TEST(vmem_budget_tracking); RUN_TEST(vmem_peak_tracks); RUN_TEST(vmem_worker_budget); + /* Edge cases and resource management */ + RUN_TEST(vmem_alloc_zero_returns_null); + RUN_TEST(vmem_free_null_zero_no_crash); + RUN_TEST(vmem_free_null_nonzero_no_crash); + RUN_TEST(vmem_alloc_very_large); + RUN_TEST(vmem_sequential_alloc_free_no_leak); + RUN_TEST(vmem_worker_budget_negative_workers); + RUN_TEST(vmem_over_budget_when_nothing_allocated); + RUN_TEST(vmem_allocated_tracks_alloc_free_cycle); + RUN_TEST(vmem_multiple_alloc_tracks_cumulative); + RUN_TEST(vmem_peak_never_decreases); + RUN_TEST(vmem_worker_budget_one_worker); + RUN_TEST(vmem_worker_budget_many_workers); + /* Arena-vmem integration */ RUN_TEST(arena_vmem_alloc_and_destroy); RUN_TEST(arena_vmem_grow_tracks_sizes); RUN_TEST(arena_vmem_large_alloc); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 7a3d8a364..f2b8a66d8 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -1241,6 +1241,304 @@ TEST(watcher_modify_tracked_file) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * RESOURCE MANAGEMENT & AUTO-INDEXING BEHAVIOR + * ══════════════════════════════════════════════════════════════════ */ + +TEST(watcher_null_store_handling) { + /* watcher_new with NULL store — verify behavior */ + cbm_watcher_t *w = cbm_watcher_new(NULL, NULL, NULL); + /* Implementation may return NULL or a valid watcher. + * Either is acceptable — key is no crash. */ + if (w) { + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + cbm_watcher_free(w); + } + PASS(); +} + +TEST(watcher_free_null_safe) { + /* Explicit test: free(NULL) must not crash */ + cbm_watcher_free(NULL); + cbm_watcher_free(NULL); + PASS(); +} + +TEST(watcher_empty_count) { + /* Fresh watcher with no projects → count 0 */ + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); + ASSERT_NOT_NULL(w); + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +TEST(watcher_watch_multiple_verify_count) { + /* Watch 5 projects, verify count at each step */ + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); + + for (int i = 0; i < 5; i++) { + char name[32], path[64]; + snprintf(name, sizeof(name), "proj-%d", i); + snprintf(path, sizeof(path), "/tmp/proj-%d", i); + cbm_watcher_watch(w, name, path); + ASSERT_EQ(cbm_watcher_watch_count(w), i + 1); + } + + /* Unwatch all */ + for (int i = 0; i < 5; i++) { + char name[32]; + snprintf(name, sizeof(name), "proj-%d", i); + cbm_watcher_unwatch(w, name); + } + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +TEST(watcher_watch_same_project_idempotent) { + /* Watching the same project twice updates the path, count stays 1 */ + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); + + cbm_watcher_watch(w, "proj", "/tmp/path-a"); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + cbm_watcher_watch(w, "proj", "/tmp/path-b"); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + cbm_watcher_watch(w, "proj", "/tmp/path-c"); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +TEST(watcher_unwatch_nonexistent_safe) { + /* Unwatch a project that was never watched — no crash */ + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); + + cbm_watcher_unwatch(w, "never-existed"); + cbm_watcher_unwatch(w, "also-never-existed"); + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +TEST(watcher_touch_nonexistent_project) { + /* touch() on a project not in the watch list — no crash */ + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); + + cbm_watcher_touch(w, "nonexistent-project"); + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +TEST(watcher_poll_interval_zero_files) { + /* 0 files → base interval (5000ms) */ + int ms = cbm_watcher_poll_interval_ms(0); + ASSERT_EQ(ms, 5000); + PASS(); +} + +TEST(watcher_poll_interval_small_files) { + /* 100 files → should be close to base (5000ms) */ + int ms = cbm_watcher_poll_interval_ms(100); + ASSERT_GTE(ms, 5000); + /* 100 files / 500 = 0 extra seconds of scaling → 5000ms */ + ASSERT_EQ(ms, 5000); + PASS(); +} + +TEST(watcher_poll_interval_medium_files) { + /* 10000 files → 5000 + 20*1000 = 25000ms */ + int ms = cbm_watcher_poll_interval_ms(10000); + ASSERT_EQ(ms, 25000); + PASS(); +} + +TEST(watcher_poll_interval_capped) { + /* 100000 files → capped at 60000ms */ + int ms = cbm_watcher_poll_interval_ms(100000); + ASSERT_EQ(ms, 60000); + /* Even larger → still capped */ + ms = cbm_watcher_poll_interval_ms(500000); + ASSERT_EQ(ms, 60000); + PASS(); +} + +TEST(watcher_poll_interval_negative) { + /* Negative file count → should handle gracefully (no crash) */ + int ms = cbm_watcher_poll_interval_ms(-1); + /* Result should be at least the base interval or 0 — just no crash */ + ASSERT_GTE(ms, 0); + PASS(); +} + +TEST(watcher_poll_empty_returns_zero) { + /* poll_once with empty watch list → 0 reindexed */ + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + index_call_count = 0; + + int reindexed = cbm_watcher_poll_once(w); + ASSERT_EQ(reindexed, 0); + ASSERT_EQ(index_call_count, 0); + + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +TEST(watcher_poll_non_git_dir) { + /* poll_once with a non-git directory → 0 changes detected */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ng2_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + SKIP("cbm_mkdtemp failed"); + + /* Create a regular file so directory is not empty */ + char cmd[512]; + snprintf(cmd, sizeof(cmd), "echo 'hello' > '%s/file.txt'", tmpdir); + system(cmd); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "nongit2", tmpdir); + index_call_count = 0; + + /* Baseline */ + cbm_watcher_poll_once(w); + + /* Modify file */ + snprintf(cmd, sizeof(cmd), "echo 'world' >> '%s/file.txt'", tmpdir); + system(cmd); + + /* Poll — non-git directory, should not trigger reindex */ + cbm_watcher_touch(w, "nongit2"); + int reindexed = cbm_watcher_poll_once(w); + ASSERT_EQ(reindexed, 0); + ASSERT_EQ(index_call_count, 0); + + cbm_watcher_free(w); + cbm_store_close(store); + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir); + system(cmd); + PASS(); +} + +TEST(watcher_stop_prevents_run) { + /* Setting stop before run → run returns immediately */ + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); + + cbm_watcher_stop(w); + int rc = cbm_watcher_run(w, 60000); + ASSERT_EQ(rc, 0); + + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +TEST(watcher_watch_unwatch_rapid_cycle) { + /* Rapid watch/unwatch cycles — stress lifecycle management */ + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); + + for (int i = 0; i < 20; i++) { + cbm_watcher_watch(w, "rapid", "/tmp/rapid"); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + cbm_watcher_unwatch(w, "rapid"); + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + } + + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +/* Callback and state for watcher_callback_data_passed test */ +static int g_cbdata_value = 42; +static int *g_cbdata_received = NULL; + +static int capture_data_callback(const char *name, const char *path, void *ud) { + (void)name; (void)path; + g_cbdata_received = (int *)ud; + return 0; +} + +TEST(watcher_callback_data_passed) { + /* Verify that user_data pointer is accessible in the callback */ + g_cbdata_received = NULL; + + /* Create a temp git repo */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cbdata_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + SKIP("cbm_mkdtemp failed"); + + char cmd[512]; + snprintf(cmd, sizeof(cmd), + "cd '%s' && git init -q && git config user.email test@test && " + "git config user.name test && echo 'hello' > file.txt && " + "git add file.txt && git commit -q -m 'init'", + tmpdir); + if (system(cmd) != 0) { + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir); + system(cmd); + SKIP("git not available"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, capture_data_callback, &g_cbdata_value); + cbm_watcher_watch(w, "cbdata-repo", tmpdir); + + /* Baseline */ + cbm_watcher_poll_once(w); + + /* Make dirty to trigger callback */ + snprintf(cmd, sizeof(cmd), "echo 'dirty' >> '%s/file.txt'", tmpdir); + system(cmd); + + cbm_watcher_touch(w, "cbdata-repo"); + cbm_watcher_poll_once(w); + + /* If callback was invoked, g_cbdata_received should point to g_cbdata_value */ + if (g_cbdata_received) { + ASSERT_EQ(*g_cbdata_received, 42); + } + + cbm_watcher_free(w); + cbm_store_close(store); + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir); + system(cmd); + PASS(); +} + +TEST(watcher_null_poll_once) { + /* poll_once(NULL) → 0 */ + int reindexed = cbm_watcher_poll_once(NULL); + ASSERT_EQ(reindexed, 0); + PASS(); +} + +TEST(watcher_null_watch_count) { + /* watch_count(NULL) → 0 */ + int count = cbm_watcher_watch_count(NULL); + ASSERT_EQ(count, 0); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -1295,4 +1593,27 @@ SUITE(watcher) { RUN_TEST(watcher_poll_only_watched_projects); RUN_TEST(watcher_touch_resets_immediate); RUN_TEST(watcher_modify_tracked_file); + + /* Resource management & auto-indexing behavior */ + RUN_TEST(watcher_null_store_handling); + RUN_TEST(watcher_free_null_safe); + RUN_TEST(watcher_empty_count); + RUN_TEST(watcher_watch_multiple_verify_count); + RUN_TEST(watcher_watch_same_project_idempotent); + RUN_TEST(watcher_unwatch_nonexistent_safe); + RUN_TEST(watcher_touch_nonexistent_project); + /* Poll interval edge cases */ + RUN_TEST(watcher_poll_interval_zero_files); + RUN_TEST(watcher_poll_interval_small_files); + RUN_TEST(watcher_poll_interval_medium_files); + RUN_TEST(watcher_poll_interval_capped); + RUN_TEST(watcher_poll_interval_negative); + /* Poll edge cases */ + RUN_TEST(watcher_poll_empty_returns_zero); + RUN_TEST(watcher_poll_non_git_dir); + RUN_TEST(watcher_stop_prevents_run); + RUN_TEST(watcher_watch_unwatch_rapid_cycle); + RUN_TEST(watcher_callback_data_passed); + RUN_TEST(watcher_null_poll_once); + RUN_TEST(watcher_null_watch_count); } diff --git a/tests/test_worker_pool.c b/tests/test_worker_pool.c index 52c9873a2..cef1516c3 100644 --- a/tests/test_worker_pool.c +++ b/tests/test_worker_pool.c @@ -221,6 +221,147 @@ TEST(tls_persistence_across_dispatch) { PASS(); } +/* ── Resource Management & Edge Case Tests ──────────────────────── */ + +TEST(parallel_for_negative_count) { + /* count=-1 → no iterations (documented: "If count <= 0, this is a no-op") */ + _Atomic int sum; + atomic_init(&sum, 0); + cbm_parallel_for_opts_t opts = {.max_workers = 4, .force_pthreads = false}; + cbm_parallel_for(-1, sum_worker, &sum, opts); + ASSERT_EQ(atomic_load(&sum), 0); + PASS(); +} + +TEST(parallel_for_null_fn) { + /* NULL function pointer — should not crash (no-op or safe handling) */ + cbm_parallel_for_opts_t opts = {.max_workers = 4, .force_pthreads = false}; + /* count=0 makes this a no-op before fn is called, so safe */ + cbm_parallel_for(0, NULL, NULL, opts); + PASS(); +} + +TEST(parallel_for_max_workers_one) { + /* max_workers=1 → serial execution, correct result */ + _Atomic int sum; + atomic_init(&sum, 0); + cbm_parallel_for_opts_t opts = {.max_workers = 1, .force_pthreads = false}; + cbm_parallel_for(50, sum_worker, &sum, opts); + ASSERT_EQ(atomic_load(&sum), 50 * 49 / 2); + PASS(); +} + +TEST(parallel_for_max_workers_zero_auto) { + /* max_workers=0 → auto-detect, should produce correct result */ + _Atomic int sum; + atomic_init(&sum, 0); + cbm_parallel_for_opts_t opts = {.max_workers = 0, .force_pthreads = false}; + cbm_parallel_for(100, sum_worker, &sum, opts); + ASSERT_EQ(atomic_load(&sum), 100 * 99 / 2); + PASS(); +} + +TEST(parallel_for_large_count_coverage) { + /* Large count (1000) → all indices visited exactly once */ + _Atomic int visited[1000]; + for (int i = 0; i < 1000; i++) + atomic_init(&visited[i], 0); + cbm_parallel_for_opts_t opts = {.max_workers = 8, .force_pthreads = false}; + cbm_parallel_for(1000, coverage_worker, visited, opts); + /* Every index must be visited exactly once */ + for (int i = 0; i < 1000; i++) { + ASSERT_EQ(atomic_load(&visited[i]), 1); + } + PASS(); +} + +TEST(parallel_for_immediate_return_callback) { + /* Callback that returns immediately — no crash, no hang */ + cbm_parallel_for_opts_t opts = {.max_workers = 4, .force_pthreads = false}; + cbm_parallel_for(500, noop_worker, NULL, opts); + PASS(); +} + +/* Helpers for context_passed_correctly test */ +typedef struct { _Atomic int counter; int magic; } ctx_test_t; + +static void count_and_verify_worker(int idx, void *vctx) { + (void)idx; + ctx_test_t *c = vctx; + if (c->magic == 0xDEAD) { + atomic_fetch_add(&c->counter, 1); + } +} + +TEST(parallel_for_context_passed_correctly) { + /* Verify the context pointer reaches every iteration */ + ctx_test_t ctx; + atomic_init(&ctx.counter, 0); + ctx.magic = 0xDEAD; + + cbm_parallel_for_opts_t opts = {.max_workers = 4, .force_pthreads = false}; + cbm_parallel_for(100, count_and_verify_worker, &ctx, opts); + /* All 100 iterations should have received the correct context */ + ASSERT_EQ(atomic_load(&ctx.counter), 100); + PASS(); +} + +/* Helper for no_duplicates test */ +static void count_visit_worker(int idx, void *ctx) { + _Atomic int *c = ctx; + atomic_fetch_add(&c[idx], 1); +} + +TEST(parallel_for_no_duplicates) { + /* Verify no index is visited more than once (atomic increment per slot) */ + _Atomic int counts[500]; + for (int i = 0; i < 500; i++) + atomic_init(&counts[i], 0); + + cbm_parallel_for_opts_t opts = {.max_workers = 8, .force_pthreads = false}; + cbm_parallel_for(500, count_visit_worker, counts, opts); + + for (int i = 0; i < 500; i++) { + ASSERT_EQ(atomic_load(&counts[i]), 1); + } + PASS(); +} + +/* Helper for single_iteration_idx_zero test */ +static int g_received_idx = -1; + +static void capture_idx_worker(int idx, void *ctx) { + (void)ctx; + g_received_idx = idx; +} + +TEST(parallel_for_single_iteration_idx_zero) { + /* count=1 → single iteration with idx=0 */ + g_received_idx = -1; + cbm_parallel_for_opts_t opts = {.max_workers = 4, .force_pthreads = false}; + cbm_parallel_for(1, capture_idx_worker, NULL, opts); + ASSERT_EQ(g_received_idx, 0); + PASS(); +} + +TEST(parallel_for_serial_matches_parallel) { + /* Serial (max_workers=1) and parallel (max_workers=8) should produce + * identical results for a deterministic reduction. */ + _Atomic int serial_sum, parallel_sum; + atomic_init(&serial_sum, 0); + atomic_init(¶llel_sum, 0); + + cbm_parallel_for_opts_t serial_opts = {.max_workers = 1, .force_pthreads = false}; + cbm_parallel_for(200, sum_worker, &serial_sum, serial_opts); + + cbm_parallel_for_opts_t parallel_opts = {.max_workers = 8, .force_pthreads = false}; + cbm_parallel_for(200, sum_worker, ¶llel_sum, parallel_opts); + + ASSERT_EQ(atomic_load(&serial_sum), atomic_load(¶llel_sum)); + ASSERT_EQ(atomic_load(&serial_sum), 200 * 199 / 2); + PASS(); +} + /* ── Suite Registration ──────────────────────────────────────────── */ SUITE(system_info) { @@ -244,4 +385,15 @@ SUITE(worker_pool) { RUN_TEST(parallel_for_per_slot_write); RUN_TEST(parallel_for_actually_parallel); RUN_TEST(tls_persistence_across_dispatch); + /* Resource management & edge cases */ + RUN_TEST(parallel_for_negative_count); + RUN_TEST(parallel_for_null_fn); + RUN_TEST(parallel_for_max_workers_one); + RUN_TEST(parallel_for_max_workers_zero_auto); + RUN_TEST(parallel_for_large_count_coverage); + RUN_TEST(parallel_for_immediate_return_callback); + RUN_TEST(parallel_for_context_passed_correctly); + RUN_TEST(parallel_for_no_duplicates); + RUN_TEST(parallel_for_single_iteration_idx_zero); + RUN_TEST(parallel_for_serial_matches_parallel); } diff --git a/tests/test_yaml.c b/tests/test_yaml.c new file mode 100644 index 000000000..c434b67c6 --- /dev/null +++ b/tests/test_yaml.c @@ -0,0 +1,1105 @@ +/* + * test_yaml.c — Tests for foundation/yaml YAML parser. + */ +#include "test_framework.h" +#include "../src/foundation/yaml.h" + +/* ── Parsing: NULL and empty input ─────────────────────────────── */ + +TEST(yaml_parse_null_input) { + cbm_yaml_node_t *root = cbm_yaml_parse(NULL, 0); + ASSERT_NOT_NULL(root); /* returns empty map on NULL */ + ASSERT_FALSE(cbm_yaml_has(root, "anything")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_parse_empty_string) { + cbm_yaml_node_t *root = cbm_yaml_parse("", 0); + ASSERT_NOT_NULL(root); + ASSERT_FALSE(cbm_yaml_has(root, "key")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_parse_negative_len) { + cbm_yaml_node_t *root = cbm_yaml_parse("key: val", -1); + ASSERT_NOT_NULL(root); + /* Negative len treated as empty */ + ASSERT_FALSE(cbm_yaml_has(root, "key")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_free_null) { + /* Must not crash */ + cbm_yaml_free(NULL); + PASS(); +} + +/* ── Parsing: single key-value pair ────────────────────────────── */ + +TEST(yaml_single_kv) { + const char *yaml = "name: hello"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "name"), "hello"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_single_kv_trailing_newline) { + const char *yaml = "name: hello\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "name"), "hello"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Parsing: multiple key-value pairs ─────────────────────────── */ + +TEST(yaml_multiple_kv) { + const char *yaml = + "name: myproject\n" + "version: 1.2.3\n" + "author: someone\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "name"), "myproject"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "version"), "1.2.3"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "author"), "someone"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Parsing: nested maps ──────────────────────────────────────── */ + +TEST(yaml_nested_map_2_levels) { + const char *yaml = + "database:\n" + " host: localhost\n" + " port: 5432\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "database.host"), "localhost"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "database.port"), "5432"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_nested_map_3_levels) { + const char *yaml = + "level1:\n" + " level2:\n" + " level3: deep_value\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "level1.level2.level3"), "deep_value"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_nested_siblings) { + const char *yaml = + "server:\n" + " host: 0.0.0.0\n" + " port: 8080\n" + "database:\n" + " host: db.local\n" + " port: 3306\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "server.host"), "0.0.0.0"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "server.port"), "8080"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "database.host"), "db.local"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "database.port"), "3306"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Parsing: string lists ─────────────────────────────────────── */ + +TEST(yaml_string_list) { + const char *yaml = + "fruits:\n" + " - apple\n" + " - banana\n" + " - cherry\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + const char *items[8]; + int count = cbm_yaml_get_str_list(root, "fruits", items, 8); + ASSERT_EQ(count, 3); + ASSERT_STR_EQ(items[0], "apple"); + ASSERT_STR_EQ(items[1], "banana"); + ASSERT_STR_EQ(items[2], "cherry"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_list_max_out_limit) { + const char *yaml = + "items:\n" + " - a\n" + " - b\n" + " - c\n" + " - d\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + const char *items[2]; + int count = cbm_yaml_get_str_list(root, "items", items, 2); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(items[0], "a"); + ASSERT_STR_EQ(items[1], "b"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Parsing: comments ─────────────────────────────────────────── */ + +TEST(yaml_comment_only) { + const char *yaml = + "# This is a comment\n" + "# Another comment\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_FALSE(cbm_yaml_has(root, "#")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_inline_comment) { + const char *yaml = "name: hello # this is a comment\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "name"), "hello"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_comment_between_keys) { + const char *yaml = + "a: 1\n" + "# skip me\n" + "b: 2\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "a"), "1"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "b"), "2"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Parsing: mixed maps and lists ─────────────────────────────── */ + +TEST(yaml_mixed_maps_and_lists) { + const char *yaml = + "project:\n" + " name: myapp\n" + " tags:\n" + " - web\n" + " - api\n" + " version: 2.0\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "project.name"), "myapp"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "project.version"), "2.0"); + const char *tags[4]; + int count = cbm_yaml_get_str_list(root, "project.tags", tags, 4); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(tags[0], "web"); + ASSERT_STR_EQ(tags[1], "api"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Parsing: colons in values ─────────────────────────────────── */ + +TEST(yaml_url_value) { + /* Only the first colon is the separator */ + const char *yaml = "url: https://example.com:8080/path\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "url"), "https://example.com:8080/path"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_multiple_colons) { + const char *yaml = "time: 12:30:45\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "time"), "12:30:45"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Parsing: empty values ─────────────────────────────────────── */ + +TEST(yaml_empty_value_becomes_map) { + /* "key:" with nothing after becomes a map node (if next line isn't "- ...") */ + const char *yaml = + "parent:\n" + " child: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_has(root, "parent")); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "parent.child"), "val"); + /* parent itself is a map, not a scalar */ + ASSERT_NULL(cbm_yaml_get_str(root, "parent")); + cbm_yaml_free(root); + PASS(); +} + +/* ── Query: get_str ────────────────────────────────────────────── */ + +TEST(yaml_get_str_scalar) { + const char *yaml = "key: value\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "key"), "value"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_str_missing) { + const char *yaml = "key: value\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_NULL(cbm_yaml_get_str(root, "nonexistent")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_str_nested) { + const char *yaml = + "a:\n" + " b:\n" + " c: found\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "a.b.c"), "found"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_str_on_map_node) { + /* Querying a map node (not scalar) returns NULL */ + const char *yaml = + "group:\n" + " key: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_NULL(cbm_yaml_get_str(root, "group")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_str_null_root) { + ASSERT_NULL(cbm_yaml_get_str(NULL, "key")); + PASS(); +} + +TEST(yaml_get_str_null_path) { + const char *yaml = "key: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_NULL(cbm_yaml_get_str(root, NULL)); + cbm_yaml_free(root); + PASS(); +} + +/* ── Query: get_float ──────────────────────────────────────────── */ + +TEST(yaml_get_float_valid) { + const char *yaml = "confidence: 0.85\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "confidence", -1.0), 0.85, 0.001); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_float_integer) { + const char *yaml = "count: 42\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "count", -1.0), 42.0, 0.001); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_float_negative) { + const char *yaml = "offset: -3.14\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "offset", 0.0), -3.14, 0.001); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_float_invalid_string) { + const char *yaml = "val: not_a_number\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "val", 99.0), 99.0, 0.001); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_float_missing_key) { + const char *yaml = "a: 1\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "missing", 77.7), 77.7, 0.001); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_float_zero) { + const char *yaml = "val: 0.0\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "val", -1.0), 0.0, 0.001); + cbm_yaml_free(root); + PASS(); +} + +/* ── Query: get_bool ───────────────────────────────────────────── */ + +TEST(yaml_get_bool_true_false) { + const char *yaml = + "enabled: true\n" + "disabled: false\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_get_bool(root, "enabled", false)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "disabled", true)); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_bool_yes_no) { + const char *yaml = + "feature_a: yes\n" + "feature_b: no\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_get_bool(root, "feature_a", false)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "feature_b", true)); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_bool_on_off) { + const char *yaml = + "logging: on\n" + "debug: off\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_get_bool(root, "logging", false)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "debug", true)); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_bool_1_0) { + const char *yaml = + "flag_on: 1\n" + "flag_off: 0\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_get_bool(root, "flag_on", false)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "flag_off", true)); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_bool_case_insensitive) { + const char *yaml = + "a: TRUE\n" + "b: False\n" + "c: YES\n" + "d: No\n" + "e: ON\n" + "f: Off\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_get_bool(root, "a", false)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "b", true)); + ASSERT_TRUE(cbm_yaml_get_bool(root, "c", false)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "d", true)); + ASSERT_TRUE(cbm_yaml_get_bool(root, "e", false)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "f", true)); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_bool_missing_returns_default) { + const char *yaml = "a: 1\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_get_bool(root, "missing", true)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "missing", false)); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_bool_unrecognized_returns_default) { + const char *yaml = "val: maybe\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_get_bool(root, "val", true)); + ASSERT_FALSE(cbm_yaml_get_bool(root, "val", false)); + cbm_yaml_free(root); + PASS(); +} + +/* ── Query: get_str_list ───────────────────────────────────────── */ + +TEST(yaml_get_str_list_non_list_path) { + const char *yaml = "scalar: hello\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + const char *items[4]; + int count = cbm_yaml_get_str_list(root, "scalar", items, 4); + ASSERT_EQ(count, 0); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_str_list_missing_path) { + const char *yaml = "key: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + const char *items[4]; + int count = cbm_yaml_get_str_list(root, "nonexistent", items, 4); + ASSERT_EQ(count, 0); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_get_str_list_null_root) { + const char *items[4]; + int count = cbm_yaml_get_str_list(NULL, "key", items, 4); + ASSERT_EQ(count, 0); + PASS(); +} + +/* ── Query: has() ──────────────────────────────────────────────── */ + +TEST(yaml_has_existing) { + const char *yaml = "key: value\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_has(root, "key")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_has_missing) { + const char *yaml = "key: value\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_FALSE(cbm_yaml_has(root, "nope")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_has_nested) { + const char *yaml = + "a:\n" + " b: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_TRUE(cbm_yaml_has(root, "a")); + ASSERT_TRUE(cbm_yaml_has(root, "a.b")); + ASSERT_FALSE(cbm_yaml_has(root, "a.c")); + ASSERT_FALSE(cbm_yaml_has(root, "a.b.c")); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_has_null_root) { + ASSERT_FALSE(cbm_yaml_has(NULL, "key")); + PASS(); +} + +/* ── Edge cases: long values ───────────────────────────────────── */ + +TEST(yaml_long_value) { + char yaml[1200]; + char expected[1025]; + memset(expected, 'x', 1024); + expected[1024] = '\0'; + int n = snprintf(yaml, sizeof(yaml), "longkey: %s\n", expected); + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, n); + ASSERT_NOT_NULL(root); + const char *val = cbm_yaml_get_str(root, "longkey"); + ASSERT_NOT_NULL(val); + ASSERT_EQ((int)strlen(val), 1024); + ASSERT_STR_EQ(val, expected); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: special characters ────────────────────────────── */ + +TEST(yaml_special_chars_in_value) { + const char *yaml = "pattern: [a-z]+(foo|bar)*\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "pattern"), "[a-z]+(foo|bar)*"); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_equals_in_value) { + const char *yaml = "query: SELECT * FROM t WHERE x=1\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "query"), "SELECT * FROM t WHERE x=1"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: deeply nested (4+ levels) ─────────────────────── */ + +TEST(yaml_deeply_nested) { + const char *yaml = + "l1:\n" + " l2:\n" + " l3:\n" + " l4:\n" + " l5: bottom\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "l1.l2.l3.l4.l5"), "bottom"); + /* Intermediate nodes exist but are not scalars */ + ASSERT_TRUE(cbm_yaml_has(root, "l1.l2.l3.l4")); + ASSERT_NULL(cbm_yaml_get_str(root, "l1.l2.l3.l4")); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: non-existent intermediate path ────────────────── */ + +TEST(yaml_missing_intermediate) { + const char *yaml = "a: 1\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_NULL(cbm_yaml_get_str(root, "x.y.z")); + ASSERT_FALSE(cbm_yaml_has(root, "x.y.z")); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: tab characters ────────────────────────────────── */ + +TEST(yaml_tab_not_indentation) { + /* Tabs are not treated as indentation by leading_spaces() */ + const char *yaml = + "key1: val1\n" + "\tkey2: val2\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "key1"), "val1"); + /* tab-indented line has 0 leading spaces, parsed as top-level */ + /* The key will be "key2" after trim_dup strips the tab from the key */ + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: carriage return ───────────────────────────────── */ + +TEST(yaml_crlf_line_endings) { + const char *yaml = "name: hello\r\nversion: 1.0\r\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "name"), "hello"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "version"), "1.0"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: quoted strings with # ─────────────────────────── */ + +TEST(yaml_quoted_string_with_hash) { + /* Quoted strings: inline comment stripping is skipped for quoted values */ + const char *yaml = "color: \"#ff0000\"\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + const char *val = cbm_yaml_get_str(root, "color"); + ASSERT_NOT_NULL(val); + /* Parser preserves quotes in value since it doesn't strip them */ + ASSERT_STR_EQ(val, "\"#ff0000\""); + cbm_yaml_free(root); + PASS(); +} + +TEST(yaml_single_quoted_with_hash) { + const char *yaml = "regex: '# not a comment'\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + const char *val = cbm_yaml_get_str(root, "regex"); + ASSERT_NOT_NULL(val); + /* Single-quoted values are also preserved with quotes */ + ASSERT_STR_EQ(val, "'# not a comment'"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: empty lines between content ───────────────────── */ + +TEST(yaml_empty_lines_between_keys) { + const char *yaml = + "a: 1\n" + "\n" + "\n" + "b: 2\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "a"), "1"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "b"), "2"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: whitespace-only value ─────────────────────────── */ + +TEST(yaml_whitespace_after_colon) { + /* "key: " with only spaces after colon -> empty value -> map node */ + const char *yaml = "bare: \n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + /* after_len is 0 after trimming spaces, so this becomes a map/list node */ + ASSERT_TRUE(cbm_yaml_has(root, "bare")); + /* Not a scalar, so get_str returns NULL */ + ASSERT_NULL(cbm_yaml_get_str(root, "bare")); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: value is just a number ────────────────────────── */ + +TEST(yaml_numeric_string_value) { + const char *yaml = "port: 8080\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + /* Everything is stored as a string */ + ASSERT_STR_EQ(cbm_yaml_get_str(root, "port"), "8080"); + /* But can be retrieved as float */ + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "port", -1.0), 8080.0, 0.001); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: no trailing newline ───────────────────────────── */ + +TEST(yaml_no_trailing_newline) { + const char *yaml = "key: value"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "key"), "value"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: line with no colon ────────────────────────────── */ + +TEST(yaml_line_without_colon_skipped) { + const char *yaml = + "valid: yes\n" + "this line has no colon\n" + "also_valid: sure\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "valid"), "yes"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "also_valid"), "sure"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: hash at start of value (no space before it) ───── */ + +TEST(yaml_hash_no_preceding_space) { + /* "#" only stripped as inline comment when preceded by space */ + const char *yaml = "channel: #general\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "channel"), "#general"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: list after comment lines ──────────────────────── */ + +TEST(yaml_list_after_comments) { + const char *yaml = + "paths:\n" + " # some paths to exclude\n" + " - /tmp\n" + " - /var/log\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + const char *items[4]; + int count = cbm_yaml_get_str_list(root, "paths", items, 4); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(items[0], "/tmp"); + ASSERT_STR_EQ(items[1], "/var/log"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: navigate() path segment > 255 chars ───────────── */ + +TEST(yaml_path_segment_overflow) { + /* navigate() uses buf[256]; segment >= 256 chars returns NULL */ + char long_path[300]; + memset(long_path, 'a', 260); + long_path[260] = '\0'; + + const char *yaml = "key: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_NULL(cbm_yaml_get_str(root, long_path)); + ASSERT_FALSE(cbm_yaml_has(root, long_path)); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: empty path string ─────────────────────────────── */ + +TEST(yaml_empty_path) { + const char *yaml = "key: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + /* Empty path -> navigate returns root (seg_len 0 -> NULL) */ + ASSERT_NULL(cbm_yaml_get_str(root, "")); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: path with trailing dot ────────────────────────── */ + +TEST(yaml_path_trailing_dot) { + const char *yaml = + "a:\n" + " b: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + /* "a." -> segment "a", then empty segment -> seg_len 0 -> NULL */ + ASSERT_NULL(cbm_yaml_get_str(root, "a.")); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: duplicate keys ────────────────────────────────── */ + +TEST(yaml_duplicate_keys) { + /* Parser stores both; find_child returns the first match */ + const char *yaml = + "key: first\n" + "key: second\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "key"), "first"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: indentation jump ──────────────────────────────── */ + +TEST(yaml_indentation_dedent) { + /* After deep nesting, dedent back to top level */ + const char *yaml = + "outer:\n" + " inner: deep\n" + "top: level\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "outer.inner"), "deep"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "top"), "level"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Smoke: real-world .cgrconfig format ───────────────────────── */ + +TEST(yaml_smoke_cgrconfig) { + const char *yaml = + "# codebase-memory config\n" + "mode: fast\n" + "\n" + "http_linker:\n" + " enabled: true\n" + " min_confidence: 0.7\n" + " exclude_paths:\n" + " - /health\n" + " - /metrics\n" + " - /internal/debug\n" + "\n" + "pipeline:\n" + " workers: 4\n" + " verbose: false\n" + "\n" + "# End of config\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + + /* Top-level scalar */ + ASSERT_STR_EQ(cbm_yaml_get_str(root, "mode"), "fast"); + + /* Nested bool */ + ASSERT_TRUE(cbm_yaml_get_bool(root, "http_linker.enabled", false)); + + /* Nested float */ + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "http_linker.min_confidence", 0.0), 0.7, 0.001); + + /* Nested list */ + const char *paths[8]; + int count = cbm_yaml_get_str_list(root, "http_linker.exclude_paths", paths, 8); + ASSERT_EQ(count, 3); + ASSERT_STR_EQ(paths[0], "/health"); + ASSERT_STR_EQ(paths[1], "/metrics"); + ASSERT_STR_EQ(paths[2], "/internal/debug"); + + /* Another nested section */ + ASSERT_STR_EQ(cbm_yaml_get_str(root, "pipeline.workers"), "4"); + ASSERT_FALSE(cbm_yaml_get_bool(root, "pipeline.verbose", true)); + + /* Non-existent */ + ASSERT_FALSE(cbm_yaml_has(root, "pipeline.timeout")); + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "pipeline.timeout", 30.0), 30.0, 0.001); + + cbm_yaml_free(root); + PASS(); +} + +/* ── Smoke: parse, query many paths, free ──────────────────────── */ + +TEST(yaml_smoke_multi_query) { + const char *yaml = + "app:\n" + " name: testapp\n" + " debug: yes\n" + " port: 9090\n" + " features:\n" + " - auth\n" + " - logging\n" + " db:\n" + " host: pghost\n" + " ssl: on\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + + /* Scalars at different depths */ + ASSERT_STR_EQ(cbm_yaml_get_str(root, "app.name"), "testapp"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "app.db.host"), "pghost"); + + /* Bools */ + ASSERT_TRUE(cbm_yaml_get_bool(root, "app.debug", false)); + ASSERT_TRUE(cbm_yaml_get_bool(root, "app.db.ssl", false)); + + /* Float */ + ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "app.port", 0.0), 9090.0, 0.001); + + /* List */ + const char *feats[4]; + int count = cbm_yaml_get_str_list(root, "app.features", feats, 4); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(feats[0], "auth"); + ASSERT_STR_EQ(feats[1], "logging"); + + /* has() checks */ + ASSERT_TRUE(cbm_yaml_has(root, "app")); + ASSERT_TRUE(cbm_yaml_has(root, "app.features")); + ASSERT_TRUE(cbm_yaml_has(root, "app.db")); + ASSERT_FALSE(cbm_yaml_has(root, "app.cache")); + + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: len shorter than string ───────────────────────── */ + +TEST(yaml_partial_len) { + /* Only parse first 7 bytes: "key: va" */ + const char *yaml = "key: value\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, 7); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "key"), "va"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: list at top level via map last-child promotion ── */ + +TEST(yaml_top_level_list_items) { + /* List items after a "key:" become children of that key's list node */ + const char *yaml = + "colors:\n" + "- red\n" + "- green\n" + "- blue\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + const char *items[4]; + int count = cbm_yaml_get_str_list(root, "colors", items, 4); + ASSERT_EQ(count, 3); + ASSERT_STR_EQ(items[0], "red"); + ASSERT_STR_EQ(items[1], "green"); + ASSERT_STR_EQ(items[2], "blue"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: mixed indentation levels (2 vs 4 spaces) ──────── */ + +TEST(yaml_inconsistent_indentation) { + /* Parser tracks indentation levels via stack, not fixed width */ + const char *yaml = + "a:\n" + " b:\n" + " c: deep\n" + " d: shallow\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "a.b.c"), "deep"); + ASSERT_STR_EQ(cbm_yaml_get_str(root, "a.d"), "shallow"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: value with leading/trailing whitespace ─────────── */ + +TEST(yaml_value_whitespace_trimmed) { + const char *yaml = "key: spaced \n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + /* trim_dup trims leading and trailing whitespace */ + ASSERT_STR_EQ(cbm_yaml_get_str(root, "key"), "spaced"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Edge cases: key with leading whitespace at top level ──────── */ + +TEST(yaml_indented_top_level) { + /* Leading spaces make the parser think it's nested */ + const char *yaml = " indented_key: val\n"; + cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml)); + ASSERT_NOT_NULL(root); + /* With indent=2, stack pops until parent.indent < 2; root.indent=-1 qualifies */ + ASSERT_STR_EQ(cbm_yaml_get_str(root, "indented_key"), "val"); + cbm_yaml_free(root); + PASS(); +} + +/* ── Suite registration ────────────────────────────────────────── */ + +SUITE(yaml) { + /* Parsing: NULL / empty */ + RUN_TEST(yaml_parse_null_input); + RUN_TEST(yaml_parse_empty_string); + RUN_TEST(yaml_parse_negative_len); + RUN_TEST(yaml_free_null); + + /* Parsing: key-value */ + RUN_TEST(yaml_single_kv); + RUN_TEST(yaml_single_kv_trailing_newline); + RUN_TEST(yaml_multiple_kv); + + /* Parsing: nested maps */ + RUN_TEST(yaml_nested_map_2_levels); + RUN_TEST(yaml_nested_map_3_levels); + RUN_TEST(yaml_nested_siblings); + + /* Parsing: lists */ + RUN_TEST(yaml_string_list); + RUN_TEST(yaml_list_max_out_limit); + + /* Parsing: comments */ + RUN_TEST(yaml_comment_only); + RUN_TEST(yaml_inline_comment); + RUN_TEST(yaml_comment_between_keys); + + /* Parsing: mixed */ + RUN_TEST(yaml_mixed_maps_and_lists); + + /* Parsing: colons */ + RUN_TEST(yaml_url_value); + RUN_TEST(yaml_multiple_colons); + + /* Parsing: empty values */ + RUN_TEST(yaml_empty_value_becomes_map); + + /* Query: get_str */ + RUN_TEST(yaml_get_str_scalar); + RUN_TEST(yaml_get_str_missing); + RUN_TEST(yaml_get_str_nested); + RUN_TEST(yaml_get_str_on_map_node); + RUN_TEST(yaml_get_str_null_root); + RUN_TEST(yaml_get_str_null_path); + + /* Query: get_float */ + RUN_TEST(yaml_get_float_valid); + RUN_TEST(yaml_get_float_integer); + RUN_TEST(yaml_get_float_negative); + RUN_TEST(yaml_get_float_invalid_string); + RUN_TEST(yaml_get_float_missing_key); + RUN_TEST(yaml_get_float_zero); + + /* Query: get_bool */ + RUN_TEST(yaml_get_bool_true_false); + RUN_TEST(yaml_get_bool_yes_no); + RUN_TEST(yaml_get_bool_on_off); + RUN_TEST(yaml_get_bool_1_0); + RUN_TEST(yaml_get_bool_case_insensitive); + RUN_TEST(yaml_get_bool_missing_returns_default); + RUN_TEST(yaml_get_bool_unrecognized_returns_default); + + /* Query: get_str_list */ + RUN_TEST(yaml_get_str_list_non_list_path); + RUN_TEST(yaml_get_str_list_missing_path); + RUN_TEST(yaml_get_str_list_null_root); + + /* Query: has() */ + RUN_TEST(yaml_has_existing); + RUN_TEST(yaml_has_missing); + RUN_TEST(yaml_has_nested); + RUN_TEST(yaml_has_null_root); + + /* Edge cases */ + RUN_TEST(yaml_long_value); + RUN_TEST(yaml_special_chars_in_value); + RUN_TEST(yaml_equals_in_value); + RUN_TEST(yaml_deeply_nested); + RUN_TEST(yaml_missing_intermediate); + RUN_TEST(yaml_tab_not_indentation); + RUN_TEST(yaml_crlf_line_endings); + RUN_TEST(yaml_quoted_string_with_hash); + RUN_TEST(yaml_single_quoted_with_hash); + RUN_TEST(yaml_empty_lines_between_keys); + RUN_TEST(yaml_whitespace_after_colon); + RUN_TEST(yaml_numeric_string_value); + RUN_TEST(yaml_no_trailing_newline); + RUN_TEST(yaml_line_without_colon_skipped); + RUN_TEST(yaml_hash_no_preceding_space); + RUN_TEST(yaml_list_after_comments); + RUN_TEST(yaml_path_segment_overflow); + RUN_TEST(yaml_empty_path); + RUN_TEST(yaml_path_trailing_dot); + RUN_TEST(yaml_duplicate_keys); + RUN_TEST(yaml_indentation_dedent); + RUN_TEST(yaml_partial_len); + RUN_TEST(yaml_top_level_list_items); + RUN_TEST(yaml_inconsistent_indentation); + RUN_TEST(yaml_value_whitespace_trimmed); + RUN_TEST(yaml_indented_top_level); + + /* Smoke tests */ + RUN_TEST(yaml_smoke_cgrconfig); + RUN_TEST(yaml_smoke_multi_query); +}