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()); + + var disabled = dispatcher.Dispatch(Parse( + """{"command":"enable-breakpoint","id":1,"enabled":false}""")); + Assert.True(disabled.Ok); + Assert.False(session.Breakpoints.Snapshot().Single().Enabled); + + var removed = dispatcher.Dispatch(Parse( + """{"command":"remove-breakpoint","id":1}""")); + Assert.True(removed.Ok); + Assert.Empty(session.Breakpoints.Snapshot()); + + var missing = dispatcher.Dispatch(Parse( + """{"command":"remove-breakpoint","id":1}""")); + Assert.False(missing.Ok); + Assert.Contains("No breakpoint", missing.Error, StringComparison.Ordinal); + } + + [Fact] + public void Dispatch_PingAndState_Succeed() + { + var session = new FakeDebuggerSession(DebuggerRunState.Running); + var dispatcher = new DebugCommandDispatcher(session); + + var ping = dispatcher.Dispatch(Parse("""{"command":"ping"}""")); + var state = dispatcher.Dispatch(Parse("""{"command":"state"}""")); + + Assert.True(ping.Ok); + Assert.True(state.Ok); + Assert.Equal("Running", GetString(state.Data!, "state")); + } + + [Fact] + public void Dispatch_Pause_RequestsPause() + { + var session = new FakeDebuggerSession(DebuggerRunState.Running); + var dispatcher = new DebugCommandDispatcher(session); + + var response = dispatcher.Dispatch(Parse("""{"command":"pause"}""")); + + Assert.True(response.Ok); + Assert.True(session.PauseRequested); + } + + [Fact] + public void Dispatch_ParseErrorCommand_SurfacesMessage() + { + var dispatcher = CreateDispatcher(); + + var response = dispatcher.Dispatch(Parse( + $$"""{"command":"{{JsonLineDebugProtocol.ParseErrorCommand}}","message":"bad line"}""")); + + Assert.False(response.Ok); + Assert.Equal("bad line", response.Error); + } + + private static DebugCommandDispatcher CreateDispatcher() + => new(new FakeDebuggerSession()); + + private static DebugRequest Parse(string json) + { + Assert.True(DebugRequest.TryParse(json, out var request, out var error), error); + return request; + } + + private static string GetString(IReadOnlyDictionary data, string key) + { + Assert.True(data.TryGetValue(key, out var value)); + Assert.NotNull(value); + return Assert.IsType(value); + } + + private static IReadOnlyDictionary GetDict( + IReadOnlyDictionary data, + string key) + { + Assert.True(data.TryGetValue(key, out var value)); + Assert.NotNull(value); + return Assert.IsAssignableFrom>(value); + } +} diff --git a/tests/SharpEmu.Debugger.Tests/DebugRequestTests.cs b/tests/SharpEmu.Debugger.Tests/DebugRequestTests.cs new file mode 100644 index 0000000..950fb74 --- /dev/null +++ b/tests/SharpEmu.Debugger.Tests/DebugRequestTests.cs @@ -0,0 +1,80 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Debugger.Protocol; +using Xunit; + +namespace SharpEmu.Debugger.Tests; + +public sealed class DebugRequestTests +{ + [Fact] + public void TryParse_AcceptsValidCommandAndNormalizesCase() + { + Assert.True(DebugRequest.TryParse("""{"command":"Ping"}""", out var request, out var error)); + Assert.Equal(string.Empty, error); + Assert.Equal("ping", request.Command); + } + + [Fact] + public void TryParse_ReadsNumericAndHexAddresses() + { + Assert.True(DebugRequest.TryParse( + """{"command":"read-memory","address":"0x1000","length":16}""", + out var request, + out _)); + + Assert.True(request.TryGetUInt64("address", out var address)); + Assert.Equal(0x1000UL, address); + Assert.True(request.TryGetInt32("length", out var length)); + Assert.Equal(16, length); + } + + [Fact] + public void TryParse_ReadsHexLengthStrings() + { + Assert.True(DebugRequest.TryParse( + """{"command":"read-memory","address":4096,"length":"0x20"}""", + out var request, + out _)); + + Assert.True(request.TryGetUInt64("address", out var address)); + Assert.Equal(4096UL, address); + Assert.True(request.TryGetInt32("length", out var length)); + Assert.Equal(0x20, length); + } + + [Fact] + public void TryParse_RejectsMissingCommand() + { + Assert.False(DebugRequest.TryParse("""{"address":1}""", out _, out var error)); + Assert.Contains("command", error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryParse_RejectsNonObjectRoot() + { + Assert.False(DebugRequest.TryParse("""["ping"]""", out _, out var error)); + Assert.Contains("object", error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryParse_RejectsMalformedJson() + { + Assert.False(DebugRequest.TryParse("""{command:""", out _, out var error)); + Assert.Contains("Malformed JSON", error, StringComparison.Ordinal); + } + + [Fact] + public void TryGetBool_ReadsJsonBooleans() + { + Assert.True(DebugRequest.TryParse( + """{"command":"enable-breakpoint","id":1,"enabled":false}""", + out var request, + out _)); + + Assert.True(request.TryGetBool("enabled", out var enabled)); + Assert.False(enabled); + Assert.False(request.TryGetBool("missing", out _)); + } +} diff --git a/tests/SharpEmu.Debugger.Tests/FakeDebuggerSession.cs b/tests/SharpEmu.Debugger.Tests/FakeDebuggerSession.cs new file mode 100644 index 0000000..8729ba2 --- /dev/null +++ b/tests/SharpEmu.Debugger.Tests/FakeDebuggerSession.cs @@ -0,0 +1,215 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Cpu.Debugging; +using SharpEmu.Debugger; +using SharpEmu.Debugger.Breakpoints; +using SharpEmu.Debugger.Session; +using SharpEmu.HLE; + +namespace SharpEmu.Debugger.Tests; + +/// +/// In-memory session for dispatcher tests. Tracks run state and an optional +/// flat guest memory region without parking a real emulation thread. +/// +internal sealed class FakeDebuggerSession : IDebuggerSession +{ + private readonly Dictionary _memory = new(); + private DebugRegisterFile _registers = new( + new ulong[16], + rip: 0, + rflags: 0, + fsBase: 0, + gsBase: 0); + + public FakeDebuggerSession(DebuggerRunState state = DebuggerRunState.Paused) + { + State = state; + Breakpoints = new BreakpointStore(); + Hook = NullCpuDebugHook.Instance; + } + + public BreakpointStore Breakpoints { get; } + + public ICpuDebugHook Hook { get; } + + public DebuggerRunState State { get; set; } + + public DebugStopEvent? LastStop { get; set; } + + public bool PauseRequested { get; private set; } + + public int ContinueCallCount { get; private set; } + + public int StepFrameCallCount { get; private set; } + + public event EventHandler? Stopped + { + add { } + remove { } + } + + public event EventHandler? Resumed + { + add { } + remove { } + } + + public event EventHandler? Terminated + { + add { } + remove { } + } + + public void SetRegisters(DebugRegisterFile registers) => _registers = registers; + + public void SeedMemory(ulong address, ReadOnlySpan bytes) + { + for (var i = 0; i < bytes.Length; i++) + { + _memory[address + (ulong)i] = bytes[i]; + } + } + + public bool TryGetRegisters(out DebugRegisterFile registers) + { + if (State != DebuggerRunState.Paused) + { + registers = default; + return false; + } + + registers = _registers; + return true; + } + + public bool TrySetRegister(DebugRegisterId id, ulong value) + { + if (State != DebuggerRunState.Paused) + { + return false; + } + + var gpr = new ulong[16]; + for (var i = 0; i < 16; i++) + { + gpr[i] = _registers[(CpuRegister)i]; + } + + var rip = _registers.Rip; + var rflags = _registers.Rflags; + var fsBase = _registers.FsBase; + var gsBase = _registers.GsBase; + + switch (id) + { + case DebugRegisterId.Rip: + rip = value; + break; + case DebugRegisterId.Rflags: + rflags = value; + break; + case DebugRegisterId.FsBase: + fsBase = value; + break; + case DebugRegisterId.GsBase: + gsBase = value; + break; + default: + if (!id.IsGeneralPurpose()) + { + return false; + } + + gpr[(int)id] = value; + break; + } + + _registers = new DebugRegisterFile(gpr, rip, rflags, fsBase, gsBase); + return true; + } + + public bool TryReadMemory(ulong address, Span destination) + { + if (State != DebuggerRunState.Paused) + { + return false; + } + + for (var i = 0; i < destination.Length; i++) + { + if (!_memory.TryGetValue(address + (ulong)i, out var value)) + { + return false; + } + + destination[i] = value; + } + + return true; + } + + public bool TryWriteMemory(ulong address, ReadOnlySpan source) + { + if (State != DebuggerRunState.Paused) + { + return false; + } + + SeedMemory(address, source); + return true; + } + + public bool TryReadXmm(int registerIndex, out ulong low, out ulong high) + { + low = 0; + high = 0; + return State == DebuggerRunState.Paused; + } + + public bool Continue() + { + ContinueCallCount++; + if (State != DebuggerRunState.Paused) + { + return false; + } + + State = DebuggerRunState.Running; + return true; + } + + public bool StepFrame() + { + StepFrameCallCount++; + if (State != DebuggerRunState.Paused) + { + return false; + } + + State = DebuggerRunState.Running; + return true; + } + + public void RequestPause() => PauseRequested = true; + + public void NotifyTerminated() => State = DebuggerRunState.Terminated; + + private sealed class NullCpuDebugHook : ICpuDebugHook + { + public static readonly NullCpuDebugHook Instance = new(); + + public void OnFrameEnter(ICpuDebugFrame frame) + { + } + + public void OnFrameExit(ICpuDebugFrame frame, OrbisGen2Result result) + { + } + + public void OnStall(ICpuDebugFrame frame, CpuStallInfo info) + { + } + } +} diff --git a/tests/SharpEmu.Debugger.Tests/SharpEmu.Debugger.Tests.csproj b/tests/SharpEmu.Debugger.Tests/SharpEmu.Debugger.Tests.csproj new file mode 100644 index 0000000..b5a6be7 --- /dev/null +++ b/tests/SharpEmu.Debugger.Tests/SharpEmu.Debugger.Tests.csproj @@ -0,0 +1,21 @@ + + + + + false + false + + + + + + + + + + + +