definition_generate_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. package main
  2. import (
  3. "os"
  4. "path/filepath"
  5. "strings"
  6. "testing"
  7. intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
  8. intvia "netdome.biz/paul/qmk-rgb/internal/via"
  9. )
  10. // generateStub is a keyboard that answers an effect range, so a generated names
  11. // file can be checked without hardware. The Vial fields are what a keyboard
  12. // running Vial firmware would answer; a zero version reads as one that is not
  13. // Vial.
  14. type generateStub struct {
  15. tops map[intvia.Channel]int
  16. vial bool
  17. vialIDs []uint16
  18. }
  19. func (g generateStub) EffectTop(ch intvia.Channel) (int, error) { return g.tops[ch], nil }
  20. func (g generateStub) VialVersion() (uint32, bool, error) {
  21. if !g.vial {
  22. return 0, false, nil
  23. }
  24. return 6, true, nil
  25. }
  26. func (g generateStub) VialEffectIDs() ([]uint16, error) { return g.vialIDs, nil }
  27. func (g generateStub) GetValue(intvia.Channel, uint8) ([]byte, error) { return []byte{0}, nil }
  28. func (g generateStub) SetValue(intvia.Channel, uint8, uint8) error { return nil }
  29. func (g generateStub) SetColor(intvia.Channel, uint8, uint8) error { return nil }
  30. func (g generateStub) DetectChannels() ([]intvia.Channel, error) { return nil, nil }
  31. func (g generateStub) Close() error { return nil }
  32. // stubGenerateTarget makes the connected keyboard a fixed one. The identifiers are
  33. // a parameter because a board the binary carries a definition for is a different
  34. // case from one it does not, and a stub for the wrong board would test nothing.
  35. func stubGenerateTarget(t *testing.T, vendorID, productID uint16, channels []intvia.Channel, stub generateStub) {
  36. t.Helper()
  37. original := openTarget
  38. t.Cleanup(func() { openTarget = original })
  39. openTarget = func(string) (rgbProtocol, targetDeviceData, []intvia.Channel, error) {
  40. return stub, stubTargetData(vendorID, productID), channels, nil
  41. }
  42. }
  43. func forceGenerateRestore(t *testing.T) func() {
  44. t.Helper()
  45. original := generateForce
  46. return func() { generateForce = original }
  47. }
  48. // forceNamesDir points the names directory at a test directory. Without it a test
  49. // that writes a names file writes into the user's real data directory, which is
  50. // what happened when this override did not exist yet.
  51. func forceNamesDir(t *testing.T, dir string) func() {
  52. t.Helper()
  53. original := namesDirOverride
  54. namesDirOverride = dir
  55. return func() { namesDirOverride = original }
  56. }
  57. // generateSetup points every writable directory at one temporary directory, so a
  58. // test cannot reach the user's.
  59. // generateSetup points the writable directories at temporary ones and returns the
  60. // names directory. They are two directories on purpose: a names file in the
  61. // definitions directory parses as a definition — it carries the board's
  62. // identifiers and no menus — and takes the board's place, which is exactly what
  63. // TestNamesFileInTheDefinitionsDirectoryIsNotADefinition is about.
  64. func generateSetup(t *testing.T) string {
  65. t.Helper()
  66. names := t.TempDir()
  67. t.Cleanup(definitionFlagRestore(t))
  68. t.Cleanup(forceDefinitionsDir(t, t.TempDir()))
  69. t.Cleanup(forceNamesDir(t, names))
  70. t.Cleanup(forceGenerateRestore(t))
  71. return names
  72. }
  73. func runGenerate(t *testing.T) (string, string) {
  74. t.Helper()
  75. cmd := NewKeyboardDefinitionsGenerateCmd()
  76. var out, errOut strings.Builder
  77. cmd.SetOut(&out)
  78. cmd.SetErr(&errOut)
  79. if err := cmd.Execute(); err != nil {
  80. t.Fatalf("definitions generate error = %v (stderr %q)", err, errOut.String())
  81. }
  82. return out.String(), errOut.String()
  83. }
  84. func onlyNamesFile(t *testing.T, dir string) string {
  85. t.Helper()
  86. matches, err := filepath.Glob(filepath.Join(dir, "*.json"))
  87. if err != nil {
  88. t.Fatal(err)
  89. }
  90. if len(matches) != 1 {
  91. t.Fatalf("names dir = %v, want one file", matches)
  92. }
  93. return matches[0]
  94. }
  95. func mustRead(t *testing.T, path string) string {
  96. t.Helper()
  97. data, err := os.ReadFile(path)
  98. if err != nil {
  99. t.Fatal(err)
  100. }
  101. return string(data)
  102. }
  103. // A generated file names no effect, so a board reports the same "no names" it
  104. // reported before one existed. A name the tool wrote would be indistinguishable
  105. // from the manufacturer's, and nothing in a file can be read back off a keyboard
  106. // to check it.
  107. func TestGeneratedNamesFileNamesNoEffectUntilTheUserFillsItIn(t *testing.T) {
  108. dir := generateSetup(t)
  109. stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix},
  110. generateStub{tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}})
  111. runGenerate(t)
  112. path := onlyNamesFile(t, dir)
  113. names, err := intrgbLoadNamesFile(path)
  114. if err != nil {
  115. t.Fatalf("LoadNamesFile(%s) error = %v; the generated file has to load", path, err)
  116. }
  117. if got := len(names.Effects(intvia.ChannelRgbMatrix)); got != 0 {
  118. t.Errorf("effects on rgb_matrix = %d, want 0 until the names are written in", got)
  119. }
  120. // The slots are still there, which is the other half: the file says which IDs
  121. // the board has and leaves the naming to the user.
  122. body := mustRead(t, path)
  123. for _, want := range []string{`"0": ""`, `"7": ""`, `"45": ""`} {
  124. if !strings.Contains(body, want) {
  125. t.Errorf("generated file has no empty slot %s", want)
  126. }
  127. }
  128. // And it is one line per effect, not a nested menu description to dig through.
  129. if lines := strings.Count(body, "\n"); lines > 60 {
  130. t.Errorf("generated file is %d lines, want roughly one per effect", lines)
  131. }
  132. }
  133. // A names file is the one the user would have run the command to write, so a
  134. // second run must not replace it.
  135. func TestGenerateDoesNotReplaceAStoredNamesFile(t *testing.T) {
  136. dir := generateSetup(t)
  137. stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix},
  138. generateStub{tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}})
  139. written := `{"vendorId":"0x1234","productId":"0x5678","channels":{"rgb_matrix":{"7":"mine"}}}`
  140. if err := os.WriteFile(filepath.Join(dir, "board.json"), []byte(written), 0o644); err != nil {
  141. t.Fatal(err)
  142. }
  143. cmd := NewKeyboardDefinitionsGenerateCmd()
  144. var out, errOut strings.Builder
  145. cmd.SetOut(&out)
  146. cmd.SetErr(&errOut)
  147. err := cmd.Execute()
  148. if err == nil {
  149. t.Fatal("definitions generate = nil error, want a refusal to replace a stored names file")
  150. }
  151. if !strings.Contains(err.Error(), "--force") {
  152. t.Errorf("error = %q, want it to offer --force", err)
  153. }
  154. if got := mustRead(t, filepath.Join(dir, "board.json")); got != written {
  155. t.Errorf("stored file = %q, want it untouched (%q)", got, written)
  156. }
  157. }
  158. // The note beside the file is what carries the names, because a JSON file cannot:
  159. // the tool would have to read comments and VIA's parser would reject them. It has
  160. // to say who wrote what, or a name is a guess wearing a count.
  161. func TestGeneratedNoteCarriesTheSpellingsAndWhoWroteThem(t *testing.T) {
  162. dir := generateSetup(t)
  163. stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix},
  164. generateStub{tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}})
  165. runGenerate(t)
  166. note := mustRead(t, strings.TrimSuffix(onlyNamesFile(t, dir), ".json")+spottedNoteSuffix)
  167. for _, want := range []string{
  168. "rgb_matrix (channel 3)",
  169. "rainbow_moving_chevron",
  170. "boards:",
  171. "keychron",
  172. string(slotsMeasured),
  173. } {
  174. if !strings.Contains(note, want) {
  175. t.Errorf("note does not carry %q", want)
  176. }
  177. }
  178. }
  179. // A Vial keyboard lists its own rgb_matrix effect IDs, and the tool must use those
  180. // rather than writing above the top: the list is the firmware answering without
  181. // being touched.
  182. func TestGenerateUsesVialsOwnListWhenTheFirmwareIsVial(t *testing.T) {
  183. dir := generateSetup(t)
  184. stubGenerateTarget(t, 0x1234, 0x5678,
  185. []intvia.Channel{intvia.ChannelRgbMatrix, intvia.ChannelRgblight},
  186. generateStub{
  187. tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45, intvia.ChannelRgblight: 6},
  188. vial: true,
  189. vialIDs: []uint16{0, 1, 2, 40, 45},
  190. })
  191. out, _ := runGenerate(t)
  192. if !strings.Contains(out, "Vial firmware") {
  193. t.Errorf("stdout = %q, want it to say the keyboard is Vial", out)
  194. }
  195. if !strings.Contains(out, experimentalVial) {
  196. t.Errorf("stdout = %q, want the Vial path marked %s", out, experimentalVial)
  197. }
  198. // rgb_matrix keeps the firmware's own numbering, gaps and all; rgblight has no
  199. // Vial list and falls back to the clamp.
  200. if !strings.Contains(out, "0, 1, 2, 40, 45") {
  201. t.Errorf("stdout = %q, want the rgb_matrix slots in the firmware's numbering", out)
  202. }
  203. if !strings.Contains(out, string(slotsReported)) || !strings.Contains(out, string(slotsMeasured)) {
  204. t.Errorf("stdout = %q, want both sources named per channel", out)
  205. }
  206. // The note must not put QMK's spellings next to numbers that are not QMK's.
  207. note := mustRead(t, strings.TrimSuffix(onlyNamesFile(t, dir), ".json")+spottedNoteSuffix)
  208. if strings.Contains(note, "rainbow_moving_chevron") {
  209. t.Errorf("note = %q, want no QMK spellings beside a Vial numbering", note)
  210. }
  211. if !strings.Contains(note, "VIALRGB_EFFECT_*") {
  212. t.Errorf("note = %q, want it to say the numbering is the firmware's own", note)
  213. }
  214. }
  215. // A Vial keyboard built without VIALRGB_ENABLE answers nothing, and a board that
  216. // cannot list its effects can still be asked by writing above the top.
  217. func TestGenerateFallsBackToTheClampWhenVialListsNothing(t *testing.T) {
  218. dir := generateSetup(t)
  219. stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix},
  220. generateStub{tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}, vial: true})
  221. out, _ := runGenerate(t)
  222. if !strings.Contains(out, string(slotsMeasured)) {
  223. t.Errorf("stdout = %q, want the clamp used when Vial lists nothing", out)
  224. }
  225. note := mustRead(t, strings.TrimSuffix(onlyNamesFile(t, dir), ".json")+spottedNoteSuffix)
  226. if !strings.Contains(note, "rainbow_moving_chevron") {
  227. t.Errorf("note = %q, want the QMK spellings again, which is what the clamp gives", note)
  228. }
  229. }
  230. // The point of storing names apart from the definition: a name the user writes
  231. // replaces the vendor's for that one effect, and the rest of the file still stands.
  232. // The board is the Impact 80, whose definition the binary carries, so "the rest"
  233. // means real names the vendor wrote.
  234. func TestANameTheUserWroteOverridesOneEffectAndLeavesTheRest(t *testing.T) {
  235. generateSetup(t)
  236. names := t.TempDir()
  237. t.Cleanup(forceNamesDir(t, names))
  238. body := `{"name":"Impact 80","vendorId":"0x36B0","productId":"0x309F",
  239. "channels":{"rgb_matrix":{"7":"my_chevron"}}}`
  240. if err := os.WriteFile(filepath.Join(names, "impact80.json"), []byte(body), 0o644); err != nil {
  241. t.Fatal(err)
  242. }
  243. catalog, _, err := resolveCatalogFor(0x36B0, 0x309F)
  244. if err != nil {
  245. t.Fatalf("resolveCatalogFor() error = %v", err)
  246. }
  247. if got := catalog.EffectName(intvia.ChannelRgbMatrix, 7); got != "my_chevron" {
  248. t.Errorf("EffectName(7) = %q, want the user's name", got)
  249. }
  250. if got := catalog.EffectSource(intvia.ChannelRgbMatrix, 7); got != intrgbSourceUser {
  251. t.Errorf("EffectSource(7) = %q, want %q", got, intrgbSourceUser)
  252. }
  253. // ID 8 is band_pinwheel_sat in the Impact 80's own file, and the user's file
  254. // said nothing about it, so it has to still be the vendor's name and carry no
  255. // source of its own.
  256. if got := catalog.EffectName(intvia.ChannelRgbMatrix, 8); got != "band_pinwheel_sat" {
  257. t.Errorf("EffectName(8) = %q, want the vendor's name left alone", got)
  258. }
  259. if got := catalog.EffectSource(intvia.ChannelRgbMatrix, 8); got != intrgbSourceVendor {
  260. t.Errorf("EffectSource(8) = %q, want an empty source for the vendor's", got)
  261. }
  262. }
  263. // intrgbLoadNamesFile and intrgbSourceUser keep the test reading through the
  264. // internal package without importing it twice under two names.
  265. func intrgbLoadNamesFile(path string) (*intrgb.Names, error) { return intrgb.LoadNamesFile(path) }
  266. const (
  267. intrgbSourceUser = intrgb.SourceUser
  268. intrgbSourceVendor = intrgb.SourceVendor
  269. )