definition_generate_test.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. package main
  2. import (
  3. "encoding/json"
  4. "os"
  5. "path/filepath"
  6. "strings"
  7. "testing"
  8. intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
  9. intvia "netdome.biz/paul/qmk-rgb/internal/via"
  10. )
  11. // generateStub is a keyboard that answers an effect range, so the generated
  12. // scaffold can be checked without hardware.
  13. type generateStub struct {
  14. tops map[intvia.Channel]int
  15. }
  16. func (g generateStub) EffectTop(ch intvia.Channel) (int, error) {
  17. return g.tops[ch], nil
  18. }
  19. func (g generateStub) GetValue(intvia.Channel, uint8) ([]byte, error) { return []byte{0}, nil }
  20. func (g generateStub) SetValue(intvia.Channel, uint8, uint8) error { return nil }
  21. func (g generateStub) SetColor(intvia.Channel, uint8, uint8) error { return nil }
  22. func (g generateStub) DetectChannels() ([]intvia.Channel, error) { return nil, nil }
  23. func (g generateStub) Close() error { return nil }
  24. func stubGenerateTarget(t *testing.T, channels []intvia.Channel, tops map[intvia.Channel]int) {
  25. t.Helper()
  26. original := openTarget
  27. t.Cleanup(func() { openTarget = original })
  28. openTarget = func(string) (rgbProtocol, targetDeviceData, []intvia.Channel, error) {
  29. return generateStub{tops: tops}, stubTargetData(0x36B0, 0x309F), channels, nil
  30. }
  31. }
  32. func forceGenerateRestore(t *testing.T) func() {
  33. t.Helper()
  34. original := generateForce
  35. return func() { generateForce = original }
  36. }
  37. // The whole point of the command: a generated file names no effect, so a board
  38. // with a generated definition reports the same "no names" it reported before one
  39. // existed. A scaffold that claimed names would be a guess the tool could not
  40. // read back off the keyboard.
  41. func TestGeneratedDefinitionNamesNoEffectUntilTheUserFillsItIn(t *testing.T) {
  42. dir := t.TempDir()
  43. t.Cleanup(definitionFlagRestore(t))
  44. t.Cleanup(forceDefinitionsDir(t, dir))
  45. t.Cleanup(forceGenerateRestore(t))
  46. stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45})
  47. cmd := NewKeyboardDefinitionsGenerateCmd()
  48. var out, errOut strings.Builder
  49. cmd.SetOut(&out)
  50. cmd.SetErr(&errOut)
  51. if err := cmd.Execute(); err != nil {
  52. t.Fatalf("definitions generate error = %v (stderr %q)", err, errOut.String())
  53. }
  54. path := onlyDefinition(t, dir)
  55. def, err := intrgb.ParseDefinition(path, []byte(mustRead(t, path)))
  56. if err != nil {
  57. t.Fatalf("ParseDefinition(%s) error = %v; the generated file has to load", path, err)
  58. }
  59. if got := len(def.Catalog.Effects(intvia.ChannelRgbMatrix)); got != 0 {
  60. t.Errorf("effects on rgb_matrix = %d, want 0 until the names are written in", got)
  61. }
  62. // The slots are still there, which is the other half: the file states which
  63. // IDs exist and leaves the naming to the user.
  64. var file struct {
  65. Menus []struct {
  66. Content []struct {
  67. Label string `json:"label"`
  68. Type string `json:"type"`
  69. Content []any `json:"content"`
  70. Options []any `json:"options"`
  71. } `json:"content"`
  72. } `json:"menus"`
  73. }
  74. if err := json.Unmarshal([]byte(mustRead(t, path)), &file); err != nil {
  75. t.Fatalf("unmarshal generated file: %v", err)
  76. }
  77. var options []any
  78. for _, menu := range file.Menus {
  79. for _, entry := range menu.Content {
  80. if entry.Type == "dropdown" {
  81. options = entry.Options
  82. }
  83. }
  84. }
  85. if len(options) != 46 {
  86. t.Fatalf("effect options = %d, want 46, one per ID from 0 to 45", len(options))
  87. }
  88. for i, option := range options {
  89. pair, ok := option.([]any)
  90. if !ok || len(pair) != 2 {
  91. t.Fatalf("option %d = %v, want a name and a number", i, option)
  92. }
  93. if name, _ := pair[0].(string); name != "" {
  94. t.Errorf("option %d is named %q, want a slot with no name", i, name)
  95. }
  96. if number, _ := pair[1].(float64); int(number) != i {
  97. t.Errorf("option %d carries ID %v, want the slot numbered %d", i, pair[1], i)
  98. }
  99. }
  100. // Every control has to be addressed by a value key the parser recognises. A
  101. // key built by trimming a suffix and appending without the underscore is
  102. // `id_qmk_rgb_matrixbrightness`, which names nothing, and nothing in the
  103. // output above would say so.
  104. wantKeys := map[string]bool{
  105. "id_qmk_rgb_matrix_brightness": false,
  106. "id_qmk_rgb_matrix_effect": false,
  107. "id_qmk_rgb_matrix_effect_speed": false,
  108. }
  109. for _, menu := range file.Menus {
  110. for _, entry := range menu.Content {
  111. key, _ := entry.Content[0].(string)
  112. if _, ok := wantKeys[key]; !ok {
  113. t.Errorf("control %q is addressed by %q, which is not a VIA value key", entry.Label, key)
  114. continue
  115. }
  116. wantKeys[key] = true
  117. }
  118. }
  119. for key, found := range wantKeys {
  120. if !found {
  121. t.Errorf("no control addresses %q", key)
  122. }
  123. }
  124. }
  125. // A definition a user has written is the one command they would have run to write
  126. // it, so it must survive a second run of the command.
  127. func TestGenerateDoesNotReplaceAStoredDefinition(t *testing.T) {
  128. dir := t.TempDir()
  129. t.Cleanup(definitionFlagRestore(t))
  130. t.Cleanup(forceDefinitionsDir(t, dir))
  131. t.Cleanup(forceGenerateRestore(t))
  132. stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45})
  133. written := `{"name":"Test Board","vendorId":"0x36B0","productId":"0x309F","menus":[],"note":"hand written"}`
  134. if err := os.WriteFile(filepath.Join(dir, "test_board.json"), []byte(written), 0o644); err != nil {
  135. t.Fatal(err)
  136. }
  137. cmd := NewKeyboardDefinitionsGenerateCmd()
  138. var out, errOut strings.Builder
  139. cmd.SetOut(&out)
  140. cmd.SetErr(&errOut)
  141. err := cmd.Execute()
  142. if err == nil {
  143. t.Fatal("definitions generate = nil error, want a refusal to replace a stored definition")
  144. }
  145. if !strings.Contains(err.Error(), "--force") {
  146. t.Errorf("error = %q, want it to offer --force", err)
  147. }
  148. if got := mustRead(t, filepath.Join(dir, "test_board.json")); got != written {
  149. t.Errorf("stored file = %q, want it untouched (%q)", got, written)
  150. }
  151. }
  152. // The note beside the file is what carries the names, because a JSON file cannot
  153. // hold them: the tool would have to read comments and VIA's parser would reject
  154. // them. It has to say who wrote what, or a name is a guess wearing a count.
  155. func TestGeneratedNoteCarriesTheSpellingsAndWhoWroteThem(t *testing.T) {
  156. dir := t.TempDir()
  157. t.Cleanup(definitionFlagRestore(t))
  158. t.Cleanup(forceDefinitionsDir(t, dir))
  159. t.Cleanup(forceGenerateRestore(t))
  160. stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45})
  161. cmd := NewKeyboardDefinitionsGenerateCmd()
  162. var out, errOut strings.Builder
  163. cmd.SetOut(&out)
  164. cmd.SetErr(&errOut)
  165. if err := cmd.Execute(); err != nil {
  166. t.Fatalf("definitions generate error = %v", err)
  167. }
  168. note := mustRead(t, strings.TrimSuffix(onlyDefinition(t, dir), ".json")+spottedNoteSuffix)
  169. if !strings.Contains(note, "rgb_matrix (channel 3), IDs 0 to 45") {
  170. t.Errorf("note = %q, want it to name the channel and the ID range", note)
  171. }
  172. // The measurement behind the names: a spelling, how many boards wrote it, and
  173. // which manufacturer most of them were.
  174. if !strings.Contains(note, "rainbow_moving_chevron") {
  175. t.Errorf("note = %q, want the spellings other definitions use", note)
  176. }
  177. if !strings.Contains(note, "boards:") {
  178. t.Errorf("note = %q, want a count of boards next to every name", note)
  179. }
  180. if !strings.Contains(note, "keychron") {
  181. t.Errorf("note = %q, want the manufacturer behind most of a name's spellings", note)
  182. }
  183. // Effect 23 is where the collection disagrees about what the number even is,
  184. // so the note has to show the runner-up rather than pick a winner.
  185. if !strings.Contains(note, "ID 23 ") {
  186. t.Errorf("note = %q, want a line for ID 23", note)
  187. }
  188. }
  189. func onlyDefinition(t *testing.T, dir string) string {
  190. t.Helper()
  191. matches, err := filepath.Glob(filepath.Join(dir, "*.json"))
  192. if err != nil {
  193. t.Fatal(err)
  194. }
  195. if len(matches) != 1 {
  196. t.Fatalf("definitions dir = %v, want one JSON file", matches)
  197. }
  198. return matches[0]
  199. }
  200. func mustRead(t *testing.T, path string) string {
  201. t.Helper()
  202. data, err := os.ReadFile(path)
  203. if err != nil {
  204. t.Fatal(err)
  205. }
  206. return string(data)
  207. }