package device import ( "encoding/json" "strings" "testing" "netdome.biz/paul/qmk-rgb/internal/hid" ) func TestIndexDevices(t *testing.T) { tests := []struct { name string devices []Device want []string }{ { name: "empty", devices: nil, want: nil, }, { name: "single", devices: []Device{{Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f}}, want: []string{"/dev/hidraw7"}, }, { name: "sorted by vendor then product then path", devices: []Device{ {Path: "/dev/hidraw9", VendorID: 0x9999, ProductID: 0x0001}, {Path: "/dev/hidraw5", VendorID: 0x1111, ProductID: 0x0002}, {Path: "/dev/hidraw2", VendorID: 0x1111, ProductID: 0x0001}, }, want: []string{"/dev/hidraw2", "/dev/hidraw5", "/dev/hidraw9"}, }, { name: "identical vendor and product sorted by path", devices: []Device{ {Path: "/dev/hidrawB", VendorID: 0x36b0, ProductID: 0x309f}, {Path: "/dev/hidrawA", VendorID: 0x36b0, ProductID: 0x309f}, }, want: []string{"/dev/hidrawA", "/dev/hidrawB"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := indexDevices(tt.devices) if len(got) != len(tt.want) { t.Fatalf("indexDevices() returned %d devices, want %d", len(got), len(tt.want)) } for i, wantPath := range tt.want { if got[i].Path != wantPath { t.Errorf("indexDevices()[%d].Path = %q, want %q", i, got[i].Path, wantPath) } if got[i].Index != i+1 { t.Errorf("indexDevices()[%d].Index = %d, want %d", i, got[i].Index, i+1) } } }) } } func TestIndexDevicesDoesNotMutateInput(t *testing.T) { input := []Device{ {Path: "/dev/hidraw9", VendorID: 0x9999, ProductID: 0x0001}, {Path: "/dev/hidraw2", VendorID: 0x1111, ProductID: 0x0001}, } indexDevices(input) if input[0].Path != "/dev/hidraw9" { t.Errorf("indexDevices() mutated input: input[0].Path = %q, want %q", input[0].Path, "/dev/hidraw9") } if input[0].Index != 0 { t.Errorf("indexDevices() mutated input: input[0].Index = %d, want 0", input[0].Index) } } func TestDeviceJSONFieldNames(t *testing.T) { dev := Device{ Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Impact 80", } data, err := json.Marshal(dev) if err != nil { t.Fatalf("Marshal() returned error: %v", err) } var got map[string]any if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("Unmarshal() returned error: %v", err) } for _, field := range []string{"index", "path", "vendorId", "productId", "name"} { if _, ok := got[field]; !ok { t.Errorf("Device JSON = %s, missing field %q", data, field) } } // The field "known" used to say whether a data file listed the board. It is gone: // whether this tool has effect names for a board is reported by the info // command, which can look, instead of by a file that can go stale. if _, ok := got["known"]; ok { t.Errorf("Device JSON = %s, must not contain the removed field \"known\"", data) } if _, ok := got["supported"]; ok { t.Errorf("Device JSON = %s, must not contain the misleading field \"supported\"", data) } } // A keyboard that reports no USB product string has no name, and the field is // omitted rather than sent empty: a consumer can tell "no name" from a name. func TestDeviceJSONOmitsUnnamedName(t *testing.T) { data, err := json.Marshal(Device{Index: 2, Path: "/dev/hidraw9"}) if err != nil { t.Fatalf("Marshal() returned error: %v", err) } if strings.Contains(string(data), `"name"`) { t.Errorf("Device JSON = %s, want the name omitted for a keyboard that reports none", data) } } // The name is the keyboard's own, from the USB product string, so a data file // cannot go stale and disagree with the hardware. func TestDeviceTakesTheNameFromTheProductString(t *testing.T) { d := Device{Index: 1, Path: "p", VendorID: 0x36B0, ProductID: 0x309F, Name: "Impact 80"} if d.Name != "Impact 80" { t.Errorf("Name = %q, want the product string the keyboard reports", d.Name) } } // The passed-over devices are what an empty discovery result has to explain, so // the grouping is the whole of the decision: one line per device rather than one // per collection, the raw HID collections left out, and a device that reports the // same collection twice listed once. func TestGroupOtherPerDevice(t *testing.T) { infos := []hid.DeviceInfo{ {Path: "a", VendorID: 0x320f, ProductID: 0x5044, ProductString: "GMMK Pro", UsagePage: 0x0001, Usage: 0x06}, {Path: "a", VendorID: 0x320f, ProductID: 0x5044, ProductString: "GMMK Pro", UsagePage: 0x000c, Usage: 0x01}, // The same pair a second time, as the HID layer reports when a device // has several interfaces: one line about it says nothing. {Path: "a2", VendorID: 0x320f, ProductID: 0x5044, ProductString: "GMMK Pro", UsagePage: 0x0001, Usage: 0x06}, // A board that put its lighting on a vendor page this tool does not // address. The page is the finding, so it has to survive the grouping. {Path: "b", VendorID: 0x320f, ProductID: 0x5044, ProductString: "GMMK Pro", UsagePage: 0xff80, Usage: 0x61}, // The collection this tool drives, which belongs to DiscoverAll. {Path: "c", VendorID: 0x36b0, ProductID: 0x309f, ProductString: "Impact 80", UsagePage: 0xff60, Usage: 0x61, RawHID: true}, } got := groupOther(infos) if len(got) != 1 { t.Fatalf("groupOther() = %d devices, want 1: %+v", len(got), got) } if got[0].VendorID != 0x320f || got[0].ProductID != 0x5044 { t.Errorf("vendor/product = %04X/%04X, want 320F/5044", got[0].VendorID, got[0].ProductID) } if got[0].Name != "GMMK Pro" { t.Errorf("name = %q, want %q", got[0].Name, "GMMK Pro") } want := []UsagePair{{0x0001, 0x06}, {0x000c, 0x01}, {0xff80, 0x61}} if len(got[0].UsagePairs) != len(want) { t.Fatalf("usage pages = %+v, want %+v", got[0].UsagePairs, want) } for i, w := range want { if got[0].UsagePairs[i] != w { t.Errorf("usagePages[%d] = %+v, want %+v", i, got[0].UsagePairs[i], w) } } } // A device with no product string still has to be listed, or the one board that // states nothing about itself is the one that goes missing from the explanation. func TestGroupOtherKeepsADeviceWithoutAName(t *testing.T) { got := groupOther([]hid.DeviceInfo{{VendorID: 0x0000, ProductID: 0x0000, UsagePage: 0xff00, Usage: 0xff}}) if len(got) != 1 { t.Fatalf("groupOther() = %d devices, want 1", len(got)) } if got[0].Name != "" { t.Errorf("name = %q, want it empty so the command can say so", got[0].Name) } }