XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

LogiBoardToolBox

【WinUI】罗技鼠标板载驱动程序

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Text;
using LogiBoardToolBox.Core.HidPlusPlus;
using LogiBoardToolBox.Core.Macros;
using LogiBoardToolBox.Core.Onboard;
using XFEExtension.NetCore.XUnit.Assertions;
using XFEExtension.NetCore.XUnit.Attributes;

namespace LogiBoardToolBox.Test;

[TestFixture]
public class OnboardTests
{
    [Test] public void CrcMatchesPublishedCheckVector() => Assert.Equal((ushort)0x29B1, SectorChecksum.Compute(Encoding.ASCII.GetBytes("123456789")));

    [Test]
    public void EditingBindingPreservesUnknownBytes()
    {
        var snapshot = Fixture.Create();
        var original = snapshot.Sectors[1];
        var profile = snapshot.GetProfiles()[0].Copy();
        profile.SetBinding(3, true, ButtonBinding.Keyboard(1, 6));
        var changed = profile.ToBytes();
        for (var i = 0; i < changed.Length - 2; i++)
            if (i is < 108 or >= 112) Assert.Equal(original[i], changed[i]);
        Assert.Equal(true, SectorChecksum.IsValid(changed));
    }

    [Test]
    public void EmptyNameDoesNotReplaceFactoryPadding()
    {
        var snapshot = Fixture.Create(); var profile = snapshot.GetProfiles()[0];
        profile.Name = profile.Name;
        Assert.Equal(Convert.ToHexString(snapshot.Sectors[1]), Convert.ToHexString(profile.ToBytes()));
    }

    [Test]
    public void MacroCompilesToFirmwareOpcodes()
    {
        var compiled = MacroCompiler.Compile("tap Ctrl+C\ndelay 50\ntap Ctrl+V");
        Assert.Equal("430106440106400032430119440119FF", Convert.ToHexString(compiled.Bytes));
        Assert.Equal("43000444000402FF", Convert.ToHexString(MacroCompiler.Compile("tap A", MacroRepeat.WhileHeld).Bytes));
    }

    [Test]
    public void InvalidMacrosAreRejectedBeforePlanning()
    {
        Reject<FormatException>(() => MacroCompiler.Compile("key Shift down"));
        Reject<FormatException>(() => MacroCompiler.Compile("key A up"));
        Reject<FormatException>(() => MacroCompiler.Compile("delay 65536"));
        Reject<FormatException>(() => MacroCompiler.Compile("tap 中文"));
        Reject<FormatException>(() => MacroCompiler.Compile("mouse 0 down"));
        Reject<FormatException>(() => MacroCompiler.Compile("tap A+B"));
    }

    [Test] public void MouseBindingsUseBitMasks() => Assert.Equal("80010010", Convert.ToHexString(ButtonBinding.Mouse(5)));

    [Test]
    public void StandardDelaySeparatesDownAndUpEvents()
    {
        var macro = MacroCompiler.WithStandardDelay(MacroCompiler.Compile("tap A"), 50);
        Assert.Equal("430004400032440004400032FF", Convert.ToHexString(macro.Bytes));
    }

    [Test]
    public void SequenceIsNotSilentlyConvertedToToggle()
        => Reject<NotSupportedException>(() => MacroCompiler.Compile("tap A", MacroRepeat.Sequence));

    [Test]
    public async Task SwitchingModeDoesNotWriteFlash()
    {
        using var fake = new FakeMouse(Fixture.Create()); using var client = new HidPlusPlusClient(fake, 1);
        var memory = new OnboardMemory(client, "test-device", "Test Mouse");
        var onboard = await memory.SetModeAsync(true); Assert.Equal((byte)1, onboard.Mode);
        var host = await memory.SetModeAsync(false); Assert.Equal((byte)2, host.Mode);
        Assert.Equal(0, fake.WriteStarts);
    }

    [Test]
    public void SharedMacrosArePackedAndDeduplicated()
    {
        var snapshot = Fixture.Create(); var macro = MacroCompiler.Compile("tap A");
        var allocation = MacroAllocator.Allocate(snapshot, [new(3, false, macro), new(4, true, macro)]);
        Assert.Equal(1, allocation.Sectors.Count);
        Assert.Equal(allocation.Bindings[(3, false)], allocation.Bindings[(4, true)]);
        Assert.Equal(true, allocation.Sectors.Values.All(b => SectorChecksum.IsValid(b)));
    }

