From 8779c96c3a2db136e7c113344872d3665423190b Mon Sep 17 00:00:00 2001
From: Radu Ursache <3800336+rursache@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:38:08 +0300
Subject: [PATCH] test(debugger): add C# unit tests for protocol and
breakpoints (#568)
Cover DebugRequest parsing, BreakpointStore, and DebugCommandDispatcher
with a fake session. Wire the project into SharpEmu.slnx.
---
SharpEmu.slnx | 1 +
.../BreakpointStoreTests.cs | 94 ++++++++
.../DebugCommandDispatcherTests.cs | 203 +++++++++++++++++
.../DebugRequestTests.cs | 80 +++++++
.../FakeDebuggerSession.cs | 215 ++++++++++++++++++
.../SharpEmu.Debugger.Tests.csproj | 21 ++
6 files changed, 614 insertions(+)
create mode 100644 tests/SharpEmu.Debugger.Tests/BreakpointStoreTests.cs
create mode 100644 tests/SharpEmu.Debugger.Tests/DebugCommandDispatcherTests.cs
create mode 100644 tests/SharpEmu.Debugger.Tests/DebugRequestTests.cs
create mode 100644 tests/SharpEmu.Debugger.Tests/FakeDebuggerSession.cs
create mode 100644 tests/SharpEmu.Debugger.Tests/SharpEmu.Debugger.Tests.csproj
diff --git a/SharpEmu.slnx b/SharpEmu.slnx
index aa18593..bfdfc11 100644
--- a/SharpEmu.slnx
+++ b/SharpEmu.slnx
@@ -19,6 +19,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/tests/SharpEmu.Debugger.Tests/BreakpointStoreTests.cs b/tests/SharpEmu.Debugger.Tests/BreakpointStoreTests.cs
new file mode 100644
index 0000000..ed031f8
--- /dev/null
+++ b/tests/SharpEmu.Debugger.Tests/BreakpointStoreTests.cs
@@ -0,0 +1,94 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Debugger.Breakpoints;
+using Xunit;
+
+namespace SharpEmu.Debugger.Tests;
+
+public sealed class BreakpointStoreTests
+{
+ [Fact]
+ public void Add_AssignsIncrementalIds()
+ {
+ var store = new BreakpointStore();
+
+ var first = store.Add(BreakpointKind.Execute, 0x1000);
+ var second = store.Add(BreakpointKind.WriteWatch, 0x2000, length: 8);
+
+ Assert.Equal(1, first.Id);
+ Assert.Equal(2, second.Id);
+ Assert.Equal(2, store.Snapshot().Count);
+ }
+
+ [Fact]
+ public void Add_ForcesExecuteLengthToOne()
+ {
+ var store = new BreakpointStore();
+
+ var breakpoint = store.Add(BreakpointKind.Execute, 0x4000, length: 64);
+
+ Assert.Equal(BreakpointKind.Execute, breakpoint.Kind);
+ Assert.Equal(1UL, breakpoint.Length);
+ Assert.True(breakpoint.Covers(0x4000));
+ Assert.False(breakpoint.Covers(0x4001));
+ }
+
+ [Fact]
+ public void Add_PreservesWatchLengthAtLeastOne()
+ {
+ var store = new BreakpointStore();
+
+ var withLength = store.Add(BreakpointKind.ReadWatch, 0x5000, length: 16);
+ var zeroClamped = store.Add(BreakpointKind.AccessWatch, 0x6000, length: 0);
+
+ Assert.Equal(16UL, withLength.Length);
+ Assert.Equal(1UL, zeroClamped.Length);
+ }
+
+ [Fact]
+ public void Remove_And_SetEnabled_MutateStore()
+ {
+ var store = new BreakpointStore();
+ var breakpoint = store.Add(BreakpointKind.Execute, 0x1000);
+
+ Assert.True(store.SetEnabled(breakpoint.Id, enabled: false));
+ Assert.False(store.Snapshot().Single().Enabled);
+ Assert.True(store.Remove(breakpoint.Id));
+ Assert.Empty(store.Snapshot());
+ Assert.False(store.Remove(breakpoint.Id));
+ Assert.False(store.SetEnabled(breakpoint.Id, enabled: true));
+ }
+
+ [Fact]
+ public void FindExecuteHit_ReturnsFirstEnabledExecuteMatch()
+ {
+ var store = new BreakpointStore();
+ var disabled = store.Add(BreakpointKind.Execute, 0x1000);
+ var watch = store.Add(BreakpointKind.WriteWatch, 0x1000, length: 4);
+ var hit = store.Add(BreakpointKind.Execute, 0x1000);
+
+ store.SetEnabled(disabled.Id, enabled: false);
+
+ var found = store.FindExecuteHit(0x1000);
+
+ Assert.NotNull(found);
+ Assert.Equal(hit.Id, found!.Id);
+ Assert.Equal(BreakpointKind.Execute, found.Kind);
+ Assert.NotEqual(watch.Id, found.Id);
+ Assert.Null(store.FindExecuteHit(0x1001));
+ }
+
+ [Fact]
+ public void Clear_RemovesAllBreakpoints()
+ {
+ var store = new BreakpointStore();
+ store.Add(BreakpointKind.Execute, 0x1);
+ store.Add(BreakpointKind.Execute, 0x2);
+
+ store.Clear();
+
+ Assert.Empty(store.Snapshot());
+ Assert.Null(store.FindExecuteHit(0x1));
+ }
+}
diff --git a/tests/SharpEmu.Debugger.Tests/DebugCommandDispatcherTests.cs b/tests/SharpEmu.Debugger.Tests/DebugCommandDispatcherTests.cs
new file mode 100644
index 0000000..d3fc693
--- /dev/null
+++ b/tests/SharpEmu.Debugger.Tests/DebugCommandDispatcherTests.cs
@@ -0,0 +1,203 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Debugger.Protocol;
+using SharpEmu.Debugger.Session;
+using Xunit;
+
+namespace SharpEmu.Debugger.Tests;
+
+public sealed class DebugCommandDispatcherTests
+{
+ private const int MaxMemoryChunk = 64 * 1024;
+
+ [Fact]
+ public void Dispatch_UnknownCommand_Fails()
+ {
+ var dispatcher = CreateDispatcher();
+
+ var response = dispatcher.Dispatch(Parse("""{"command":"nope"}"""));
+
+ Assert.False(response.Ok);
+ Assert.Equal("nope", response.Command);
+ Assert.Contains("Unknown command", response.Error, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Dispatch_Registers_WhenNotPaused_Fails()
+ {
+ var session = new FakeDebuggerSession(DebuggerRunState.Running);
+ var dispatcher = new DebugCommandDispatcher(session);
+
+ var response = dispatcher.Dispatch(Parse("""{"command":"registers"}"""));
+
+ Assert.False(response.Ok);
+ Assert.Equal("Target is not paused.", response.Error);
+ }
+
+ [Fact]
+ public void Dispatch_ContinueAndStep_WhenNotPaused_Fail()
+ {
+ var session = new FakeDebuggerSession(DebuggerRunState.Running);
+ var dispatcher = new DebugCommandDispatcher(session);
+
+ var cont = dispatcher.Dispatch(Parse("""{"command":"continue"}"""));
+ var step = dispatcher.Dispatch(Parse("""{"command":"step"}"""));
+
+ Assert.False(cont.Ok);
+ Assert.Equal("Target is not paused.", cont.Error);
+ Assert.False(step.Ok);
+ Assert.Equal("Target is not paused.", step.Error);
+ Assert.Equal(1, session.ContinueCallCount);
+ Assert.Equal(1, session.StepFrameCallCount);
+ }
+
+ [Fact]
+ public void Dispatch_ReadMemory_RejectsLengthAboveCap()
+ {
+ var session = new FakeDebuggerSession();
+ session.SeedMemory(0x1000, new byte[16]);
+ var dispatcher = new DebugCommandDispatcher(session);
+
+ var oversize = dispatcher.Dispatch(Parse(
+ $$"""{"command":"read-memory","address":"0x1000","length":{{MaxMemoryChunk + 1}}}"""));
+ var zero = dispatcher.Dispatch(Parse(
+ """{"command":"read-memory","address":"0x1000","length":0}"""));
+
+ Assert.False(oversize.Ok);
+ Assert.Contains(MaxMemoryChunk.ToString(), oversize.Error);
+ Assert.False(zero.Ok);
+ Assert.Contains(MaxMemoryChunk.ToString(), zero.Error);
+ }
+
+ [Fact]
+ public void Dispatch_WriteMemory_RejectsPayloadAboveCap()
+ {
+ var session = new FakeDebuggerSession();
+ var dispatcher = new DebugCommandDispatcher(session);
+ var hex = new string('A', (MaxMemoryChunk + 1) * 2);
+
+ var response = dispatcher.Dispatch(Parse(
+ $$"""{"command":"write-memory","address":"0x1000","bytes":"{{hex}}"}"""));
+
+ Assert.False(response.Ok);
+ Assert.Contains(MaxMemoryChunk.ToString(), response.Error);
+ }
+
+ [Fact]
+ public void Dispatch_ReadMemory_WhenPaused_ReturnsHexBytes()
+ {
+ var session = new FakeDebuggerSession();
+ session.SeedMemory(0x2000, [0xDE, 0xAD, 0xBE, 0xEF]);
+ var dispatcher = new DebugCommandDispatcher(session);
+
+ var response = dispatcher.Dispatch(Parse(
+ """{"command":"read-memory","address":"0x2000","length":4}"""));
+
+ Assert.True(response.Ok);
+ Assert.NotNull(response.Data);
+ Assert.Equal("DEADBEEF", GetString(response.Data!, "bytes"));
+ Assert.Equal("0x0000000000002000", GetString(response.Data!, "address"));
+ }
+
+ [Fact]
+ public void Dispatch_BreakpointCrud_HappyPath()
+ {
+ var session = new FakeDebuggerSession();
+ var dispatcher = new DebugCommandDispatcher(session);
+
+ var added = dispatcher.Dispatch(Parse(
+ """{"command":"add-breakpoint","address":"0x401000","kind":"Execute","length":8}"""));
+ Assert.True(added.Ok);
+ Assert.NotNull(added.Data);
+
+ var breakpoint = GetDict(added.Data!, "breakpoint");
+ Assert.Equal(1, Convert.ToInt32(breakpoint["id"]));
+ Assert.Equal("Execute", Assert.IsType(breakpoint["kind"]));
+ Assert.Equal(1UL, Convert.ToUInt64(breakpoint["length"]));
+ Assert.True(Assert.IsType(breakpoint["enabled"]));
+
+ var listed = dispatcher.Dispatch(Parse("""{"command":"list-breakpoints"}"""));
+ Assert.True(listed.Ok);
+ var list = Assert.IsAssignableFrom(listed.Data!["breakpoints"]);
+ Assert.Single(list.Cast