Skip to content

Commit 0dd0948

Browse files
authored
test: extend test coverage (#73)
1 parent 25b7daa commit 0dd0948

8 files changed

Lines changed: 804 additions & 256 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ dispatcher.emit_safe(PlayerStateChanged{.health = player->health});
223223
- **Shutdown orchestration tests:** `tests/test_shutdown.cpp` tests the `DMK_Shutdown()` function from `DetourModKit.hpp`, verifying idempotent shutdown, hot-reload re-initialization, and correct teardown ordering across all subsystems.
224224
- **Integration tests:** `tests/test_hook_integration.cpp` tests cross-module hooking against `tests/fixtures/hook_target_lib.cpp` (built as a DLL). The DLL exports `extern "C"` functions with volatile magic constants for stable AOB patterns. Includes hot-reload integration tests that exercise full hook/teardown/re-hook cycles, multi-type reload, enable/disable toggling, and repeated cycle stress.
225225
- **Platform tests:** `tests/test_platform.cpp` tests internal loader-lock detection and module pinning utilities from `src/platform.hpp`.
226+
- **Decoder tests:** `tests/test_x86_decode.cpp` tests the internal header `src/x86_decode.hpp` (RIP-relative E9 / EB / FF25 resolvers consumed by `Scanner`). The test file adds `src/` to its include path and drives each decoder with hand-crafted byte buffers.
227+
- **Worker tests:** `tests/test_worker.cpp` covers the `StoppableWorker` RAII `std::jthread` wrapper, including the empty-body early return, swallowed `std::exception` and unknown-exception paths, and idempotent `request_stop()` / `shutdown()`. The loader-lock detach arm is untestable from user code (only reached under DllMain) and is accepted as such.
226228
- **Test fixture pattern:** Each suite uses a `::testing::Test` subclass with `SetUp()`/`TearDown()` for temp file cleanup. Temp file paths must include the process ID (`_getpid()`) and a counter to avoid collisions when CTest runs tests in parallel as separate processes.
227229
- **VMT hook test lifetime:** GoogleTest destroys test-body locals *before* calling `TearDown()`. VMT tests must explicitly call `remove_all_vmt_hooks()` (or `remove_vmt_hook`) before target objects go out of scope. Do not rely on `TearDown()` for VMT cleanup when the hooked object is a test-body local.
228230
- **Coverage gate:** 80% minimum line coverage enforced in CI. All PRs must pass.

docs/tests/README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,21 @@ This is expected when casting `FARPROC` from `GetProcAddress` to a typed functio
280280

281281
These tests enable the test-only `debug_snapshot_use_count()` accessor via `#define DMK_EVENT_DISPATCHER_INTERNAL_TESTING 1` at the top of the translation unit. The macro is not part of the public API and must not be defined in consumer code.
282282

283+
## x86 Control-Flow Decoder Tests
284+
285+
`tests/test_x86_decode.cpp` exercises the internal header `src/x86_decode.hpp`, which is consumed by `Scanner` for RIP-relative jump/call resolution. The decoders are small and pure, so each branch is driven by crafting a byte buffer and calling the decoder directly:
286+
287+
| Test | What it proves |
288+
| ---- | -------------- |
289+
| `DecodeE9Rel32_WrongOpcodeRejected` / `DecodeEbRel8_WrongOpcodeRejected` | Opcode-mismatch short-circuit returns `std::nullopt` without reading the displacement. |
290+
| `DecodeE9Rel32_ValidForwardDisplacement` / `DecodeE9Rel32_ValidBackwardDisplacement` | `base + 5 + disp32` is computed for both positive and negative displacements. |
291+
| `DecodeEbRel8_NegativeDisplacementSignExtended` | `0xFE` on the displacement byte decodes as `-2`, proving the `std::int8_t` cast sign-extends correctly. |
292+
| `DecodeFf25Indirect_WrongFirstByteRejected` / `DecodeFf25Indirect_WrongSecondByteRejected` | Both halves of the compound `FF 25` opcode predicate are rejected independently. |
293+
| `DecodeFf25Indirect_UnreadableSlotRejected` | A displacement that places the indirect slot outside the user address range returns `std::nullopt`. |
294+
| `DecodeFf25Indirect_SlotProducesDestination` | Happy path: the slot pointer is materialised and returned verbatim. An aligned struct lays out the instruction and slot so the RIP-relative displacement is independent of padding. |
295+
296+
The decoder header lives under `src/` (not the public include tree), so the test file adds `src/` to its include path and uses `DetourModKit::detail::` directly.
297+
283298
## Benchmark Harness
284299

285300
`tests/bench_event_dispatcher.cpp` is a standalone microbenchmark executable. It is deliberately not a gtest binary so it can run under any build configuration (release, release+PGO, ASAN, etc.) without dragging in the gtest runtime.
@@ -310,7 +325,9 @@ tests/
310325
├── fixtures/
311326
│ └── hook_target_lib.cpp # Fixture DLL (exported functions for integration tests)
312327
├── test_async_logger.cpp # Async logger tests
328+
├── test_bootstrap.cpp # DllMain lifecycle and instance-gate tests
313329
├── test_config.cpp # Configuration tests
330+
├── test_config_watcher.cpp # INI hot-reload watcher tests
314331
├── test_event_dispatcher.cpp # Event dispatcher tests (incl. fast-path and snapshot stability)
315332
├── test_filesystem.cpp # Filesystem tests
316333
├── test_format.cpp # Format utilities tests
@@ -325,7 +342,9 @@ tests/
325342
├── test_scanner.cpp # AOB scanner tests
326343
├── test_shutdown.cpp # DMK_Shutdown orchestration tests
327344
├── test_string.cpp # String utilities tests
328-
└── test_win_file_stream.cpp # Win32 file stream tests
345+
├── test_win_file_stream.cpp # Win32 file stream tests
346+
├── test_worker.cpp # StoppableWorker jthread RAII tests
347+
└── test_x86_decode.cpp # x86 control-flow instruction decoders (internal)
329348
330349
docs/tests/
331350
├── README.md # This guide

tests/test_async_logger.cpp

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,67 @@ TEST(AsyncLoggerConfigTest, NullMutex_Throws)
470470
EXPECT_THROW(AsyncLogger(config, file_stream, nullptr), std::invalid_argument);
471471
}
472472