    [Test]
    public void MacroEntriesFollowNativeTwoByteAlignment()
    {
        var snapshot = Fixture.Create(10);
        var first = MacroCompiler.Compile("tap A");
        var second = MacroCompiler.Compile("tap B");
        var allocation = MacroAllocator.Allocate(snapshot, [new(3, false, first), new(4, false, second)]);
        Assert.Equal(new MacroLocation(6, 0), allocation.Bindings[(3, false)]);
        Assert.Equal(new MacroLocation(6, 8), allocation.Bindings[(4, false)]);
        Assert.Equal((byte)0xFF, allocation.Sectors[6][7]);
        foreach (var sector in allocation.Sectors) snapshot.Sectors[sector.Key] = sector.Value;
        Assert.Equal(Convert.ToHexString(second.Bytes), Convert.ToHexString(MacroAllocator.ReadMacroBytes(snapshot, allocation.Bindings[(4, false)])));
    }

    [Test]
    public void ReimportMigratesOddEntryWithoutOverwritingSharedLegacySector()
    {
        var snapshot = Fixture.Create(10); var macro = MacroCompiler.Compile("tap A");
        var sector = Fixture.MacroSector([]); macro.Bytes.CopyTo(sector, 5); SectorChecksum.Seal(sector); snapshot.Sectors[6] = sector;
        foreach (var index in new[] { 0, 1 })
        {
            var profile = snapshot.GetProfiles()[index]; profile.SetBinding(3, false, ButtonBinding.Macro(6, 5)); snapshot.Sectors[profile.Sector] = profile.ToBytes();
        }
        var original = Convert.ToHexString(sector);
        var plan = MemoryWritePlan.Create(snapshot, snapshot.GetProfiles()[0], [new(3, false, macro)], true);
        Assert.Equal(2, plan.Changes.Count);
        Assert.Equal(true, plan.Changes.Any(c => c.Sector == 7));
        Assert.Equal(true, plan.Changes.All(c => c.Sector is 1 or 7));
        Assert.Equal(original, Convert.ToHexString(snapshot.Sectors[6]));
        foreach (var change in plan.Changes) snapshot.Sectors[change.Sector] = change.After;
        var changedProfile = snapshot.GetProfiles()[0];
        var binding = changedProfile.GetBinding(3);
        Assert.Equal((ushort)7, ButtonBinding.MacroSector(binding)); Assert.Equal((ushort)0, ButtonBinding.MacroOffset(binding));
    }

    [Test]
    public void AlignmentPaddingCannotPushAnEntryIntoChecksumBytes()
    {
        var snapshot = Fixture.Create(10);
        var first = MacroCompiler.Compile(string.Join('\n', Enumerable.Repeat("delay 1", 83)), MacroRepeat.WhileHeld);
        var allocation = MacroAllocator.Allocate(snapshot, [new(3, false, first), new(4, false, MacroCompiler.Compile("wait-release"))]);
        Assert.Equal(251, first.Length);
        Assert.Equal(new MacroLocation(7, 0), allocation.Bindings[(4, false)]);
        Assert.Equal(true, allocation.Sectors.Values.All(data => SectorChecksum.IsValid(data)));
    }

    [Test]
    public void LongMacroUsesJumpChainAtInstructionBoundaries()
    {
        var snapshot = Fixture.Create(); var macro = MacroCompiler.Compile(string.Join('\n', Enumerable.Repeat("tap A", 110)));
        var allocation = MacroAllocator.Allocate(snapshot, [new(3, false, macro)]);
        Assert.Equal(true, allocation.Sectors.Count > 1);
        foreach (var entry in allocation.Sectors) snapshot.Sectors[entry.Key] = entry.Value;
        var profile = snapshot.GetProfiles()[0]; var location = allocation.Bindings[(3, false)];
        profile.SetBinding(3, false, ButtonBinding.Macro(location.Sector, location.Offset)); snapshot.Sectors[1] = profile.ToBytes();
        var used = MacroAllocator.FindReferencedSectors(snapshot);
        Assert.Equal(true, allocation.Sectors.Keys.All(used.Contains));
    }

