keyboard_info_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. package main
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "strings"
  7. "testing"
  8. intdevice "netdome.biz/paul/qmk-rgb/internal/device"
  9. )
  10. type deviceInfoOutput struct {
  11. Devices []keyboardLine `json:"devices"`
  12. Total int `json:"total"`
  13. Others []intdevice.HIDDevice `json:"otherHidDevices"`
  14. }
  15. // stubDiscovery replaces both enumerations the command makes. Only the
  16. // keyboards are of interest to most tests, so the devices that were passed over
  17. // are stubbed empty as well: leaving that one real would make the output depend
  18. // on what is plugged into the machine running the tests.
  19. func stubDiscovery(t *testing.T, devices []intdevice.Device) {
  20. t.Helper()
  21. orig, origOther := discoverAll, discoverOther
  22. discoverAll = func() ([]intdevice.Device, error) { return devices, nil }
  23. discoverOther = func() ([]intdevice.HIDDevice, error) { return nil, nil }
  24. t.Cleanup(func() { discoverAll, discoverOther = orig, origOther })
  25. }
  26. // runRealKeyboardInfo executes the shipped command so a re-added
  27. // human-readable banner is caught, not just changes inside a helper.
  28. func runRealKeyboardInfo(t *testing.T) (stdout, stderr string, err error) {
  29. t.Helper()
  30. var out, errOut bytes.Buffer
  31. keyboardInfoCmd.SetOut(&out)
  32. keyboardInfoCmd.SetErr(&errOut)
  33. // Restored to nil rather than to whatever it was: OutOrStdout() answers
  34. // os.Stdout, and setting that back leaves the command with a writer of its
  35. // own, which a root's SetOut can no longer override. The next test that ran
  36. // this command through a root would then print its help to the terminal.
  37. t.Cleanup(func() {
  38. keyboardInfoCmd.SetOut(nil)
  39. keyboardInfoCmd.SetErr(nil)
  40. })
  41. err = keyboardInfoCmd.RunE(keyboardInfoCmd, nil)
  42. return out.String(), errOut.String(), err
  43. }
  44. func TestKeyboardInfoWritesOnlyJSON(t *testing.T) {
  45. withJSON(t)
  46. stubDiscovery(t, []intdevice.Device{
  47. {Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Impact 80"},
  48. })
  49. stdout, stderr, err := runRealKeyboardInfo(t)
  50. if err != nil {
  51. t.Fatalf("keyboard info returned error: %v", err)
  52. }
  53. if stderr != "" {
  54. t.Errorf("keyboard info wrote %q to stderr, want no human-readable banner next to the JSON", stderr)
  55. }
  56. var got deviceInfoOutput
  57. if err := json.Unmarshal([]byte(stdout), &got); err != nil {
  58. t.Fatalf("stdout is not valid JSON: %v (output %q)", err, stdout)
  59. }
  60. if got.Total != 1 {
  61. t.Errorf("total = %d, want 1", got.Total)
  62. }
  63. if len(got.Devices) != 1 {
  64. t.Fatalf("devices = %d entries, want 1", len(got.Devices))
  65. }
  66. if got.Devices[0].Index != 1 {
  67. t.Errorf("devices[0].index = %d, want 1", got.Devices[0].Index)
  68. }
  69. if got.Devices[0].Path != "/dev/hidraw7" {
  70. t.Errorf("devices[0].path = %q, want %q", got.Devices[0].Path, "/dev/hidraw7")
  71. }
  72. }
  73. func TestKeyboardInfoEmitsIndentedJSON(t *testing.T) {
  74. withJSON(t)
  75. stubDiscovery(t, []intdevice.Device{
  76. {Index: 1, Path: "/dev/hidraw7", Name: "Impact 80"},
  77. })
  78. stdout, _, err := runRealKeyboardInfo(t)
  79. if err != nil {
  80. t.Fatalf("keyboard info returned error: %v", err)
  81. }
  82. if !strings.Contains(stdout, "\n \"devices\": [") {
  83. t.Errorf("stdout = %q, want two-space indented JSON like the other commands emit", stdout)
  84. }
  85. if !strings.HasSuffix(stdout, "\n") {
  86. t.Errorf("stdout = %q, want a trailing newline", stdout)
  87. }
  88. }
  89. func TestKeyboardInfoEmitsEmptyArrayNotNull(t *testing.T) {
  90. withJSON(t)
  91. stubDiscovery(t, nil)
  92. stdout, _, err := runRealKeyboardInfo(t)
  93. if err != nil {
  94. t.Fatalf("keyboard info returned error: %v", err)
  95. }
  96. if !strings.Contains(stdout, `"devices": []`) {
  97. t.Errorf("stdout = %q, want an empty array so consumers can iterate unconditionally", stdout)
  98. }
  99. if !strings.Contains(stdout, `"otherHidDevices": []`) {
  100. t.Errorf("stdout = %q, want the passed-over devices as an empty array, not null", stdout)
  101. }
  102. }
  103. // An empty result is the one case a user cannot read on their own: it looks the
  104. // same whether the keyboard is unplugged or its lighting is on a HID collection
  105. // this tool does not address. So the listing names the devices that are there,
  106. // with the usage pages they do expose — the missing 0xFF60/0x61 is the finding.
  107. func TestKeyboardInfoEmptyResultNamesTheDevicesPassedOver(t *testing.T) {
  108. stubDiscovery(t, nil)
  109. stubOtherDiscovery(t, []intdevice.HIDDevice{
  110. {
  111. VendorID: 0x320f,
  112. ProductID: 0x5044,
  113. Name: "GMMK Pro",
  114. UsagePairs: []intdevice.UsagePair{
  115. {UsagePage: 0x0001, Usage: 0x06},
  116. {UsagePage: 0xff80, Usage: 0x61},
  117. },
  118. },
  119. })
  120. stdout, _, err := runRealKeyboardInfo(t)
  121. if err != nil {
  122. t.Fatalf("keyboard info returned error: %v", err)
  123. }
  124. for _, want := range []string{
  125. "No keyboard with the QMK Raw HID interface found",
  126. "usage page 0xFF60, usage 0x61",
  127. "0x320F/0x5044",
  128. "GMMK Pro",
  129. "0xFF80/0x61",
  130. } {
  131. if !strings.Contains(stdout, want) {
  132. t.Errorf("stdout = %q, want it to contain %q", stdout, want)
  133. }
  134. }
  135. }
  136. // The preamble says what was looked for and why a board has it, and it must not
  137. // claim VIA was checked: nothing is opened here, and VIA is never what the
  138. // collection filter asks about. A sentence that says it did would send a user
  139. // looking for a probe that does not exist.
  140. func TestKeyboardInfoEmptyResultDoesNotClaimToHaveCheckedVIA(t *testing.T) {
  141. stubDiscovery(t, nil)
  142. stubOtherDiscovery(t, nil)
  143. stdout, _, err := runRealKeyboardInfo(t)
  144. if err != nil {
  145. t.Fatalf("keyboard info returned error: %v", err)
  146. }
  147. if !strings.Contains(stdout, "VIA's build cannot be built without it") {
  148. t.Errorf("stdout = %q, want it to say why a QMK firmware has the collection", stdout)
  149. }
  150. if strings.Contains(stdout, "no VIA") || strings.Contains(stdout, "does not support VIA") {
  151. t.Errorf("stdout = %q, want no claim about VIA: this command never opened the board", stdout)
  152. }
  153. }
  154. // And the opposite: a keyboard was found, so the devices that were passed over
  155. // are only ever a dock or a mouse, and a listing beside the line the user came
  156. // for buries it.
  157. func TestKeyboardInfoListsOtherDevicesOnlyWhenNoneWasFound(t *testing.T) {
  158. stubDiscovery(t, []intdevice.Device{
  159. {Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Impact 80"},
  160. })
  161. stubOtherDiscovery(t, []intdevice.HIDDevice{
  162. {VendorID: 0x046d, ProductID: 0xc041, Name: "USB Gaming Mouse",
  163. UsagePairs: []intdevice.UsagePair{{UsagePage: 0xff00, Usage: 0x01}}},
  164. })
  165. stdout, _, err := runRealKeyboardInfo(t)
  166. if err != nil {
  167. t.Fatalf("keyboard info returned error: %v", err)
  168. }
  169. if !strings.Contains(stdout, "Impact 80") {
  170. t.Errorf("stdout = %q, want the keyboard that was found", stdout)
  171. }
  172. if strings.Contains(stdout, "USB Gaming Mouse") {
  173. t.Errorf("stdout = %q, want no listing of passed-over devices when a keyboard was found", stdout)
  174. }
  175. }
  176. // With nothing found and nothing else connected, the sentence has to say that
  177. // rather than print a heading with nothing under it.
  178. func TestKeyboardInfoEmptyResultWithNoHIDDevicesAtAll(t *testing.T) {
  179. stubDiscovery(t, nil)
  180. stubOtherDiscovery(t, nil)
  181. stdout, _, err := runRealKeyboardInfo(t)
  182. if err != nil {
  183. t.Fatalf("keyboard info returned error: %v", err)
  184. }
  185. if !strings.Contains(stdout, "No other HID device is connected") {
  186. t.Errorf("stdout = %q, want it to say that no HID device is connected", stdout)
  187. }
  188. if strings.Contains(stdout, "These HID devices are connected") {
  189. t.Errorf("stdout = %q, want no heading for a list that is not there", stdout)
  190. }
  191. }
  192. // A consumer asking "why not this one" reads the JSON, so the devices that were
  193. // passed over are in it whether or not a keyboard was found.
  194. func TestKeyboardInfoJSONCarriesTheOtherHIDDevicesWhateverItFinds(t *testing.T) {
  195. withJSON(t)
  196. stubDiscovery(t, []intdevice.Device{
  197. {Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Impact 80"},
  198. })
  199. stubOtherDiscovery(t, []intdevice.HIDDevice{
  200. {VendorID: 0x320f, ProductID: 0x5044, Name: "GMMK Pro",
  201. UsagePairs: []intdevice.UsagePair{{UsagePage: 0xff80, Usage: 0x61}}},
  202. })
  203. stdout, _, err := runRealKeyboardInfo(t)
  204. if err != nil {
  205. t.Fatalf("keyboard info returned error: %v", err)
  206. }
  207. var got deviceInfoOutput
  208. if err := json.Unmarshal([]byte(stdout), &got); err != nil {
  209. t.Fatalf("stdout is not valid JSON: %v (output %q)", err, stdout)
  210. }
  211. if len(got.Devices) != 1 {
  212. t.Fatalf("devices = %d entries, want 1", len(got.Devices))
  213. }
  214. if len(got.Others) != 1 {
  215. t.Fatalf("otherHidDevices = %d entries, want 1", len(got.Others))
  216. }
  217. if got.Others[0].VendorID != 0x320f {
  218. t.Errorf("otherHidDevices[0].vendorId = %04X, want 320F", got.Others[0].VendorID)
  219. }
  220. if len(got.Others[0].UsagePairs) != 1 || got.Others[0].UsagePairs[0].UsagePage != 0xff80 {
  221. t.Errorf("otherHidDevices[0].usagePages = %+v, want the page it was passed over for", got.Others[0].UsagePairs)
  222. }
  223. }
  224. // The passed-over devices are never selectable, so they carry no index and none
  225. // is printed: a number there would be one --device does not take.
  226. func TestKeyboardInfoGivesNoNumberToADeviceThatCannotBeSelected(t *testing.T) {
  227. stubDiscovery(t, nil)
  228. stubOtherDiscovery(t, []intdevice.HIDDevice{
  229. {VendorID: 0x320f, ProductID: 0x5044, Name: "GMMK Pro",
  230. UsagePairs: []intdevice.UsagePair{{UsagePage: 0xff80, Usage: 0x61}}},
  231. })
  232. stdout, _, err := runRealKeyboardInfo(t)
  233. if err != nil {
  234. t.Fatalf("keyboard info returned error: %v", err)
  235. }
  236. for _, line := range strings.Split(stdout, "\n") {
  237. if !strings.Contains(line, "0x320F/0x5044") {
  238. continue
  239. }
  240. if strings.HasPrefix(strings.TrimSpace(line), "1 ") {
  241. t.Errorf("stdout = %q, want no number on a device --device cannot select", stdout)
  242. }
  243. }
  244. }
  245. func stubOtherDiscovery(t *testing.T, others []intdevice.HIDDevice) {
  246. t.Helper()
  247. orig := discoverOther
  248. discoverOther = func() ([]intdevice.HIDDevice, error) { return others, nil }
  249. t.Cleanup(func() { discoverOther = orig })
  250. }
  251. func TestKeyboardInfoPropagatesDiscoveryError(t *testing.T) {
  252. orig := discoverAll
  253. discoverAll = func() ([]intdevice.Device, error) { return nil, errStub }
  254. t.Cleanup(func() { discoverAll = orig })
  255. _, _, err := runRealKeyboardInfo(t)
  256. if err == nil {
  257. t.Fatal("keyboard info expected discovery error, got nil")
  258. }
  259. if !strings.Contains(err.Error(), "enumerate") {
  260. t.Errorf("error = %v, want it to wrap the discovery failure", err)
  261. }
  262. }
  263. var errStub = errors.New("enumerate: stub failure")
  264. // A command that a test gave a writer of its own keeps it, and a writer set that
  265. // way outranks the root's. Restoring os.Stdout rather than nil therefore left the
  266. // shipped `keyboard info` writing to the terminal while a later test believed it
  267. // was capturing its help, and the failure showed up as noise in an unrelated
  268. // test's output rather than as a failing one.
  269. func TestKeyboardInfoHelpGoesWhereTheRootSendsIt(t *testing.T) {
  270. // In a subtest, so the restore has happened by the time the help runs: that
  271. // ordering is the whole defect, and doing both here would not test it.
  272. t.Run("a test gave it a writer", func(t *testing.T) {
  273. runRealKeyboardInfo(t)
  274. })
  275. var out bytes.Buffer
  276. root := newRootCommand()
  277. registerCommands(root)
  278. root.SetOut(&out)
  279. root.SetErr(&out)
  280. root.SetArgs([]string{"keyboard", "info", "--help"})
  281. if err := root.Execute(); err != nil {
  282. t.Fatalf("keyboard info --help returned error: %v", err)
  283. }
  284. if !strings.Contains(out.String(), "Usage:") {
  285. t.Errorf("captured %q, want the help, so the root's writer is in charge", out.String())
  286. }
  287. }