473+
TEST(AsyncLoggerConfigTest, Validate_DefaultIsValid)
474+
{
475+
AsyncLoggerConfig config;
476+
EXPECT_TRUE(config.validate());
477+
}
478+
479+
TEST(AsyncLoggerConfigTest, Validate_RejectsNonPowerOfTwoCapacity)
480+
{
481+
AsyncLoggerConfig config;
482+
config.queue_capacity = 5;
483+
EXPECT_FALSE(config.validate());
484+
}
485+
486+
TEST(AsyncLoggerConfigTest, Validate_RejectsCapacityBelowTwo)
487+
{
488+
AsyncLoggerConfig config;
489+
config.queue_capacity = 1;
490+
EXPECT_FALSE(config.validate());
491+
config.queue_capacity = 0;
492+
EXPECT_FALSE(config.validate());
493+
}
494+
495+
TEST(AsyncLoggerConfigTest, Validate_RejectsZeroBatch)
496+
{
497+
AsyncLoggerConfig config;
498+
config.batch_size = 0;
499+
EXPECT_FALSE(config.validate());
500+
}
501+
502+
TEST(AsyncLoggerConfigTest, Validate_RejectsNonPositiveFlushInterval)
503+
{
504+
AsyncLoggerConfig config;
505+
config.flush_interval = std::chrono::milliseconds{0};
506+
EXPECT_FALSE(config.validate());
507+
config.flush_interval = std::chrono::milliseconds{-1};
508+
EXPECT_FALSE(config.validate());
509+
}
510+
511+
TEST(AsyncLoggerConfigTest, Validate_RejectsZeroSpinBackoff)
512+
{
513+
AsyncLoggerConfig config;
514+
config.spin_backoff_iterations = 0;
515+
EXPECT_FALSE(config.validate());
516+
}
517+
518+
TEST(AsyncLoggerConfigTest, Validate_RejectsNonPositiveBlockTimeout)
519+
{
520+
AsyncLoggerConfig config;
521+
config.block_timeout_ms = std::chrono::milliseconds{0};
522+
EXPECT_FALSE(config.validate());
523+
config.block_timeout_ms = std::chrono::milliseconds{-5};
524+
EXPECT_FALSE(config.validate());
525+
}
526+
527+
TEST(AsyncLoggerConfigTest, Validate_RejectsZeroBlockMaxSpin)
528+
{
529+
AsyncLoggerConfig config;
530+
config.block_max_spin_iterations = 0;
531+
EXPECT_FALSE(config.validate());
532+
}
533+
473534
TEST(DynamicMPMCQueueTest, InvalidCapacity_NotPowerOfTwo)
474535
{
475536
EXPECT_THROW(DynamicMPMCQueue(3), std::invalid_argument);
@@ -598,36 +659,6 @@ TEST(LogMessageTest, LargeMessage_HeapPath)
598659
EXPECT_NE(log_msg.overflow, nullptr);
599660
}
600661

