channels_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. package main
  2. import (
  3. "bytes"
  4. "strings"
  5. "testing"
  6. intdevice "netdome.biz/paul/qmk-rgb/internal/device"
  7. "netdome.biz/paul/qmk-rgb/internal/via"
  8. )
  9. func TestResolveZoneNameAcceptsASubsystemName(t *testing.T) {
  10. got, err := resolveZoneName("rgb_matrix", map[uint16]string{2: "logo"}, nil)
  11. if err != nil {
  12. t.Fatalf("resolveZoneName() error = %v", err)
  13. }
  14. if len(got) != 1 || got[0] != via.ChannelRgbMatrix {
  15. t.Errorf("resolveZoneName() = %v, want [3]", got)
  16. }
  17. }
  18. func TestResolveZoneNameAcceptsADisplayName(t *testing.T) {
  19. got, err := resolveZoneName("logo", map[uint16]string{2: "logo", 3: "backlight", 4: "side"}, nil)
  20. if err != nil {
  21. t.Fatalf("resolveZoneName() error = %v", err)
  22. }
  23. if len(got) != 1 || got[0] != via.ChannelRgblight {
  24. t.Errorf("resolveZoneName() = %v, want [2]", got)
  25. }
  26. }
  27. func TestResolveZoneNameRejectsAnUnknownName(t *testing.T) {
  28. _, err := resolveZoneName("nope", map[uint16]string{2: "logo"}, nil)
  29. if err == nil {
  30. t.Fatal("resolveZoneName() expected an error, got nil")
  31. }
  32. if !strings.Contains(err.Error(), "rgb_matrix") {
  33. t.Errorf("error = %q, want it to name an accepted form", err)
  34. }
  35. }
  36. // A board that supplies no display names keeps the subsystem vocabulary, so the
  37. // physical names that work on the Impact 80 do not work elsewhere.
  38. func TestResolveZoneNameIgnoresDisplayNamesForAnotherBoard(t *testing.T) {
  39. if _, err := resolveZoneName("logo", nil, nil); err == nil {
  40. t.Fatal("resolveZoneName(\"logo\", nil, nil) expected an error, got nil")
  41. }
  42. }
  43. // Two channels carrying the same display name are ambiguous, and the tool
  44. // reports that rather than picking one. Whether a display name shadows another
  45. // channel's subsystem name cannot be decided here, because presence is only
  46. // known after the probe; displayNameConflicts decides that.
  47. func TestResolveZoneNameReportsAnAmbiguousName(t *testing.T) {
  48. _, err := resolveZoneName("backlight", map[uint16]string{2: "logo", 3: "backlight", 4: "backlight"}, nil)
  49. if err == nil {
  50. t.Fatal("resolveZoneName() expected an error for a name two channels answer to")
  51. }
  52. if !strings.Contains(err.Error(), "backlight") {
  53. t.Errorf("error = %q, want it to name the conflicting name", err)
  54. }
  55. }
  56. func TestDisplayNameConflictsRejectsAShadowedSubsystemName(t *testing.T) {
  57. // Channel 1 is present and its subsystem is "backlight", while the file
  58. // also calls channel 3 "backlight": two channels, one name.
  59. err := displayNameConflicts(
  60. map[uint16]string{3: "backlight"},
  61. []via.Channel{via.ChannelBacklight, via.ChannelRgbMatrix},
  62. )
  63. if err == nil {
  64. t.Fatal("displayNameConflicts() expected an error, got nil")
  65. }
  66. if !strings.Contains(err.Error(), "backlight") {
  67. t.Errorf("error = %q, want it to name the conflicting name", err)
  68. }
  69. }
  70. func TestDisplayNameConflictsAllowsTheImpact80Naming(t *testing.T) {
  71. // The Impact 80 calls channel 3 "backlight" and has no channel 1, so
  72. // nothing shadows anything.
  73. err := displayNameConflicts(
  74. map[uint16]string{2: "logo", 3: "backlight", 4: "side"},
  75. []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio},
  76. )
  77. if err != nil {
  78. t.Fatalf("displayNameConflicts() error = %v, want nil", err)
  79. }
  80. }
  81. func TestChannelNamePrefersTheDisplayName(t *testing.T) {
  82. display := map[uint16]string{2: "logo"}
  83. if got := channelName(via.ChannelRgblight, display); got != "logo" {
  84. t.Errorf("channelName(2) = %q, want %q", got, "logo")
  85. }
  86. if got := channelName(via.ChannelRgbMatrix, display); got != "rgb_matrix" {
  87. t.Errorf("channelName(3) = %q, want %q", got, "rgb_matrix")
  88. }
  89. }
  90. // The display names must come from the keyboard the command targets, not from
  91. // whichever one enumeration returned first. They come from that board's
  92. // definition file, which is where a channel's name lives now.
  93. func TestPrepareTargetUsesTheSelectedKeyboard(t *testing.T) {
  94. devices := []intdevice.Device{
  95. {VendorID: 0x6666, ProductID: 0x0001},
  96. {VendorID: 0x36B0, ProductID: 0x309F, Name: "Impact 80"},
  97. }
  98. dir := t.TempDir()
  99. writeDefinition(t, dir, "rainy75.json", `{
  100. "name": "Rainy 75", "vendorId": "0x6666", "productId": "0x0001",
  101. "menus": [{"label":"Lighting","content":[{"label":"deck","content":[
  102. {"label":"Effect","type":"dropdown","content":["id_qmk_rgblight_effect",2,2],"options":["none"]}]}]}]}`)
  103. writeDefinition(t, dir, "impact80.json", `{
  104. "name": "Impact 80", "vendorId": "0x36B0", "productId": "0x309F",
  105. "menus": [{"label":"Lighting","content":[{"label":"logo","content":[
  106. {"label":"Effect","type":"dropdown","content":["id_qmk_rgblight_effect",2,2],"options":["none","wave"]}]}]}]}`)
  107. t.Cleanup(forceDefinitionsDir(t, dir))
  108. originalDiscover := discoverAll
  109. originalTarget := targetDevice
  110. originalZone := targetZone
  111. t.Cleanup(func() {
  112. discoverAll = originalDiscover
  113. targetDevice = originalTarget
  114. targetZone = originalZone
  115. })
  116. discoverAll = func() ([]intdevice.Device, error) { return devices, nil }
  117. targetDevice = "2"
  118. targetZone = "logo"
  119. got, err := prepareTarget()
  120. if err != nil {
  121. t.Fatalf("prepareTarget() error = %v", err)
  122. }
  123. if got.Display[2] != "logo" {
  124. t.Errorf("display names = %v, want the Impact 80's", got.Display)
  125. }
  126. if got.Device.Name != "Impact 80" {
  127. t.Errorf("name = %q, want the name the definition gives the board", got.Device.Name)
  128. }
  129. }
  130. // A board renamed after a profile was written leaves keys that resolve to
  131. // nothing. Each one must be reported by name, and nothing may be written.
  132. func TestLoadWarnsForEveryUnresolvableZoneKey(t *testing.T) {
  133. t.Cleanup(vendoredDefinitions(t))
  134. dir := t.TempDir()
  135. profile := Profile{
  136. Name: "renamed",
  137. Version: 1,
  138. Zones: map[string]*ZoneSettings{
  139. "old-logo": {Enabled: true, Effect: "light", Brightness: 100, Speed: 1, Color: "00ff"},
  140. "old-backlight": {Enabled: true, Effect: "wave", Brightness: 100, Speed: 1, Color: "00ff"},
  141. },
  142. }
  143. originalDir := profilesDirOverride
  144. t.Cleanup(func() { profilesDirOverride = originalDir })
  145. profilesDirOverride = dir
  146. if err := profile.Save(); err != nil {
  147. t.Fatalf("save profile: %v", err)
  148. }
  149. proto := &verifyingProtocol{}
  150. t.Cleanup(stubTargetForProfileTest(t, proto, "", impact80Display()))
  151. var stderr bytes.Buffer
  152. cmd := NewProfileLoadCmd()
  153. cmd.SetOut(&bytes.Buffer{})
  154. cmd.SetErr(&stderr)
  155. cmd.SetArgs([]string{"renamed"})
  156. if err := cmd.Execute(); err != nil {
  157. t.Fatalf("load returned error: %v", err)
  158. }
  159. for _, want := range []string{"old-logo", "old-backlight"} {
  160. if !strings.Contains(stderr.String(), want) {
  161. t.Errorf("stderr = %q, want it to name the unresolved key %q", stderr.String(), want)
  162. }
  163. }
  164. if len(proto.reports) != 0 {
  165. t.Errorf("reports = %v, want nothing written for unresolvable keys", proto.reports)
  166. }
  167. }
  168. // stubTargetForProfileTest points the profile commands at one protocol, one
  169. // profile directory and one --zone value, and returns the restore function.
  170. func stubTargetForProfileTest(t *testing.T, proto rgbProtocol, zoneFlag string, display map[uint16]string) func() {
  171. t.Helper()
  172. originalTarget := openTarget
  173. originalZone := targetZone
  174. openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
  175. // The board identity matters: the catalog is selected by VID/PID, and a
  176. // profile that names an effect needs one to resolve it against.
  177. target := targetDeviceData{
  178. Device: intdevice.Device{VendorID: 0x36B0, ProductID: 0x309F},
  179. Display: display,
  180. }
  181. requested, err := resolveZoneName(zoneFlag, display, nil)
  182. if err != nil {
  183. return nil, target, nil, err
  184. }
  185. target.Requested = requested
  186. resolved, err := resolveChannels(proto, target)
  187. if err != nil {
  188. return nil, target, nil, err
  189. }
  190. return proto, target, resolved, nil
  191. }
  192. targetZone = zoneFlag
  193. return func() {
  194. openTarget = originalTarget
  195. targetZone = originalZone
  196. }
  197. }
  198. // A key that names a channel this keyboard does not have must be reported like
  199. // any other key it cannot place. "backlight" is the case that matters: it is a
  200. // display name on the Impact 80 and QMK's subsystem name for channel 1, so it
  201. // still resolves after a board renames channel 3 — to a channel that is absent.
  202. func TestLoadWarnsWhenAKeyNamesAnAbsentChannel(t *testing.T) {
  203. t.Cleanup(vendoredDefinitions(t))
  204. dir := t.TempDir()
  205. originalDir := profilesDirOverride
  206. t.Cleanup(func() { profilesDirOverride = originalDir })
  207. profilesDirOverride = dir
  208. profile := Profile{
  209. Name: "renamed",
  210. Version: 1,
  211. Zones: map[string]*ZoneSettings{
  212. "backlight": {Enabled: true, Effect: "light", Brightness: 100, Speed: 1, Color: "00ff"},
  213. },
  214. }
  215. if err := profile.Save(); err != nil {
  216. t.Fatalf("save profile: %v", err)
  217. }
  218. proto := &verifyingProtocol{}
  219. t.Cleanup(stubTargetForProfileTest(t, proto, "", nil))
  220. var stderr bytes.Buffer
  221. cmd := NewProfileLoadCmd()
  222. cmd.SetOut(&bytes.Buffer{})
  223. cmd.SetErr(&stderr)
  224. cmd.SetArgs([]string{"renamed"})
  225. if err := cmd.Execute(); err != nil {
  226. t.Fatalf("load returned error: %v", err)
  227. }
  228. if !strings.Contains(stderr.String(), "backlight") {
  229. t.Errorf("stderr = %q, want the key reported by name", stderr.String())
  230. }
  231. if len(proto.reports) != 0 {
  232. t.Errorf("reports = %v, want nothing written for an absent channel", proto.reports)
  233. }
  234. }
  235. // Without a catalog there are no effect names to look the profile's value up
  236. // in. The load must say that, not claim the name was not found.
  237. func TestLoadSaysSoWhenTheBoardHasNoCatalog(t *testing.T) {
  238. dir := t.TempDir()
  239. originalDir := profilesDirOverride
  240. t.Cleanup(func() { profilesDirOverride = originalDir })
  241. profilesDirOverride = dir
  242. profile := Profile{
  243. Name: "p",
  244. Version: 1,
  245. Zones: map[string]*ZoneSettings{
  246. "rgb_matrix": {Enabled: true, Effect: "breathing", Brightness: 100, Speed: 1, Color: "00ff"},
  247. },
  248. }
  249. if err := profile.Save(); err != nil {
  250. t.Fatalf("save profile: %v", err)
  251. }
  252. proto := &verifyingProtocol{applied: map[via.Channel]uint8{
  253. via.ChannelRgblight: 160,
  254. via.ChannelRgbMatrix: 255,
  255. via.ChannelAudio: 160,
  256. }}
  257. restore := stubTargetForUnknownBoard(t, proto, dir)
  258. t.Cleanup(restore)
  259. var stderr bytes.Buffer
  260. cmd := NewProfileLoadCmd()
  261. cmd.SetOut(&bytes.Buffer{})
  262. cmd.SetErr(&stderr)
  263. cmd.SetArgs([]string{"p"})
  264. if err := cmd.Execute(); err != nil {
  265. t.Fatalf("load returned error: %v", err)
  266. }
  267. if !strings.Contains(stderr.String(), "no effect names") {
  268. t.Errorf("stderr = %q, want the missing names named", stderr.String())
  269. }
  270. if strings.Contains(stderr.String(), `effect "breathing" not found`) {
  271. t.Errorf("stderr = %q, must not blame the effect name for a missing catalog", stderr.String())
  272. }
  273. if len(proto.reports) != 0 {
  274. t.Errorf("reports = %v, want nothing written without a catalog", proto.reports)
  275. }
  276. }
  277. // stubTargetForUnknownBoard points the commands at a board that has channels but
  278. // no definition and no compiled-in catalog, so it has no effect names.
  279. func stubTargetForUnknownBoard(t *testing.T, proto rgbProtocol, dir string) func() {
  280. t.Helper()
  281. originalTarget := openTarget
  282. originalDir := profilesDirOverride
  283. openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
  284. return proto, targetDeviceData{
  285. Device: intdevice.Device{VendorID: 0x6666, ProductID: 0x0001},
  286. }, impact80Channels(), nil
  287. }
  288. profilesDirOverride = dir
  289. return func() {
  290. openTarget = originalTarget
  291. profilesDirOverride = originalDir
  292. }
  293. }
  294. // A board without a catalog cannot name its effects, so a saved profile records
  295. // "unknown" for every channel and cannot be loaded back. That is worth saying at
  296. // save time, where the user can still do something about it.
  297. func TestSaveWarnsThatEffectNamesCannotBeRecorded(t *testing.T) {
  298. dir := t.TempDir()
  299. originalDir := profilesDirOverride
  300. t.Cleanup(func() { profilesDirOverride = originalDir })
  301. profilesDirOverride = dir
  302. proto := &verifyingProtocol{applied: map[via.Channel]uint8{
  303. via.ChannelRgblight: 160,
  304. via.ChannelRgbMatrix: 255,
  305. via.ChannelAudio: 160,
  306. }}
  307. restore := stubTargetForUnknownBoard(t, proto, dir)
  308. t.Cleanup(restore)
  309. var stderr bytes.Buffer
  310. cmd := NewProfileSaveCmd()
  311. cmd.SetOut(&bytes.Buffer{})
  312. cmd.SetErr(&stderr)
  313. cmd.SetArgs([]string{"p"})
  314. if err := cmd.Execute(); err != nil {
  315. t.Fatalf("save returned error: %v", err)
  316. }
  317. if !strings.Contains(stderr.String(), "unknown") {
  318. t.Errorf("stderr = %q, want the effect name it cannot record named", stderr.String())
  319. }
  320. if !strings.Contains(stderr.String(), "no effect names") {
  321. t.Errorf("stderr = %q, want the missing names named", stderr.String())
  322. }
  323. }
  324. // A channel's own definition label and the subsystem name it replaces can both
  325. // match one name, and that is one channel rather than a conflict: the
  326. // documented `--zone backlight` has to keep working on a board whose definition
  327. // calls the channel "Backlight".
  328. func TestResolveZoneNameAcceptsTheNameADefinitionReplaced(t *testing.T) {
  329. display := map[uint16]string{2: "logo", 3: "Backlight", 4: "side"}
  330. alternatives := map[uint16][]string{3: {"backlight"}}
  331. for _, name := range []string{"backlight", "Backlight", "BACKLIGHT"} {
  332. got, err := resolveZoneName(name, display, alternatives)
  333. if err != nil {
  334. t.Errorf("resolveZoneName(%q) error = %v", name, err)
  335. continue
  336. }
  337. if len(got) != 1 || got[0] != 3 {
  338. t.Errorf("resolveZoneName(%q) = %v, want [3]", name, got)
  339. }
  340. }
  341. }
  342. // Two different channels answering to one name is still a conflict, whichever
  343. // source the names come from.
  344. func TestResolveZoneNameStillReportsARealConflict(t *testing.T) {
  345. display := map[uint16]string{2: "Backlight", 3: "backlight"}
  346. if _, err := resolveZoneName("backlight", display, map[uint16][]string{4: {"backlight"}}); err == nil {
  347. t.Error("resolveZoneName() = nil error, want a conflict for one name on two channels")
  348. }
  349. }
  350. // A board whose definition calls channel 3 "Backlight" has no channel 1, and
  351. // naming an unused channel would make "backlight" reach two channels at once —
  352. // the subsystem name of the one it does not have, and the label of the one it
  353. // does. The label has to win, because it is what the board calls that channel.
  354. func TestZoneNamePrefersTheDefinitionLabelOverAnotherChannelsSubsystem(t *testing.T) {
  355. dir := t.TempDir()
  356. writeDefinition(t, dir, "impact80.json", `{
  357. "name": "Impact 80", "vendorId": "0x36B0", "productId": "0x309F",
  358. "menus": [{"label":"Lighting","content":[{"label":"Backlight","content":[
  359. {"label":"Effect","type":"dropdown","content":["id_qmk_rgb_matrix_effect",3,2],
  360. "options":[["cycle_left_right",13]]}]}]}]}`)
  361. t.Cleanup(forceDefinitionsDir(t, dir))
  362. display, alternatives := applyDefinitionLabels(0x36B0, 0x309F)
  363. if display[3] != "Backlight" {
  364. t.Errorf("display = %v, want channel 3 named by the definition", display)
  365. }
  366. if _, ok := display[1]; ok {
  367. t.Errorf("display = %v, want no entry for a channel the definition does not name", display)
  368. }
  369. for _, name := range []string{"backlight", "Backlight", "BACKLIGHT", "rgb_matrix"} {
  370. got, err := resolveZoneName(name, display, alternatives)
  371. if err != nil {
  372. t.Errorf("resolveZoneName(%q) error = %v, want channel 3", name, err)
  373. continue
  374. }
  375. if len(got) != 1 || got[0] != 3 {
  376. t.Errorf("resolveZoneName(%q) = %v, want [3]", name, got)
  377. }
  378. }
  379. }
  380. // Fifteen commands in one flat list is where a user starts reading the help to
  381. // find a command and gives up. The groups say what a command is for, and a
  382. // command that belongs to none of them is a hole in the list.
  383. func TestRootHelpGroupsTheCommands(t *testing.T) {
  384. // The same registration the binary runs, so a command that someone adds
  385. // without a group is caught here rather than in the help output.
  386. root := newRootCommand()
  387. registerCommands(root)
  388. var out bytes.Buffer
  389. root.SetOut(&out)
  390. root.SetArgs([]string{"--help"})
  391. if err := root.Execute(); err != nil {
  392. t.Fatalf("--help returned error: %v", err)
  393. }
  394. help := out.String()
  395. for _, want := range []string{
  396. "Lighting Commands:",
  397. "Profile Commands:",
  398. "Keyboard Commands:",
  399. "save", "load", "list", "delete", "effect", "definition", "keyboard",
  400. } {
  401. if !strings.Contains(help, want) {
  402. t.Errorf("--help does not mention %q", want)
  403. }
  404. }
  405. // Every command the tool adds has to be in a group, or it lands in
  406. // "Additional Commands" where the grouping says nothing about it.
  407. grouped := make(map[string]bool)
  408. for _, c := range root.Commands() {
  409. if c.GroupID != "" {
  410. grouped[c.Name()] = true
  411. }
  412. }
  413. for _, c := range root.Commands() {
  414. if c.Name() == "help" || c.Name() == "completion" {
  415. continue
  416. }
  417. if !grouped[c.Name()] {
  418. t.Errorf("command %q is in no group, so it appears ungrouped in --help", c.Name())
  419. }
  420. }
  421. }