    [Test]
    public void ExistingJumpTargetsStayReserved()
    {
        var snapshot = Fixture.Create(); var profile = snapshot.GetProfiles()[0];
        profile.SetBinding(3, false, ButtonBinding.Macro(6)); snapshot.Sectors[1] = profile.ToBytes();
        snapshot.Sectors[6] = Fixture.MacroSector([0x60, 0, 7]);
        snapshot.Sectors[7] = Fixture.MacroSector([0x43, 0, 4, 0x44, 0, 4, 0xFF]);
        var allocation = MacroAllocator.Allocate(snapshot, [new(4, false, MacroCompiler.Compile("tap B"))]);
        Assert.Equal((ushort)8, allocation.Bindings[(4, false)].Sector);
    }

    [Test]
    public void UnknownExistingMacroCannotBeOverwritten()
    {
        var snapshot = Fixture.Create(); var profile = snapshot.GetProfiles()[0];
        profile.SetBinding(3, false, ButtonBinding.Macro(6)); snapshot.Sectors[1] = profile.ToBytes();
        snapshot.Sectors[6] = Fixture.MacroSector([0xFE]);
        Reject<InvalidDataException>(() => MacroAllocator.Allocate(snapshot, [new(4, false, MacroCompiler.Compile("tap B"))]));
    }

    [Test]
    public void HugeMacroFailsWithoutChangingSnapshot()
    {
        var snapshot = Fixture.Create(); var original = Convert.ToHexString(snapshot.Sectors[6]);
        var macro = MacroCompiler.Compile(string.Join('\n', Enumerable.Repeat("tap A", 800)));
        Reject<InvalidOperationException>(() => MacroAllocator.Allocate(snapshot, [new(3, false, macro)]));
        Assert.Equal(original, Convert.ToHexString(snapshot.Sectors[6]));
    }

    [Test]
    public void NoChangeDoesNotScheduleFlashWrites()
    {
        var snapshot = Fixture.Create();
        Assert.Equal(0, MemoryWritePlan.Create(snapshot, snapshot.GetProfiles()[0], []).Changes.Count);
    }

    [Test]
    public void MacroBeforeProfileAndDirectoryLast()
    {
        var snapshot = Fixture.Create(); var profile = snapshot.GetProfiles()[1]; profile.Enabled = false;
        var plan = MemoryWritePlan.Create(snapshot, profile, [new(3, false, MacroCompiler.Compile("tap A"))]);
        Assert.Equal(6, plan.Changes[0].Sector);
        Assert.Equal(2, plan.Changes[1].Sector);
        Assert.Equal(0, plan.Changes[^1].Sector);
    }

    [Test]
    public void LeftClickAndEnabledProfileAreRequired()
    {
        var snapshot = Fixture.Create(); var profile = snapshot.GetProfiles()[0];
        profile.SetBinding(0, false, ButtonBinding.Disabled());
        Reject<InvalidDataException>(() => MemoryWritePlan.Create(snapshot, profile, []));
        snapshot.Sectors[0][6] = 0; SectorChecksum.Seal(snapshot.Sectors[0]);
        profile = snapshot.GetProfiles()[0]; profile.Enabled = false;
        Reject<InvalidOperationException>(() => MemoryWritePlan.Create(snapshot, profile, []));
    }

    [Test]
    public void Format10NeedsExplicitOptInAndPreservesUnconfirmedRegions()
    {
        var snapshot = Fixture.Create(format: 10); var profile = snapshot.GetProfiles()[0];
        Reject<NotSupportedException>(() => MemoryWritePlan.Create(snapshot, profile, []));
        var plan = MemoryWritePlan.Create(snapshot, profile, [new(3, false, MacroCompiler.Compile("tap A"))], true);
        var after = plan.Changes.Single(c => c.Sector == 1).After;
        for (var i = 0; i < after.Length - 2; i++)
            if (i is < 44 or >= 48) Assert.Equal(snapshot.Sectors[1][i], after[i]);
        profile.ReportInterval = 2;
        Reject<InvalidOperationException>(() => MemoryWritePlan.Create(snapshot, profile, [], true));
    }

