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.
This commit is contained in:
Radu Ursache 2026-07-23 15:38:08 +03:00 committed by GitHub
parent 4c6cff1116
commit 8779c96c3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 614 additions and 0 deletions

View File

@ -19,6 +19,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SharpEmu.Debugger.Tests/SharpEmu.Debugger.Tests.csproj" />
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />

View File

@ -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));
}
}

View File

@ -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<string>(breakpoint["kind"]));
Assert.Equal(1UL, Convert.ToUInt64(breakpoint["length"]));
Assert.True(Assert.IsType<bool>(breakpoint["enabled"]));
var listed = dispatcher.Dispatch(Parse("""{"command":"list-breakpoints"}"""));
Assert.True(listed.Ok);
var list = Assert.IsAssignableFrom<System.Collections.IEnumerable>(listed.Data!["breakpoints"]);
Assert.Single(list.Cast<object?>());
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<string, object?> data, string key)
{
Assert.True(data.TryGetValue(key, out var value));
Assert.NotNull(value);
return Assert.IsType<string>(value);
}
private static IReadOnlyDictionary<string, object?> GetDict(
IReadOnlyDictionary<string, object?> data,
string key)
{
Assert.True(data.TryGetValue(key, out var value));
Assert.NotNull(value);
return Assert.IsAssignableFrom<IReadOnlyDictionary<string, object?>>(value);
}
}

View File

@ -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 _));
}
}

View File

@ -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;
/// <summary>
/// In-memory session for dispatcher tests. Tracks run state and an optional
/// flat guest memory region without parking a real emulation thread.
/// </summary>
internal sealed class FakeDebuggerSession : IDebuggerSession
{
private readonly Dictionary<ulong, byte> _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<DebugStopEvent>? 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<byte> 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<byte> 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<byte> 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)
{
}
}
}

View File

@ -0,0 +1,21 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SharpEmu.Debugger\SharpEmu.Debugger.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
</Project>