definition_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. package rgb
  2. import (
  3. "os"
  4. "path/filepath"
  5. "strings"
  6. "testing"
  7. "netdome.biz/paul/qmk-rgb/internal/via"
  8. )
  9. // A definition may attach a number to an option that is not its position in the
  10. // list, so the ID has to come from the number and not from the index. This
  11. // fixture names three effects at IDs 1, 2 and 5 and leaves 0, 3 and 4 unnamed.
  12. func TestLoadDefinitionReadsEffectIDsFromTheOptions(t *testing.T) {
  13. def := loadDefinitionFixture(t, "tuple_options.json")
  14. effects := def.Catalog.Effects(via.ChannelRgbMatrix)
  15. if len(effects) != 3 {
  16. t.Fatalf("rgb_matrix effects = %d, want 3", len(effects))
  17. }
  18. want := []Effect{{ID: 1, Name: "Solid Color"}, {ID: 2, Name: "Breathing"}, {ID: 5, Name: "Rainbow"}}
  19. for i, w := range want {
  20. if effects[i] != w {
  21. t.Errorf("effects[%d] = %+v, want %+v", i, effects[i], w)
  22. }
  23. }
  24. // A gap must report unknown rather than the next name along.
  25. for _, id := range []uint8{0, 3, 4, 6} {
  26. if got := def.Catalog.EffectName(via.ChannelRgbMatrix, id); got != unknownEffectName {
  27. t.Errorf("EffectName(rgb_matrix, %d) = %q, want %q", id, got, unknownEffectName)
  28. }
  29. }
  30. }
  31. // The same effect is reachable by name, which is what `effect <name>` needs.
  32. func TestDefinitionEffectsResolveByName(t *testing.T) {
  33. def := loadDefinitionFixture(t, "tuple_options.json")
  34. id, ok := def.Catalog.EffectID(via.ChannelRgbMatrix, "Rainbow")
  35. if !ok || id != 5 {
  36. t.Errorf("EffectID(rgb_matrix, \"Rainbow\") = %d, %t, want 5, true", id, ok)
  37. }
  38. if _, ok := def.Catalog.EffectID(via.ChannelRgbMatrix, "Solid"); ok {
  39. t.Error("EffectID(rgb_matrix, \"Solid\") = found, want not found")
  40. }
  41. }
  42. // Plain string options carry no number, and the position is the ID.
  43. func TestLoadDefinitionNumbersStringOptionsByPosition(t *testing.T) {
  44. def := loadDefinitionFixture(t, "string_options.json")
  45. effects := def.Catalog.Effects(via.ChannelRgblight)
  46. want := []Effect{{ID: 0, Name: "All Off"}, {ID: 1, Name: "Solid Color"}, {ID: 2, Name: "Breathing 1"}, {ID: 3, Name: "Breathing 2"}}
  47. if len(effects) != len(want) {
  48. t.Fatalf("rgblight effects = %d, want %d", len(effects), len(want))
  49. }
  50. for i, w := range want {
  51. if effects[i] != w {
  52. t.Errorf("effects[%d] = %+v, want %+v", i, effects[i], w)
  53. }
  54. }
  55. }
  56. // VIA serves built definitions that carry vendorProductId as a number and no
  57. // vendorId/productId pair, so both spellings have to be read.
  58. func TestDefinitionIdentifiesTheBoardFromEitherSpelling(t *testing.T) {
  59. tests := []struct {
  60. file string
  61. wantVendor uint16
  62. wantProd uint16
  63. }{
  64. {"tuple_options.json", 0x1234, 0x5678},
  65. {"string_options.json", 0x1234, 0x5678},
  66. }
  67. for _, tt := range tests {
  68. def := loadDefinitionFixture(t, tt.file)
  69. if def.VendorID != tt.wantVendor || def.ProductID != tt.wantProd {
  70. t.Errorf("%s: vendor/product = 0x%04x/0x%04x, want 0x%04x/0x%04x",
  71. tt.file, def.VendorID, def.ProductID, tt.wantVendor, tt.wantProd)
  72. }
  73. }
  74. if got := loadDefinitionFixture(t, "string_options.json").Name; got != "Served Board" {
  75. t.Errorf("name = %q, want %q", got, "Served Board")
  76. }
  77. }
  78. // Brightness, color and speed controls are not effect lists and must not become
  79. // one.
  80. func TestLoadDefinitionIgnoresOtherControls(t *testing.T) {
  81. def := loadDefinitionFixture(t, "tuple_options.json")
  82. for _, ch := range []via.Channel{via.ChannelBacklight, via.ChannelRgbMatrix} {
  83. for _, e := range def.Catalog.Effects(ch) {
  84. if e.Name == "" {
  85. t.Errorf("channel %v has an effect without a name", ch)
  86. }
  87. }
  88. }
  89. if got := def.Catalog.Effects(via.ChannelRgbMatrix); len(got) != 3 {
  90. t.Errorf("rgb_matrix effects = %d, want 3 (brightness and color excluded)", len(got))
  91. }
  92. }
  93. // A definition is matched to a board by its VID/PID, so a directory can hold
  94. // many of them and the wrong one must not be used.
  95. func TestFindDefinitionMatchesTheBoard(t *testing.T) {
  96. dir := t.TempDir()
  97. copyFixture(t, dir, "tuple_options.json")
  98. defs, err := LoadDefinitionsDir(dir)
  99. if err != nil {
  100. t.Fatalf("LoadDefinitionsDir() error = %v", err)
  101. }
  102. if len(defs) != 1 {
  103. t.Fatalf("definitions = %d, want 1", len(defs))
  104. }
  105. if got := FindDefinition(defs, 0x1234, 0x5678); got == nil {
  106. t.Error("FindDefinition(matching) = nil, want the definition")
  107. }
  108. if got := FindDefinition(defs, 0x36B0, 0x309F); got != nil {
  109. t.Error("FindDefinition(other board) = a definition, want nil")
  110. }
  111. }
  112. // A file that is not a definition, or not one for any board, is reported rather
  113. // than skipped in silence.
  114. func TestLoadDefinitionsDirRejectsUnusableFiles(t *testing.T) {
  115. dir := t.TempDir()
  116. if err := os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{not json"), 0o600); err != nil {
  117. t.Fatal(err)
  118. }
  119. if _, err := LoadDefinitionsDir(dir); err == nil {
  120. t.Error("LoadDefinitionsDir(broken json) = nil error, want an error")
  121. }
  122. }
  123. func TestLoadDefinitionRejectsAMissingFile(t *testing.T) {
  124. if _, err := LoadDefinition(filepath.Join(t.TempDir(), "nope.json")); err == nil {
  125. t.Error("LoadDefinition(missing) = nil error, want an error")
  126. }
  127. }
  128. func loadDefinitionFixture(t *testing.T, name string) *Definition {
  129. t.Helper()
  130. def, err := LoadDefinition(filepath.Join("testdata", name))
  131. if err != nil {
  132. t.Fatalf("LoadDefinition(%s) error = %v", name, err)
  133. }
  134. return def
  135. }
  136. func copyFixture(t *testing.T, dir, name string) {
  137. t.Helper()
  138. data, err := os.ReadFile(filepath.Join("testdata", name))
  139. if err != nil {
  140. t.Fatal(err)
  141. }
  142. if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
  143. t.Fatal(err)
  144. }
  145. }
  146. // A definition carries the manufacturer's own spelling, and the tool's aliases
  147. // are a spelling convenience rather than board knowledge, so they have to work
  148. // whichever spelling a definition happens to use. The vendor of this board
  149. // writes "breathe" where the compiled-in catalog writes "breathing".
  150. func TestDefinitionCatalogResolvesTheToolsAliasesBothWays(t *testing.T) {
  151. names := []Effect{{ID: 0, Name: "none"}, {ID: 3, Name: "spectrum"}, {ID: 4, Name: "breathe"}, {ID: 5, Name: "light"}}
  152. catalog := NewCatalog("vendor", map[via.Channel][]Effect{via.ChannelRgblight: names})
  153. tests := []struct {
  154. name string
  155. effect string
  156. want uint8
  157. }{
  158. {"the vendor's own spelling", "breathe", 4},
  159. {"the tool's canonical name", "breathing", 4},
  160. {"an alias that the channel has", "off", 0},
  161. {"an alias that resolves per channel", "rainbow", 3},
  162. {"a name no alias or entry reaches", "nonsense", 0},
  163. }
  164. for _, tt := range tests {
  165. t.Run(tt.name, func(t *testing.T) {
  166. id, ok := catalog.EffectID(via.ChannelRgblight, tt.effect)
  167. if tt.effect == "nonsense" {
  168. if ok {
  169. t.Errorf("EffectID(%q) = %d, true, want not found", tt.effect, id)
  170. }
  171. return
  172. }
  173. if !ok || id != tt.want {
  174. t.Errorf("EffectID(%q) = %d, %t, want %d, true", tt.effect, id, ok, tt.want)
  175. }
  176. })
  177. }
  178. }
  179. // The compiled-in catalog keeps working exactly as before.
  180. func TestBuiltInCatalogStillResolvesItsAliases(t *testing.T) {
  181. catalog := impact80(t)
  182. tests := []struct {
  183. channel via.Channel
  184. effect string
  185. want uint8
  186. }{
  187. {via.ChannelRgblight, "breathing", 4},
  188. {via.ChannelRgblight, "breathe", 4},
  189. {via.ChannelRgblight, "off", 0},
  190. {via.ChannelRgbMatrix, "breathing", 5},
  191. {via.ChannelRgbMatrix, "rainbow", 17},
  192. {via.ChannelAudio, "rainbow", 3},
  193. }
  194. for _, tt := range tests {
  195. id, ok := catalog.EffectID(tt.channel, tt.effect)
  196. if !ok || id != tt.want {
  197. t.Errorf("EffectID(%v, %q) = %d, %t, want %d, true", tt.channel, tt.effect, id, ok, tt.want)
  198. }
  199. }
  200. }
  201. // A definition writes display spellings ("fixed wave") where the tool writes
  202. // identifiers ("fixed_wave"). The difference is whitespace, not a different
  203. // effect, so both must resolve — the tool's documented name may not stop working
  204. // because a definition file happens to be present.
  205. func TestDefinitionCatalogResolvesSpacesAsUnderscores(t *testing.T) {
  206. names := []Effect{{ID: 0, Name: "none"}, {ID: 1, Name: "wave"}, {ID: 2, Name: "fixed wave"}}
  207. catalog := NewCatalog("vendor", map[via.Channel][]Effect{via.ChannelRgblight: names})
  208. tests := []struct {
  209. effect string
  210. want uint8
  211. }{
  212. {"fixed wave", 2},
  213. {"fixed_wave", 2},
  214. {"wave", 1},
  215. }
  216. for _, tt := range tests {
  217. id, ok := catalog.EffectID(via.ChannelRgblight, tt.effect)
  218. if !ok || id != tt.want {
  219. t.Errorf("EffectID(%q) = %d, %t, want %d, true", tt.effect, id, ok, tt.want)
  220. }
  221. }
  222. }
  223. // The compiled-in catalog has no spaced names, so the rule changes nothing there.
  224. func TestBuiltInCatalogIsUnchangedByTheSpacingRule(t *testing.T) {
  225. catalog := impact80(t)
  226. if id, ok := catalog.EffectID(via.ChannelRgblight, "fixed_wave"); !ok || id != 2 {
  227. t.Errorf("EffectID(rgblight, \"fixed_wave\") = %d, %t, want 2, true", id, ok)
  228. }
  229. if _, ok := catalog.EffectID(via.ChannelRgbMatrix, "fixed_wave"); ok {
  230. t.Error("EffectID(rgb_matrix, \"fixed_wave\") = found, want not found: only the rgblight channel has it")
  231. }
  232. }
  233. // The sub-menu a definition puts a lighting channel under is that channel's name
  234. // in VIA's own interface, and it is board data the file already carries. Using
  235. // it means the tool and VIA call the same channel the same thing, instead of
  236. // the tool inventing a third name.
  237. func TestDefinitionNamesItsChannelsBySubMenu(t *testing.T) {
  238. def := loadDefinitionFixture(t, "tuple_options.json")
  239. want := map[uint16]string{2: "logo", 3: "Backlight", 4: "side"}
  240. for channel, label := range want {
  241. if got := def.Labels[channel]; got != label {
  242. t.Errorf("Labels[%d] = %q, want %q", channel, got, label)
  243. }
  244. }
  245. if len(def.Labels) != len(want) {
  246. t.Errorf("Labels = %v, want only the lighting channels", def.Labels)
  247. }
  248. }
  249. // The label is whatever the definition calls the channel, not a name this tool
  250. // knows: a different definition calls the same channel Underglow, and that name
  251. // is the one VIA shows.
  252. func TestDefinitionTakesWhateverTheChannelIsCalled(t *testing.T) {
  253. def := loadDefinitionFixture(t, "string_options.json")
  254. if got := def.Labels[2]; got != "Underglow" {
  255. t.Errorf("Labels[2] = %q, want %q", got, "Underglow")
  256. }
  257. if len(def.Labels) != 1 {
  258. t.Errorf("Labels = %v, want only the channel the definition names", def.Labels)
  259. }
  260. }
  261. // The definition files in the repository are read at runtime, so a truncated or
  262. // edited one would fail every command that names an effect rather than fail a
  263. // test. This parses the vendored Impact 80 file and checks what it has to
  264. // contain.
  265. func TestVendoredImpact80DefinitionIsUsable(t *testing.T) {
  266. def, err := LoadDefinition(filepath.Join("..", "..", "definitions", "impact80.json"))
  267. if err != nil {
  268. t.Fatalf("LoadDefinition(definitions/impact80.json) error = %v", err)
  269. }
  270. if def.VendorID != 0x36B0 || def.ProductID != 0x309F {
  271. t.Errorf("identifiers = 0x%04X/0x%04X, want 0x36B0/0x309F", def.VendorID, def.ProductID)
  272. }
  273. want := map[via.Channel]int{
  274. via.ChannelRgblight: 7,
  275. via.ChannelRgbMatrix: 46,
  276. via.ChannelAudio: 7,
  277. }
  278. for ch, count := range want {
  279. if got := len(def.Catalog.Effects(ch)); got != count {
  280. t.Errorf("channel %v has %d effects, want %d", ch, got, count)
  281. }
  282. }
  283. // The names are the vendor's, and the tool's spelling has to resolve too.
  284. if id, ok := def.Catalog.EffectID(via.ChannelRgblight, "fixed wave"); !ok || id != 2 {
  285. t.Errorf("EffectID(rgblight, \"fixed wave\") = %d, %t, want 2, true", id, ok)
  286. }
  287. if id, ok := def.Catalog.EffectID(via.ChannelRgblight, "fixed_wave"); !ok || id != 2 {
  288. t.Errorf("EffectID(rgblight, \"fixed_wave\") = %d, %t, want 2, true", id, ok)
  289. }
  290. if got := def.Labels[3]; got != "Backlight" {
  291. t.Errorf("Labels[3] = %q, want %q", got, "Backlight")
  292. }
  293. }
  294. // enable has to put a channel on an effect, and a definition carries no notion
  295. // of a default one. The rule is taken from the board's own list: the first
  296. // effect that is not the "off" entry. That is board data rather than a hardcoded
  297. // ID, so it works for any board with a definition and is gone for a board
  298. // without one, which is the honest answer there.
  299. func TestDefaultEffectIsTheBoardsFirstEffectThatIsNotOff(t *testing.T) {
  300. catalog := loadDefinitionFixture(t, "tuple_options.json").Catalog
  301. tests := []struct {
  302. channel via.Channel
  303. want uint8
  304. ok bool
  305. }{
  306. // Each of these lists starts at ID 0 with the off entry, so the first
  307. // effect that lights anything is the second one. The rgb_matrix list
  308. // starts at ID 1, so ID 0 does not exist on it at all.
  309. {via.ChannelRgbMatrix, 1, true},
  310. {via.ChannelRgblight, 1, true},
  311. {via.ChannelAudio, 1, true},
  312. }
  313. for _, tt := range tests {
  314. got, ok := catalog.DefaultEffect(tt.channel)
  315. if got != tt.want || ok != tt.ok {
  316. t.Errorf("DefaultEffect(%v) = %d, %t, want %d, %t", tt.channel, got, ok, tt.want, tt.ok)
  317. }
  318. }
  319. }
  320. // A list that names nothing cannot be turned on by an effect, and saying so is
  321. // the point.
  322. func TestDefaultEffectIsAbsentForAnEmptyChannel(t *testing.T) {
  323. catalog := NewCatalog("empty", map[via.Channel][]Effect{via.ChannelRgblight: {{ID: 0, Name: "none"}}})
  324. if got, ok := catalog.DefaultEffect(via.ChannelRgblight); ok {
  325. t.Errorf("DefaultEffect() = %d, true, want none: the channel has only the off entry", got)
  326. }
  327. }
  328. // The message a command fails with is the only documentation a user meets before
  329. // they read anything. It has to name the way out, and there are two: fetch the
  330. // board's definition, or set an effect by number.
  331. func TestMissingCatalogErrorNamesBothWaysOut(t *testing.T) {
  332. _, _, err := ResolveEffect(nil, "wave", []via.Channel{via.ChannelRgbMatrix}, false)
  333. if err == nil {
  334. t.Fatal("ResolveEffect(nil catalog) = nil error, want one")
  335. }
  336. for _, want := range []string{"`keyboard fetch`", "`effect <index>`"} {
  337. if !strings.Contains(err.Error(), want) {
  338. t.Errorf("error = %q, want it to mention %q", err, want)
  339. }
  340. }
  341. }