#include "doctest.h" #include namespace { int lock_calls = 0; int unlock_calls = 0; int try_lock_calls = 0; bool try_lock_result = true; uint32_t try_lock_timeout_seen = 0; void mock_lock() { lock_calls++; } void mock_unlock() { unlock_calls++; } bool mock_try_lock(uint32_t timeout) { try_lock_calls++; try_lock_timeout_seen = timeout; return try_lock_result; } int lock_a_calls = 0; int lock_b_calls = 0; void mock_lock_a() { lock_a_calls++; } void mock_lock_b() { lock_b_calls++; } void reset_mocks() { lock_calls = 0; unlock_calls = 0; try_lock_calls = 0; try_lock_result = true; try_lock_timeout_seen = 0; lock_a_calls = 0; lock_b_calls = 0; } } // namespace TEST_CASE("file_mutex_get with zero registrations returns a no-op mutex") { FileMutex mutex; file_mutex_get(&mutex, "/nowhere/file.txt"); CHECK_EQ(mutex.lock, nullptr); CHECK_EQ(mutex.try_lock, nullptr); CHECK_EQ(mutex.unlock, nullptr); // Calling through a no-op mutex must be safe, and try_lock must report success. file_mutex_lock(&mutex); CHECK_EQ(file_mutex_try_lock(&mutex, 123), true); file_mutex_unlock(&mutex); } TEST_CASE("file_mutex_add/get with a single registration") { reset_mocks(); FileMutex registered = { .lock = mock_lock, .try_lock = mock_try_lock, .unlock = mock_unlock }; file_mutex_add(®istered, "/mock1"); FileMutex mutex; // Exact mount path match. file_mutex_get(&mutex, "/mock1"); CHECK_EQ(mutex.lock, mock_lock); CHECK_EQ(mutex.try_lock, mock_try_lock); CHECK_EQ(mutex.unlock, mock_unlock); // Descendant path match. file_mutex_get(&mutex, "/mock1/nested/file.txt"); CHECK_EQ(mutex.lock, mock_lock); // Unrelated path falls back to no-op. FileMutex unrelated; file_mutex_get(&unrelated, "/other/file.txt"); CHECK_EQ(unrelated.lock, nullptr); // Prefix-but-not-descendant path (e.g. "/mock1x") must not match "/mock1". FileMutex prefix_only; file_mutex_get(&prefix_only, "/mock1x/file.txt"); CHECK_EQ(prefix_only.lock, nullptr); // Exercise the resolved callbacks. file_mutex_get(&mutex, "/mock1"); file_mutex_lock(&mutex); CHECK_EQ(lock_calls, 1); CHECK_EQ(file_mutex_try_lock(&mutex, 42), true); CHECK_EQ(try_lock_calls, 1); CHECK_EQ(try_lock_timeout_seen, 42); file_mutex_unlock(&mutex); CHECK_EQ(unlock_calls, 1); // Re-registering the same path is a no-op: original callbacks remain in place. FileMutex replacement = { .lock = nullptr, .try_lock = nullptr, .unlock = nullptr }; file_mutex_add(&replacement, "/mock1"); file_mutex_get(&mutex, "/mock1"); CHECK_EQ(mutex.lock, mock_lock); } TEST_CASE("file_mutex_add/get with two registrations resolves to the matching path") { reset_mocks(); FileMutex mutex_a = { .lock = mock_lock_a, .try_lock = nullptr, .unlock = nullptr }; FileMutex mutex_b = { .lock = mock_lock_b, .try_lock = nullptr, .unlock = nullptr }; file_mutex_add(&mutex_a, "/mock2a"); file_mutex_add(&mutex_b, "/mock2b"); FileMutex resolved; file_mutex_get(&resolved, "/mock2a/file.txt"); CHECK_EQ(resolved.lock, mock_lock_a); file_mutex_get(&resolved, "/mock2b/file.txt"); CHECK_EQ(resolved.lock, mock_lock_b); // Path matching neither registration falls back to no-op. file_mutex_get(&resolved, "/mock2c/file.txt"); CHECK_EQ(resolved.lock, nullptr); // Registration order matters: the first matching entry wins, not the longest // prefix. A mount nested under an earlier one is shadowed by it. FileMutex mutex_nested = { .lock = nullptr, .try_lock = nullptr, .unlock = nullptr }; file_mutex_add(&mutex_nested, "/mock2a/nested"); file_mutex_get(&resolved, "/mock2a/nested/file.txt"); CHECK_EQ(resolved.lock, mock_lock_a); // still /mock2a, registered first } TEST_CASE("file_mutex_add returns a valid id") { reset_mocks(); FileMutex registered = { .lock = mock_lock, .try_lock = nullptr, .unlock = nullptr }; FileMutexId id = file_mutex_add(®istered, "/mockA"); CHECK_NE(id, FILE_MUTEX_ID_INVALID); } TEST_CASE("file_mutex_add with a duplicate path returns the existing id") { reset_mocks(); FileMutex mutex_1 = { .lock = mock_lock, .try_lock = nullptr, .unlock = nullptr }; FileMutex mutex_2 = { .lock = mock_lock_a, .try_lock = nullptr, .unlock = nullptr }; FileMutexId id_1 = file_mutex_add(&mutex_1, "/mockB"); FileMutexId id_2 = file_mutex_add(&mutex_2, "/mockB"); CHECK_EQ(id_1, id_2); FileMutex resolved; file_mutex_get(&resolved, "/mockB"); CHECK_EQ(resolved.lock, mock_lock); // first registration's callbacks win, unchanged } TEST_CASE("file_mutex_remove removes a registration") { reset_mocks(); FileMutex registered = { .lock = mock_lock, .try_lock = nullptr, .unlock = nullptr }; FileMutexId id = file_mutex_add(®istered, "/mockC"); FileMutex before; file_mutex_get(&before, "/mockC"); CHECK_EQ(before.lock, mock_lock); file_mutex_remove(id); FileMutex after; file_mutex_get(&after, "/mockC"); CHECK_EQ(after.lock, nullptr); } TEST_CASE("file_mutex_remove with an unknown id is a safe no-op") { reset_mocks(); FileMutex registered = { .lock = mock_lock, .try_lock = nullptr, .unlock = nullptr }; file_mutex_add(®istered, "/mockD"); file_mutex_remove(999999); // never issued file_mutex_remove(FILE_MUTEX_ID_INVALID); FileMutex resolved; file_mutex_get(&resolved, "/mockD"); CHECK_EQ(resolved.lock, mock_lock); // untouched } TEST_CASE("file_mutex_remove of one registration leaves others intact") { reset_mocks(); FileMutex mutex_a = { .lock = mock_lock_a, .try_lock = nullptr, .unlock = nullptr }; FileMutex mutex_b = { .lock = mock_lock_b, .try_lock = nullptr, .unlock = nullptr }; FileMutexId id_a = file_mutex_add(&mutex_a, "/mockE1"); file_mutex_add(&mutex_b, "/mockE2"); file_mutex_remove(id_a); FileMutex resolved_a; file_mutex_get(&resolved_a, "/mockE1"); CHECK_EQ(resolved_a.lock, nullptr); FileMutex resolved_b; file_mutex_get(&resolved_b, "/mockE2"); CHECK_EQ(resolved_b.lock, mock_lock_b); }