    [Test]
    public void UnknownLayoutRemainsReadOnly()
    {
        var snapshot = Fixture.Create(format: 11);
        Assert.Equal(false, snapshot.Capabilities.CanEditProfiles);
        Assert.Equal(0, snapshot.GetProfiles().Count);
    }

    [Test]
    public void BackupIdentityMustMatchMouse()
    {
        var baseline = Fixture.Create(); var foreign = Fixture.Create(); foreign.DeviceIdentity = "another mouse";
        Reject<InvalidDataException>(() => MemoryWritePlan.Restore(baseline, foreign));
    }

    [Test]
    public async Task SectorReadsUseOverlappingTailFor255ByteMemory()
    {
        using var fake = new FakeMouse(Fixture.Create()); using var client = new HidPlusPlusClient(fake, 1);
        var memory = new OnboardMemory(client, "test-device", "Test Mouse");
        var snapshot = await memory.ReadAsync();
        Assert.Equal(true, fake.ReadOffsets.Contains(239));
        Assert.Equal(false, fake.ReadOffsets.Contains(240));
        Assert.Equal(5, snapshot.GetProfiles().Count);
    }

    [Test]
    public async Task RequestsIgnoreNotificationsAndUnrelatedReplies()
    {
        using var fake = new FakeMouse(Fixture.Create()) { InjectUnrelated = true };
        using var client = new HidPlusPlusClient(fake, 1);
        Assert.Equal(true, await client.PingAsync());
    }

    [Test]
    public async Task CommitBacksUpVerifiesAndActivatesOnboardProfile()
    {
        using var scratch = new ScratchDirectory(); var baseline = Fixture.Create();
        using var fake = new FakeMouse(baseline); using var client = new HidPlusPlusClient(fake, 1);
        var memory = new OnboardMemory(client, baseline.DeviceIdentity, baseline.DeviceName);
        var profile = baseline.GetProfiles()[0]; profile.Name = "Test profile";
        var plan = MemoryWritePlan.Create(baseline, profile, [new(3, false, MacroCompiler.Compile("tap A"))]);
        var result = await memory.CommitAsync(plan, scratch.Path);
        Assert.Equal(plan.Changes.Count, result.SectorsWritten); Assert.Equal((byte)1, fake.Mode); Assert.Equal((ushort)1, fake.Active);
        foreach (var change in plan.Changes) Assert.Equal(Convert.ToHexString(change.After), Convert.ToHexString(fake.Sectors[change.Sector]));
        Assert.Equal(true, File.Exists(result.BackupPath));
        var saved = await OnboardMemory.LoadBackupAsync(result.BackupPath);
        Assert.Equal(Convert.ToHexString(baseline.Sectors[1]), Convert.ToHexString(saved.Sectors[1]));
    }

    [Test]
    public async Task StaleSnapshotIsRejectedBeforeChangingModeOrFlash()
    {
        using var scratch = new ScratchDirectory(); var baseline = Fixture.Create();
        using var fake = new FakeMouse(baseline); using var client = new HidPlusPlusClient(fake, 1);
        var memory = new OnboardMemory(client, baseline.DeviceIdentity, baseline.DeviceName);
        var profile = baseline.GetProfiles()[0]; profile.Name = "New name";
        var plan = MemoryWritePlan.Create(baseline, profile, []);
        fake.Sectors[1][10] ^= 1;
        await RejectAsync<InvalidOperationException>(() => memory.CommitAsync(plan, scratch.Path));
        Assert.Equal(0, fake.WriteStarts); Assert.Equal((byte)2, fake.Mode);
    }

    [Test]
    public async Task FailedCommitRestoresPartialSectorAndOriginalHostMode()
    {
        using var scratch = new ScratchDirectory(); var baseline = Fixture.Create();
        using var fake = new FakeMouse(baseline) { FailOnceAtSector = 1 };
        using var client = new HidPlusPlusClient(fake, 1);
        var memory = new OnboardMemory(client, baseline.DeviceIdentity, baseline.DeviceName);
        var profile = baseline.GetProfiles()[0]; profile.Name = "Will rollback";
        var plan = MemoryWritePlan.Create(baseline, profile, [new(3, false, MacroCompiler.Compile("tap A"))]);
        var error = await RejectAsync<IOException>(() => memory.CommitAsync(plan, scratch.Path));
        Assert.Equal(true, error.Message.Contains("已读回验证并恢复"));
        foreach (var change in plan.Changes) Assert.Equal(Convert.ToHexString(change.Before), Convert.ToHexString(fake.Sectors[change.Sector]));
        Assert.Equal((byte)2, fake.Mode); Assert.Equal((ushort)0, fake.Active);
    }