601-
TEST(AsyncLoggerConfigTest, Validate_AllReturnValues)
602-
{
603-
AsyncLoggerConfig valid_config;
604-
EXPECT_TRUE(valid_config.validate());
605-
606-
AsyncLoggerConfig zero_cap;
607-
zero_cap.queue_capacity = 0;
608-
EXPECT_FALSE(zero_cap.validate());
609-
610-
AsyncLoggerConfig cap_one;
611-
cap_one.queue_capacity = 1;
612-
EXPECT_FALSE(cap_one.validate());
613-
614-
AsyncLoggerConfig bad_cap;
615-
bad_cap.queue_capacity = 100;
616-
EXPECT_FALSE(bad_cap.validate());
617-
618-
AsyncLoggerConfig zero_batch;
619-
zero_batch.batch_size = 0;
620-
EXPECT_FALSE(zero_batch.validate());
621-
622-
AsyncLoggerConfig zero_interval;
623-
zero_interval.flush_interval = std::chrono::milliseconds{0};
624-
EXPECT_FALSE(zero_interval.validate());
625-
626-
AsyncLoggerConfig zero_spin;
627-
zero_spin.spin_backoff_iterations = 0;
628-
EXPECT_FALSE(zero_spin.validate());
629-
}
630-
631662
TEST(AsyncLoggerConfigTest, Validate_ValidSpinBackoff)
632663
{
633664
AsyncLoggerConfig config;

tests/test_bootstrap.cpp

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,84 @@ TEST(BootstrapUnitTest, RequestShutdownBeforeAttachIsNoOp)
7171
EXPECT_NO_THROW(Bootstrap::request_shutdown());
7272
}
7373

