package main import ( "bytes" "encoding/json" "errors" "strings" "testing" intdevice "netdome.biz/paul/qmk-rgb/internal/device" ) type deviceInfoOutput struct { Devices []keyboardLine `json:"devices"` Total int `json:"total"` Others []intdevice.HIDDevice `json:"otherHidDevices"` } // stubDiscovery replaces both enumerations the command makes. Only the // keyboards are of interest to most tests, so the devices that were passed over // are stubbed empty as well: leaving that one real would make the output depend // on what is plugged into the machine running the tests. func stubDiscovery(t *testing.T, devices []intdevice.Device) { t.Helper() orig, origOther := discoverAll, discoverOther discoverAll = func() ([]intdevice.Device, error) { return devices, nil } discoverOther = func() ([]intdevice.HIDDevice, error) { return nil, nil } t.Cleanup(func() { discoverAll, discoverOther = orig, origOther }) } // runRealKeyboardInfo executes the shipped command so a re-added // human-readable banner is caught, not just changes inside a helper. func runRealKeyboardInfo(t *testing.T) (stdout, stderr string, err error) { t.Helper() var out, errOut bytes.Buffer keyboardInfoCmd.SetOut(&out) keyboardInfoCmd.SetErr(&errOut) // Restored to nil rather than to whatever it was: OutOrStdout() answers // os.Stdout, and setting that back leaves the command with a writer of its // own, which a root's SetOut can no longer override. The next test that ran // this command through a root would then print its help to the terminal. t.Cleanup(func() { keyboardInfoCmd.SetOut(nil) keyboardInfoCmd.SetErr(nil) }) err = keyboardInfoCmd.RunE(keyboardInfoCmd, nil) return out.String(), errOut.String(), err } func TestKeyboardInfoWritesOnlyJSON(t *testing.T) { withJSON(t) stubDiscovery(t, []intdevice.Device{ {Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Impact 80"}, }) stdout, stderr, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } if stderr != "" { t.Errorf("keyboard info wrote %q to stderr, want no human-readable banner next to the JSON", stderr) } var got deviceInfoOutput if err := json.Unmarshal([]byte(stdout), &got); err != nil { t.Fatalf("stdout is not valid JSON: %v (output %q)", err, stdout) } if got.Total != 1 { t.Errorf("total = %d, want 1", got.Total) } if len(got.Devices) != 1 { t.Fatalf("devices = %d entries, want 1", len(got.Devices)) } if got.Devices[0].Index != 1 { t.Errorf("devices[0].index = %d, want 1", got.Devices[0].Index) } if got.Devices[0].Path != "/dev/hidraw7" { t.Errorf("devices[0].path = %q, want %q", got.Devices[0].Path, "/dev/hidraw7") } } func TestKeyboardInfoEmitsIndentedJSON(t *testing.T) { withJSON(t) stubDiscovery(t, []intdevice.Device{ {Index: 1, Path: "/dev/hidraw7", Name: "Impact 80"}, }) stdout, _, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } if !strings.Contains(stdout, "\n \"devices\": [") { t.Errorf("stdout = %q, want two-space indented JSON like the other commands emit", stdout) } if !strings.HasSuffix(stdout, "\n") { t.Errorf("stdout = %q, want a trailing newline", stdout) } } func TestKeyboardInfoEmitsEmptyArrayNotNull(t *testing.T) { withJSON(t) stubDiscovery(t, nil) stdout, _, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } if !strings.Contains(stdout, `"devices": []`) { t.Errorf("stdout = %q, want an empty array so consumers can iterate unconditionally", stdout) } if !strings.Contains(stdout, `"otherHidDevices": []`) { t.Errorf("stdout = %q, want the passed-over devices as an empty array, not null", stdout) } } // An empty result is the one case a user cannot read on their own: it looks the // same whether the keyboard is unplugged or its lighting is on a HID collection // this tool does not address. So the listing names the devices that are there, // with the usage pages they do expose — the missing 0xFF60/0x61 is the finding. func TestKeyboardInfoEmptyResultNamesTheDevicesPassedOver(t *testing.T) { stubDiscovery(t, nil) stubOtherDiscovery(t, []intdevice.HIDDevice{ { VendorID: 0x320f, ProductID: 0x5044, Name: "GMMK Pro", UsagePairs: []intdevice.UsagePair{ {UsagePage: 0x0001, Usage: 0x06}, {UsagePage: 0xff80, Usage: 0x61}, }, }, }) stdout, _, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } for _, want := range []string{ "No keyboard with the QMK Raw HID interface found", "usage page 0xFF60, usage 0x61", "0x320F/0x5044", "GMMK Pro", "0xFF80/0x61", } { if !strings.Contains(stdout, want) { t.Errorf("stdout = %q, want it to contain %q", stdout, want) } } } // The preamble says what was looked for and why a board has it, and it must not // claim VIA was checked: nothing is opened here, and VIA is never what the // collection filter asks about. A sentence that says it did would send a user // looking for a probe that does not exist. func TestKeyboardInfoEmptyResultDoesNotClaimToHaveCheckedVIA(t *testing.T) { stubDiscovery(t, nil) stubOtherDiscovery(t, nil) stdout, _, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } if !strings.Contains(stdout, "VIA's build cannot be built without it") { t.Errorf("stdout = %q, want it to say why a QMK firmware has the collection", stdout) } if strings.Contains(stdout, "no VIA") || strings.Contains(stdout, "does not support VIA") { t.Errorf("stdout = %q, want no claim about VIA: this command never opened the board", stdout) } } // And the opposite: a keyboard was found, so the devices that were passed over // are only ever a dock or a mouse, and a listing beside the line the user came // for buries it. func TestKeyboardInfoListsOtherDevicesOnlyWhenNoneWasFound(t *testing.T) { stubDiscovery(t, []intdevice.Device{ {Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Impact 80"}, }) stubOtherDiscovery(t, []intdevice.HIDDevice{ {VendorID: 0x046d, ProductID: 0xc041, Name: "USB Gaming Mouse", UsagePairs: []intdevice.UsagePair{{UsagePage: 0xff00, Usage: 0x01}}}, }) stdout, _, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } if !strings.Contains(stdout, "Impact 80") { t.Errorf("stdout = %q, want the keyboard that was found", stdout) } if strings.Contains(stdout, "USB Gaming Mouse") { t.Errorf("stdout = %q, want no listing of passed-over devices when a keyboard was found", stdout) } } // With nothing found and nothing else connected, the sentence has to say that // rather than print a heading with nothing under it. func TestKeyboardInfoEmptyResultWithNoHIDDevicesAtAll(t *testing.T) { stubDiscovery(t, nil) stubOtherDiscovery(t, nil) stdout, _, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } if !strings.Contains(stdout, "No other HID device is connected") { t.Errorf("stdout = %q, want it to say that no HID device is connected", stdout) } if strings.Contains(stdout, "These HID devices are connected") { t.Errorf("stdout = %q, want no heading for a list that is not there", stdout) } } // A consumer asking "why not this one" reads the JSON, so the devices that were // passed over are in it whether or not a keyboard was found. func TestKeyboardInfoJSONCarriesTheOtherHIDDevicesWhateverItFinds(t *testing.T) { withJSON(t) stubDiscovery(t, []intdevice.Device{ {Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Impact 80"}, }) stubOtherDiscovery(t, []intdevice.HIDDevice{ {VendorID: 0x320f, ProductID: 0x5044, Name: "GMMK Pro", UsagePairs: []intdevice.UsagePair{{UsagePage: 0xff80, Usage: 0x61}}}, }) stdout, _, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } var got deviceInfoOutput if err := json.Unmarshal([]byte(stdout), &got); err != nil { t.Fatalf("stdout is not valid JSON: %v (output %q)", err, stdout) } if len(got.Devices) != 1 { t.Fatalf("devices = %d entries, want 1", len(got.Devices)) } if len(got.Others) != 1 { t.Fatalf("otherHidDevices = %d entries, want 1", len(got.Others)) } if got.Others[0].VendorID != 0x320f { t.Errorf("otherHidDevices[0].vendorId = %04X, want 320F", got.Others[0].VendorID) } if len(got.Others[0].UsagePairs) != 1 || got.Others[0].UsagePairs[0].UsagePage != 0xff80 { t.Errorf("otherHidDevices[0].usagePages = %+v, want the page it was passed over for", got.Others[0].UsagePairs) } } // The passed-over devices are never selectable, so they carry no index and none // is printed: a number there would be one --device does not take. func TestKeyboardInfoGivesNoNumberToADeviceThatCannotBeSelected(t *testing.T) { stubDiscovery(t, nil) stubOtherDiscovery(t, []intdevice.HIDDevice{ {VendorID: 0x320f, ProductID: 0x5044, Name: "GMMK Pro", UsagePairs: []intdevice.UsagePair{{UsagePage: 0xff80, Usage: 0x61}}}, }) stdout, _, err := runRealKeyboardInfo(t) if err != nil { t.Fatalf("keyboard info returned error: %v", err) } for _, line := range strings.Split(stdout, "\n") { if !strings.Contains(line, "0x320F/0x5044") { continue } if strings.HasPrefix(strings.TrimSpace(line), "1 ") { t.Errorf("stdout = %q, want no number on a device --device cannot select", stdout) } } } func stubOtherDiscovery(t *testing.T, others []intdevice.HIDDevice) { t.Helper() orig := discoverOther discoverOther = func() ([]intdevice.HIDDevice, error) { return others, nil } t.Cleanup(func() { discoverOther = orig }) } func TestKeyboardInfoPropagatesDiscoveryError(t *testing.T) { orig := discoverAll discoverAll = func() ([]intdevice.Device, error) { return nil, errStub } t.Cleanup(func() { discoverAll = orig }) _, _, err := runRealKeyboardInfo(t) if err == nil { t.Fatal("keyboard info expected discovery error, got nil") } if !strings.Contains(err.Error(), "enumerate") { t.Errorf("error = %v, want it to wrap the discovery failure", err) } } var errStub = errors.New("enumerate: stub failure") // A command that a test gave a writer of its own keeps it, and a writer set that // way outranks the root's. Restoring os.Stdout rather than nil therefore left the // shipped `keyboard info` writing to the terminal while a later test believed it // was capturing its help, and the failure showed up as noise in an unrelated // test's output rather than as a failing one. func TestKeyboardInfoHelpGoesWhereTheRootSendsIt(t *testing.T) { // In a subtest, so the restore has happened by the time the help runs: that // ordering is the whole defect, and doing both here would not test it. t.Run("a test gave it a writer", func(t *testing.T) { runRealKeyboardInfo(t) }) var out bytes.Buffer root := newRootCommand() registerCommands(root) root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"keyboard", "info", "--help"}) if err := root.Execute(); err != nil { t.Fatalf("keyboard info --help returned error: %v", err) } if !strings.Contains(out.String(), "Usage:") { t.Errorf("captured %q, want the help, so the root's writer is in charge", out.String()) } }