    [Test]
    public async Task WriteEndHardwareErrorRequiresMatchingReadback()
    {
        using var scratch = new ScratchDirectory(); var baseline = Fixture.Create();
        using var fake = new FakeMouse(baseline) { FailOnceAtSector = 1, EndError = 4 };
        using var client = new HidPlusPlusClient(fake, 1);
        var memory = new OnboardMemory(client, baseline.DeviceIdentity, baseline.DeviceName);
        var profile = baseline.GetProfiles()[0]; profile.Name = "Readback wins";
        var result = await memory.CommitAsync(MemoryWritePlan.Create(baseline, profile, []), scratch.Path);
        Assert.Equal(1, result.SectorsWritten);
    }

    private static T Reject<T>(Action action) where T : Exception
    {
        try { action(); } catch (T error) { return error; }
        throw new Exception($"Expected {typeof(T).Name}.");
    }
    private static async Task<T> RejectAsync<T>(Func<Task> action) where T : Exception
    {
        try { await action(); } catch (T error) { return error; }
        throw new Exception($"Expected {typeof(T).Name}.");
    }
}

internal static class Fixture
{
    internal static MemorySnapshot Create(byte format = 5)
    {
        var snapshot = new MemorySnapshot
        {
            DeviceIdentity = "test-device",
            DeviceName = "Test Mouse",
            Mode = 2,
            ActiveSector = 0,
            Description = [1, format, 1, 5, 2, 11, 16, 0, 255, 0x0A, 4, 0, 0, 0, 0, 0]
        };
        for (var i = 0; i < 16; i++) snapshot.Sectors[i] = Enumerable.Repeat((byte)0xFF, 255).ToArray();
        for (var i = 1; i <= 5; i++)
        {
            var data = snapshot.Sectors[i]; data[0] = format == 10 ? (byte)0xFF : (byte)1; data[1] = 2; data[2] = 0;
            ushort[] dpis = [800, 1200, 1600, 2400, 3200];
            for (var d = 0; d < 5; d++) System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(3 + d * 2), dpis[d]);
            for (var button = 0; button < 5; button++) ButtonBinding.Mouse(button + 1).CopyTo(data, 32 + button * 4);
            data[20] = 3; data[21] = 3;
            if (format == 10) for (var d = 0; d < 5; d++) System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(86 + d * 2), dpis[d]);
            SectorChecksum.Seal(data);
            snapshot.Sectors[0][4 * (i - 1)] = 0; snapshot.Sectors[0][4 * (i - 1) + 1] = (byte)i;
            snapshot.Sectors[0][4 * (i - 1) + 2] = (byte)(i <= 2 ? 1 : 0);
        }
        SectorChecksum.Seal(snapshot.Sectors[0]);
        snapshot.Sectors[256] = (byte[])snapshot.Sectors[0].Clone();
        snapshot.Sectors[257] = (byte[])snapshot.Sectors[1].Clone();
        snapshot.Sectors[258] = (byte[])snapshot.Sectors[2].Clone();
        return snapshot;
    }
    internal static byte[] MacroSector(byte[] program)
    {
        var sector = Enumerable.Repeat((byte)0xFF, 255).ToArray(); program.CopyTo(sector, 0); return SectorChecksum.Seal(sector);
    }
}

internal sealed class ScratchDirectory : IDisposable
{
    private readonly string root = System.IO.Path.GetFullPath(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "LogiBoardTests"));
    public string Path { get; }
    public ScratchDirectory() { Path = System.IO.Path.Combine(root, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path); }
    public void Dispose()
    {
        var resolved = System.IO.Path.GetFullPath(Path);
        if (!resolved.StartsWith(root + System.IO.Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Invalid test scratch path.");
        Directory.Delete(resolved, recursive: true);
    }
}