74+
TEST(BootstrapUnitTest, ModuleHandleReturnsNullBeforeAttach)
75+
{
76+
EXPECT_EQ(Bootstrap::module_handle(), nullptr);
77+
}
78+
79+
TEST(BootstrapUnitTest, NullHModuleSkipsDisableThreadLibraryCalls)
80+
{
81+
CallbackSignals sig;
82+
Bootstrap::ModInfo info{};
83+
info.prefix = "BS_TEST";
84+
info.log_file = "bs_test_nullmod.log";
85+
info.game_process_name = "DefinitelyNotTheCurrentProcess_null.exe";
86+
info.instance_mutex_prefix = "BS_Test_Mutex_NullMod_";
87+
88+
const BOOL result = Bootstrap::on_dll_attach(
89+
nullptr,
90+
info,
91+
[&sig]() noexcept
92+
{
93+
sig.init_calls.fetch_add(1, std::memory_order_relaxed);
94+
return true;
95+
},
96+
[&sig]() noexcept
97+
{
98+
sig.shutdown_calls.fetch_add(1, std::memory_order_relaxed);
99+
});
100+
101+
EXPECT_EQ(result, FALSE);
102+
EXPECT_EQ(sig.init_calls.load(), 0);
103+
EXPECT_EQ(sig.shutdown_calls.load(), 0);
104+
}
105+
106+
TEST(BootstrapUnitTest, EmptyProcessNamePassesGateButMutexCollisionStillFails)
107+
{
108+
// Pre-own the mutex name so the attach still fails without arming the
109+
// shutdown event or the worker thread.
110+
const std::string_view prefix = "BS_Test_Mutex_EmptyGate_";
111+
112+
wchar_t expected_name[128]{};
113+
std::wstring wprefix;
114+
wprefix.reserve(prefix.size());
115+
for (char c : prefix)
116+
{
117+
wprefix.push_back(static_cast<wchar_t>(static_cast<unsigned char>(c)));
118+
}
119+
const int n = wsprintfW(expected_name, L"%s%lu", wprefix.c_str(),
120+
GetCurrentProcessId());
121+
ASSERT_GT(n, 0);
122+
123+
HANDLE pre_owned = CreateMutexW(nullptr, FALSE, expected_name);
124+
ASSERT_NE(pre_owned, nullptr);
125+
ASSERT_EQ(GetLastError(), 0u);
126+
127+
CallbackSignals sig;
128+
Bootstrap::ModInfo info{};
129+
info.prefix = "BS_TEST";
130+
info.log_file = "bs_test_emptygate.log";
131+
info.game_process_name = "";
132+
info.instance_mutex_prefix = prefix;
133+
134+
const BOOL result = Bootstrap::on_dll_attach(
135+
GetModuleHandleW(nullptr),
136+
info,
137+
[&sig]() noexcept
138+
{
139+
sig.init_calls.fetch_add(1, std::memory_order_relaxed);
140+
return true;
141+
},
142+
[&sig]() noexcept
143+
{
144+
sig.shutdown_calls.fetch_add(1, std::memory_order_relaxed);
145+
});
146+
147+
EXPECT_EQ(result, FALSE);
148+
EXPECT_EQ(sig.init_calls.load(), 0);
149+
CloseHandle(pre_owned);
150+
}
151+
74152
TEST(BootstrapUnitTest, ProcessGateMismatchReturnsFalse)
75153
{
76154
CallbackSignals sig;
@@ -230,3 +308,55 @@ TEST_F(BootstrapIntegrationTest, HappyPathAttachInitShutdown)
230308

231309
attached = false;
232310
}
311+
312+
TEST_F(BootstrapIntegrationTest, InitAndShutdownExceptionsAreCaught)
313+
{
314+
// Drain any globals left set by a prior successful attach (HappyPath
315+
// leaves g_shutdown_event / g_worker_thread non-null for its design);
316+
// this is the first on_dll_detach call in the process and will win
317+
// the CAS and clear the static handles.
318+
Bootstrap::on_dll_detach(FALSE);
319+
320+
const std::string exe_name = current_exe_basename();
321+
ASSERT_FALSE(exe_name.empty());
322+
323+
Bootstrap::ModInfo info{};
324+
info.prefix = "BS_TEST";
325+
info.log_file = "bs_test_throws.log";
326+
info.game_process_name = exe_name;
327+
info.instance_mutex_prefix = "BS_Test_Mutex_Throws_";
328+
329+
auto init_fn = [this]() -> bool
330+
{
331+
sig.init_calls.fetch_add(1, std::memory_order_relaxed);
332+
{
333+
std::lock_guard lock(sig.m);
334+
sig.init_done.store(true, std::memory_order_release);
335+
}
336+
sig.cv.notify_all();
337+
throw std::runtime_error("init failure");
338+
};
339+
340+
auto shutdown_fn = [this]()
341+
{
342+
sig.shutdown_calls.fetch_add(1, std::memory_order_relaxed);
343+
{
344+
std::lock_guard lock(sig.m);
345+
sig.shutdown_done.store(true, std::memory_order_release);
346+
}
347+
sig.cv.notify_all();
348+
throw std::runtime_error("shutdown failure");
349+
};
350+
351+
const BOOL result = Bootstrap::on_dll_attach(
352+
GetModuleHandleW(nullptr), info, init_fn, shutdown_fn);
353+
ASSERT_EQ(result, TRUE);
354+
attached = true;
355+
356+
ASSERT_TRUE(sig.wait_for_init(kTestTimeout));
357+
EXPECT_EQ(sig.init_calls.load(), 1);
358+
359+
Bootstrap::request_shutdown();
360+
ASSERT_TRUE(sig.wait_for_shutdown(kTestTimeout));
361+
EXPECT_EQ(sig.shutdown_calls.load(), 1);
362+
}

0 commit comments

Comments
 (0)