using LogiBoardToolBox.Core.HidPlusPlus;
using LogiBoardToolBox.Core.Lighting;
using LogiBoardToolBox.Core.Macros;
using LogiBoardToolBox.Core.Onboard;
using XFEExtension.NetCore.XUnit.Assertions;
using XFEExtension.NetCore.XUnit.Attributes;
namespace LogiBoardToolBox.Test;
[TestFixture]
public class AdvancedSettingsTests
{
[Test]
public async Task LightforceWritesOnlyItsMaskedBitAndSkipsUnchangedState()
{
using var mouse = new FeatureMouse(); using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS"); await controller.ReadAsync();
await controller.SetLightforceAsync(false); Assert.Equal((byte)0xA0, mouse.ModeStatus);
var write = mouse.Requests.Single(r => r[2] == 11 && r[3] >> 4 == 1);
Assert.Equal("00A00001", Convert.ToHexString(write, 4, 4));
await controller.SetLightforceAsync(false); Assert.Equal(1, mouse.Requests.Count(r => r[2] == 11 && r[3] >> 4 == 1));
await controller.SetLightforceAsync(true); Assert.Equal((byte)0xA1, mouse.ModeStatus);
}
[Test]
public async Task StartupTogglePreservesSixOpaqueBytesAndVerifiesReadback()
{
using var mouse = new FeatureMouse(); using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS");
await controller.SetStartupEffectAsync(false);
Assert.Equal(false, controller.State!.StartupEnabled!.Value);
Assert.Equal("0B16212C3742", Convert.ToHexString(mouse.Startup, 4, 6));
await controller.SetStartupEffectAsync(true); Assert.Equal(true, controller.State!.StartupEnabled!.Value);
Assert.Equal(false, mouse.Requests.Any(r => r[2] == 12 && r[3] >> 4 is >= 6 and <= 8));
}
[Test]
public async Task AdvertisedHeroWithFirmwareErrorCannotStartOrApplyCalibration()
{
using var mouse = new FeatureMouse { HeroUnavailable = true }; using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS"); var state = await controller.ReadAsync();
Assert.Equal(false, state.CalibrationSupported); Assert.Equal(true, state.CalibrationIssue.Contains("0x05"));
try { await controller.StartCalibrationAsync(); throw new Exception("Expected rejection"); } catch (NotSupportedException) { }
try { await controller.SetCalibrationAsync(10); throw new Exception("Expected rejection"); } catch (NotSupportedException) { }
Assert.Equal(false, mouse.Requests.Any(r => r[2] == 7 && r[3] >> 4 is 9 or 10));
}
[Test]
public async Task HeroCalibrationInfoRequiresSoftwareModeAndBecomesAvailableAfterSwitch()
{
using var mouse = new FeatureMouse { Mode = 1 }; using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS"); var board = await controller.ReadAsync();
Assert.Equal(false, board.CalibrationSupported); Assert.Equal(true, board.CalibrationIssue.Contains("板载模式"));
Assert.Equal(false, mouse.Requests.Any(r => r[2] == 7 && r[3] >> 4 == 8));
mouse.Mode = 2; var host = await controller.ReadAsync();
Assert.Equal(true, host.CalibrationSupported); Assert.Equal((ushort)800, host.Calibration!.DpiX);
}
[Test]
public async Task HitsSensorCannotExposeLightforceAndG502HasNoInventedLodMode()
{
using var mouse = new FeatureMouse { Hits = true }; using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS"); var state = await controller.ReadAsync();
Assert.Equal(false, state.LightforceSupported); Assert.Equal(false, state.SurfaceModeSupported);
try { await controller.SetLightforceAsync(false); throw new Exception("Expected rejection"); } catch (NotSupportedException) { }
Assert.Equal(false, mouse.Requests.Any(r => r[2] == 11 && r[3] >> 4 == 1));
}
[Test]
public async Task AutomaticSaveCoalescesAnEditingGesture()
{
var count = 0; await using var queue = new ImmediateSaveQueue(() => { count++; return Task.CompletedTask; }, TimeSpan.FromMilliseconds(10));
for (var i = 0; i < 20; i++) queue.Request();
await queue.FlushAsync(); Assert.Equal(1, count); Assert.Equal(false, queue.Pending);
}
[Test]
public async Task EditsArrivingDuringFlashAreSerializedAndNotDropped()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var count = 0; var concurrent = 0; var maximum = 0;
await using var queue = new ImmediateSaveQueue(async () => { maximum = Math.Max(maximum, ++concurrent); count++; if (count == 1) { entered.SetResult(); await release.Task; } concurrent--; }, TimeSpan.FromMilliseconds(1));
queue.Request(); await entered.Task; queue.Request(); release.SetResult(); await queue.FlushAsync();
Assert.Equal(2, count); Assert.Equal(1, maximum);
}
[Test]
public async Task SaveFailureIsSurfacedAndANewEditCanRetry()
{
var count = 0; var failures = 0;
await using var queue = new ImmediateSaveQueue(() => { if (++count == 1) throw new IOException("device disconnected"); return Task.CompletedTask; }, TimeSpan.FromMilliseconds(1));
queue.Failed += (_, _) => failures++; queue.Request();
try { await queue.FlushAsync(); throw new Exception("Expected failure"); } catch (IOException) { }
queue.Request(); await queue.FlushAsync(); Assert.Equal(2, count); Assert.Equal(1, failures);
}
[Test]
public async Task RestoreReturnsNewMacroSpaceToItsExactOriginalErasedBytes()
{
var original = Fixture.Create(10); using var mouse = new FakeMouse(original); using var client = new HidPlusPlusClient(mouse, 1);
var memory = new OnboardMemory(client, original.DeviceIdentity, original.DeviceName); using var scratch = new ScratchDirectory();
var plan = MemoryWritePlan.Create(original, original.GetProfiles()[0], [new(3, false, MacroCompiler.Compile("mouse 1 down\ndelay 20\nmouse 1 up"))], true);
await memory.CommitAsync(plan, scratch.Path); var installed = await memory.ReadAsync();
await memory.CommitAsync(MemoryWritePlan.Restore(installed, original, true), scratch.Path);
await memory.SetModeAsync(false); var restored = await memory.ReadAsync();
Assert.Equal(true, original.Sectors.All(p => p.Value.SequenceEqual(restored.Sectors[p.Key]))); Assert.Equal((byte)2, restored.Mode); Assert.Equal((ushort)0, restored.ActiveSector);
}
[Test]
public void AutoSaveReusesExistingShortAndChainedMacros()
{
foreach (var script in new[] { "tap A", string.Join('\n', Enumerable.Repeat("tap B", 110)) })
{
var snapshot = Fixture.Create(10); var macro = MacroCompiler.Compile(script);
var first = MemoryWritePlan.Create(snapshot, snapshot.GetProfiles()[0], [new(3, false, macro)], true);
foreach (var change in first.Changes) snapshot.Sectors[change.Sector] = change.After;
var next = MemoryWritePlan.Create(snapshot, snapshot.GetProfiles()[0], [new(3, false, macro)], true);
Assert.Equal(0, next.Changes.Count);
}
}
[Test]
public void AnimationDirectionsAndHoldCurveProduceDistinctPlayback()
{
var red = Enumerable.Repeat(new Rgb(255, 0, 0), 8).ToArray(); var blue = Enumerable.Repeat(new Rgb(0, 0, 255), 8).ToArray();
var settings = new LightingSettings { Mode = LightsyncMode.Animation, Period = 1000, Frames = [new(0, red), new(500, blue)] };
Assert.Equal(new Rgb(128, 0, 128), LightingRenderer.Render(settings, 250)[0]);
Assert.Equal(new Rgb(255, 0, 0), LightingRenderer.Render(settings with { Frames = [new(0, red, true, TransitionCurve.None), new(500, blue)] }, 250)[0]);
var bounce = settings with { Direction = AnimationDirection.Bounce };
Assert.Equal(LightingRenderer.Render(bounce, 750)[0], LightingRenderer.Render(bounce, 1250)[0]);
var reversed = settings with { Direction = AnimationDirection.ReverseCycle };
Assert.Equal(LightingRenderer.Render(settings, 100)[0], LightingRenderer.Render(reversed, 900)[0]);
Assert.Equal(true, LightingTransition.Evaluate(TransitionCurve.InCirc, .25) < .25);
Assert.Equal(true, LightingTransition.Evaluate(TransitionCurve.OutCirc, .25) > .25);
}
[Test]
public void RandomAnimationKeepsEditedFrameDurationsAndReproduciblePreview()
{
var red = Enumerable.Repeat(new Rgb(255, 0, 0), 8).ToArray(); var blue = Enumerable.Repeat(new Rgb(0, 0, 255), 8).ToArray();
var settings = new LightingSettings { Mode = LightsyncMode.Animation, Direction = AnimationDirection.Random, Period = 1000, Frames = [new(0, red, false), new(900, blue, false)] };
Assert.Equal(red[0], LightingRenderer.Render(settings, 0)[0]); Assert.Equal(red[0], LightingRenderer.Render(settings, 899)[0]);
Assert.Equal(blue[0], LightingRenderer.Render(settings, 900)[0]); Assert.Equal(blue[0], LightingRenderer.Render(settings, 999)[0]);
Assert.Equal(red[0], LightingRenderer.Render(settings, 1000)[0]);
Assert.Equal(LightingRenderer.Render(settings, 12345)[0], LightingRenderer.Render(settings, 12345)[0]);
}
}
using LogiBoardToolBox.Core.HidPlusPlus;
using LogiBoardToolBox.Core.Lighting;
using LogiBoardToolBox.Core.Macros;
using LogiBoardToolBox.Core.Onboard;
using XFEExtension.NetCore.XUnit.Assertions;
using XFEExtension.NetCore.XUnit.Attributes;
namespace LogiBoardToolBox.Test;
[TestFixture]
public class AdvancedSettingsTests
{
[Test]
public async Task LightforceWritesOnlyItsMaskedBitAndSkipsUnchangedState()
{
using var mouse = new FeatureMouse(); using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS"); await controller.ReadAsync();
await controller.SetLightforceAsync(false); Assert.Equal((byte)0xA0, mouse.ModeStatus);
var write = mouse.Requests.Single(r => r[2] == 11 && r[3] >> 4 == 1);
Assert.Equal("00A00001", Convert.ToHexString(write, 4, 4));
await controller.SetLightforceAsync(false); Assert.Equal(1, mouse.Requests.Count(r => r[2] == 11 && r[3] >> 4 == 1));
await controller.SetLightforceAsync(true); Assert.Equal((byte)0xA1, mouse.ModeStatus);
}
[Test]
public async Task StartupTogglePreservesSixOpaqueBytesAndVerifiesReadback()
{
using var mouse = new FeatureMouse(); using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS");
await controller.SetStartupEffectAsync(false);
Assert.Equal(false, controller.State!.StartupEnabled!.Value);
Assert.Equal("0B16212C3742", Convert.ToHexString(mouse.Startup, 4, 6));
await controller.SetStartupEffectAsync(true); Assert.Equal(true, controller.State!.StartupEnabled!.Value);
Assert.Equal(false, mouse.Requests.Any(r => r[2] == 12 && r[3] >> 4 is >= 6 and <= 8));
}
[Test]
public async Task AdvertisedHeroWithFirmwareErrorCannotStartOrApplyCalibration()
{
using var mouse = new FeatureMouse { HeroUnavailable = true }; using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS"); var state = await controller.ReadAsync();
Assert.Equal(false, state.CalibrationSupported); Assert.Equal(true, state.CalibrationIssue.Contains("0x05"));
try { await controller.StartCalibrationAsync(); throw new Exception("Expected rejection"); } catch (NotSupportedException) { }
try { await controller.SetCalibrationAsync(10); throw new Exception("Expected rejection"); } catch (NotSupportedException) { }
Assert.Equal(false, mouse.Requests.Any(r => r[2] == 7 && r[3] >> 4 is 9 or 10));
}
[Test]
public async Task HeroCalibrationInfoRequiresSoftwareModeAndBecomesAvailableAfterSwitch()
{
using var mouse = new FeatureMouse { Mode = 1 }; using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS"); var board = await controller.ReadAsync();
Assert.Equal(false, board.CalibrationSupported); Assert.Equal(true, board.CalibrationIssue.Contains("板载模式"));
Assert.Equal(false, mouse.Requests.Any(r => r[2] == 7 && r[3] >> 4 == 8));
mouse.Mode = 2; var host = await controller.ReadAsync();
Assert.Equal(true, host.CalibrationSupported); Assert.Equal((ushort)800, host.Calibration!.DpiX);
}
[Test]
public async Task HitsSensorCannotExposeLightforceAndG502HasNoInventedLodMode()
{
using var mouse = new FeatureMouse { Hits = true }; using var client = new HidPlusPlusClient(mouse, 1);
var controller = new AdvancedMouseController(client, "G502 X PLUS"); var state = await controller.ReadAsync();
Assert.Equal(false, state.LightforceSupported); Assert.Equal(false, state.SurfaceModeSupported);
try { await controller.SetLightforceAsync(false); throw new Exception("Expected rejection"); } catch (NotSupportedException) { }
Assert.Equal(false, mouse.Requests.Any(r => r[2] == 11 && r[3] >> 4 == 1));
}
[Test]
public async Task AutomaticSaveCoalescesAnEditingGesture()
{
var count = 0; await using var queue = new ImmediateSaveQueue(() => { count++; return Task.CompletedTask; }, TimeSpan.FromMilliseconds(10));
for (var i = 0; i < 20; i++) queue.Request();
await queue.FlushAsync(); Assert.Equal(1, count); Assert.Equal(false, queue.Pending);
}
[Test]
public async Task EditsArrivingDuringFlashAreSerializedAndNotDropped()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var count = 0; var concurrent = 0; var maximum = 0;
await using var queue = new ImmediateSaveQueue(async () => { maximum = Math.Max(maximum, ++concurrent); count++; if (count == 1) { entered.SetResult(); await release.Task; } concurrent--; }, TimeSpan.FromMilliseconds(1));
queue.Request(); await entered.Task; queue.Request(); release.SetResult(); await queue.FlushAsync();
Assert.Equal(2, count); Assert.Equal(1, maximum);
}
[Test]
public async Task SaveFailureIsSurfacedAndANewEditCanRetry()
{
var count = 0; var failures = 0;
await using var queue = new ImmediateSaveQueue(() => { if (++count == 1) throw new IOException("device disconnected"); return Task.CompletedTask; }, TimeSpan.FromMilliseconds(1));
queue.Failed += (_, _) => failures++; queue.Request();
try { await queue.FlushAsync(); throw new Exception("Expected failure"); } catch (IOException) { }
queue.Request(); await queue.FlushAsync(); Assert.Equal(2, count); Assert.Equal(1, failures);
}
[Test]
public async Task RestoreReturnsNewMacroSpaceToItsExactOriginalErasedBytes()
{
var original = Fixture.Create(10); using var mouse = new FakeMouse(original); using var client = new HidPlusPlusClient(mouse, 1);
var memory = new OnboardMemory(client, original.DeviceIdentity, original.DeviceName); using var scratch = new ScratchDirectory();
var plan = MemoryWritePlan.Create(original, original.GetProfiles()[0], [new(3, false, MacroCompiler.Compile("mouse 1 down\ndelay 20\nmouse 1 up"))], true);
await memory.CommitAsync(plan, scratch.Path); var installed = await memory.ReadAsync();
await memory.CommitAsync(MemoryWritePlan.Restore(installed, original, true), scratch.Path);
await memory.SetModeAsync(false); var restored = await memory.ReadAsync();
Assert.Equal(true, original.Sectors.All(p => p.Value.SequenceEqual(restored.Sectors[p.Key]))); Assert.Equal((byte)2, restored.Mode); Assert.Equal((ushort)0, restored.ActiveSector);
}
[Test]
public void AutoSaveReusesExistingShortAndChainedMacros()
{
foreach (var script in new[] { "tap A", string.Join('\n', Enumerable.Repeat("tap B", 110)) })
{
var snapshot = Fixture.Create(10); var macro = MacroCompiler.Compile(script);
var first = MemoryWritePlan.Create(snapshot, snapshot.GetProfiles()[0], [new(3, false, macro)], true);
foreach (var change in first.Changes) snapshot.Sectors[change.Sector] = change.After;
var next = MemoryWritePlan.Create(snapshot, snapshot.GetProfiles()[0], [new(3, false, macro)], true);
Assert.Equal(0, next.Changes.Count);
}
}
[Test]
public void AnimationDirectionsAndHoldCurveProduceDistinctPlayback()
{
var red = Enumerable.Repeat(new Rgb(255, 0, 0), 8).ToArray(); var blue = Enumerable.Repeat(new Rgb(0, 0, 255), 8).ToArray();
var settings = new LightingSettings { Mode = LightsyncMode.Animation, Period = 1000, Frames = [new(0, red), new(500, blue)] };
Assert.Equal(new Rgb(128, 0, 128), LightingRenderer.Render(settings, 250)[0]);
Assert.Equal(new Rgb(255, 0, 0), LightingRenderer.Render(settings with { Frames = [new(0, red, true, TransitionCurve.None), new(500, blue)] }, 250)[0]);
var bounce = settings with { Direction = AnimationDirection.Bounce };
Assert.Equal(LightingRenderer.Render(bounce, 750)[0], LightingRenderer.Render(bounce, 1250)[0]);
var reversed = settings with { Direction = AnimationDirection.ReverseCycle };
Assert.Equal(LightingRenderer.Render(settings, 100)[0], LightingRenderer.Render(reversed, 900)[0]);
Assert.Equal(true, LightingTransition.Evaluate(TransitionCurve.InCirc, .25) < .25);
Assert.Equal(true, LightingTransition.Evaluate(TransitionCurve.OutCirc, .25) > .25);
}
[Test]
public void RandomAnimationKeepsEditedFrameDurationsAndReproduciblePreview()
{
var red = Enumerable.Repeat(new Rgb(255, 0, 0), 8).ToArray(); var blue = Enumerable.Repeat(new Rgb(0, 0, 255), 8).ToArray();
var settings = new LightingSettings { Mode = LightsyncMode.Animation, Direction = AnimationDirection.Random, Period = 1000, Frames = [new(0, red, false), new(900, blue, false)] };
Assert.Equal(red[0], LightingRenderer.Render(settings, 0)[0]); Assert.Equal(red[0], LightingRenderer.Render(settings, 899)[0]);
Assert.Equal(blue[0], LightingRenderer.Render(settings, 900)[0]); Assert.Equal(blue[0], LightingRenderer.Render(settings, 999)[0]);
Assert.Equal(red[0], LightingRenderer.Render(settings, 1000)[0]);
Assert.Equal(LightingRenderer.Render(settings, 12345)[0], LightingRenderer.Render(settings, 12345)[0]);
}
}