definition_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. package main
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "net/http"
  6. "os"
  7. "path/filepath"
  8. "strings"
  9. "testing"
  10. intdevice "netdome.biz/paul/qmk-rgb/internal/device"
  11. )
  12. func TestFetchDefinitionStoresTheFileForTheBoard(t *testing.T) {
  13. dir := t.TempDir()
  14. t.Cleanup(definitionFlagRestore(t))
  15. t.Cleanup(forceDefinitionsDir(t, dir))
  16. stubPrepareTarget(t, 0x1234, 0x5678)
  17. served := `{"name":"Test Board","vendorProductId":305419896,"menus":[]}`
  18. var requested []string
  19. stubHTTPGet(t, func(url string) ([]byte, int, error) {
  20. requested = append(requested, url)
  21. if strings.Contains(url, "/v3/") {
  22. return []byte(served), http.StatusOK, nil
  23. }
  24. return nil, http.StatusNotFound, errors.New("404")
  25. })
  26. cmd := NewKeyboardFetchCmd()
  27. var out, errOut strings.Builder
  28. cmd.SetOut(&out)
  29. cmd.SetErr(&errOut)
  30. if err := cmd.Execute(); err != nil {
  31. t.Fatalf("keyboard fetch error = %v (stderr %q)", err, errOut.String())
  32. }
  33. if len(requested) != 1 || !strings.Contains(requested[0], "/v3/305419896.json") {
  34. t.Errorf("requested = %v, want one v3 URL for vendorProductId 305419896", requested)
  35. }
  36. if !strings.Contains(out.String(), "Test Board") {
  37. t.Errorf("stdout = %q, want the board name", out.String())
  38. }
  39. entries, err := os.ReadDir(dir)
  40. if err != nil {
  41. t.Fatal(err)
  42. }
  43. if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".json") {
  44. t.Fatalf("definitions dir = %v, want one JSON file", entries)
  45. }
  46. }
  47. // An unknown board is answered with a web page and a success status, so the
  48. // fetch must not store it and must say what happened instead.
  49. func TestFetchDefinitionRejectsAPageThatIsNotADefinition(t *testing.T) {
  50. dir := t.TempDir()
  51. t.Cleanup(definitionFlagRestore(t))
  52. t.Cleanup(forceDefinitionsDir(t, dir))
  53. stubPrepareTarget(t, 0x1234, 0x5678)
  54. stubHTTPGet(t, func(string) ([]byte, int, error) {
  55. return []byte("<!doctype html><html><head><title>VIA</title></head></html>"), http.StatusOK, nil
  56. })
  57. cmd := NewKeyboardFetchCmd()
  58. var out, errOut strings.Builder
  59. cmd.SetOut(&out)
  60. cmd.SetErr(&errOut)
  61. err := cmd.Execute()
  62. if err == nil {
  63. t.Fatal("keyboard fetch = nil error, want a failure for a non-definition")
  64. }
  65. if !strings.Contains(err.Error(), "no definition") {
  66. t.Errorf("error = %q, want it to say there is no definition", err)
  67. }
  68. entries, _ := os.ReadDir(dir)
  69. if len(entries) != 0 {
  70. t.Errorf("definitions dir = %v, want nothing stored", entries)
  71. }
  72. }
  73. // A definition for another board must not be used, and saying so is the whole
  74. // point of carrying the identifiers.
  75. func TestDefinitionFlagRejectsTheWrongBoard(t *testing.T) {
  76. dir := t.TempDir()
  77. path := filepath.Join(dir, "other.json")
  78. if err := os.WriteFile(path, []byte(`{"name":"Other","vendorId":"0x1111","productId":"0x2222"}`), 0o600); err != nil {
  79. t.Fatal(err)
  80. }
  81. t.Cleanup(definitionFlagRestore(t))
  82. definitionFlag = path
  83. _, _, err := resolveCatalog(stubTargetData(0x1234, 0x5678))
  84. if err == nil {
  85. t.Fatal("resolveCatalog() = nil error, want a rejection for another board")
  86. }
  87. if !strings.Contains(err.Error(), "0x1111") {
  88. t.Errorf("error = %q, want it to name the board the file is for", err)
  89. }
  90. }
  91. // A definition in the data directory is used for its board without a flag.
  92. func TestResolveCatalogPrefersTheFileForTheBoard(t *testing.T) {
  93. dir := t.TempDir()
  94. if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{
  95. "name":"From File","vendorId":"0x1234","productId":"0x5678",
  96. "menus":[{"label":"Lighting","content":[{"label":"Backlight","content":[
  97. {"label":"Effect","type":"dropdown","content":["id_qmk_rgb_matrix_effect",3,2],
  98. "options":[["Only One",4]]}]}]}]}`), 0o600); err != nil {
  99. t.Fatal(err)
  100. }
  101. t.Cleanup(definitionFlagRestore(t))
  102. t.Cleanup(forceDefinitionsDir(t, dir))
  103. catalog, source, err := resolveCatalog(stubTargetData(0x1234, 0x5678))
  104. if err != nil {
  105. t.Fatalf("resolveCatalog() error = %v", err)
  106. }
  107. if id, ok := catalog.EffectID(3, "Only One"); !ok || id != 4 {
  108. t.Errorf("EffectID(rgb_matrix, \"Only One\") = %d, %t, want 4, true", id, ok)
  109. }
  110. if !strings.Contains(source, "b.json") {
  111. t.Errorf("source = %q, want the file it came from", source)
  112. }
  113. }
  114. // The Impact 80's definition is built into the binary, because `go install`
  115. // delivers a binary and no data directory, and this board's file cannot be
  116. // fetched. It is still the vendor's file and not a hand-written list, so it is
  117. // reported as the file it is and stays overridable.
  118. func TestResolveCatalogFallsBackToTheBuiltInDefinition(t *testing.T) {
  119. dir := t.TempDir()
  120. t.Cleanup(definitionFlagRestore(t))
  121. t.Cleanup(forceDefinitionsDir(t, dir))
  122. catalog, source, err := resolveCatalog(stubTargetData(0x36B0, 0x309F))
  123. if err != nil {
  124. t.Fatalf("resolveCatalog() error = %v", err)
  125. }
  126. if catalog == nil {
  127. t.Fatal("resolveCatalog() = nil, want the built-in definition: go install ships no data directory")
  128. }
  129. if want := "definitions/impact80.json"; source != want {
  130. t.Errorf("source = %q, want %q: it is the shipped file, not a path in an install", source, want)
  131. }
  132. if id, ok := catalog.EffectID(3, "riverflow"); !ok || id != 45 {
  133. t.Errorf("EffectID(3, \"riverflow\") = %d, %t, want 45, true", id, ok)
  134. }
  135. }
  136. // A file in a definitions directory wins over the copy built into the binary, or
  137. // the shipped file could never be corrected without a rebuild. This is the
  138. // property that keeps the built-in copy from being a second source of a name:
  139. // it is consulted last, and only when nothing else has one.
  140. func TestDefinitionsDirectoryOverridesTheBuiltInFile(t *testing.T) {
  141. dir := t.TempDir()
  142. t.Cleanup(definitionFlagRestore(t))
  143. t.Cleanup(forceDefinitionsDir(t, dir))
  144. replacement := `{"name":"Impact 80","vendorId":"0x36B0","productId":"0x309F",
  145. "menus":[{"label":"Lighting","content":[{"label":"Backlight","content":[
  146. {"label":"Effect","type":"dropdown","content":["id_qmk_rgb_matrix_effect",3,2],
  147. "options":[["only one",7]]}]}]}]}`
  148. if err := os.WriteFile(filepath.Join(dir, "impact80.json"), []byte(replacement), 0o644); err != nil {
  149. t.Fatal(err)
  150. }
  151. catalog, source, err := resolveCatalog(stubTargetData(0x36B0, 0x309F))
  152. if err != nil {
  153. t.Fatalf("resolveCatalog() error = %v", err)
  154. }
  155. if source != filepath.Join(dir, "impact80.json") {
  156. t.Errorf("source = %q, want the file in the directory", source)
  157. }
  158. if id, ok := catalog.EffectID(3, "riverflow"); ok {
  159. t.Errorf("EffectID(3, \"riverflow\") = %d, true, want not found: the placed file replaces the built-in one", id)
  160. }
  161. if id, ok := catalog.EffectID(3, "only one"); !ok || id != 7 {
  162. t.Errorf("EffectID(3, \"only one\") = %d, %t, want 7, true", id, ok)
  163. }
  164. }
  165. // An unknown board with no file has no catalog at all, which the commands
  166. // already report rather than guessing.
  167. func TestResolveCatalogHasNothingForAnUnknownBoard(t *testing.T) {
  168. dir := t.TempDir()
  169. t.Cleanup(definitionFlagRestore(t))
  170. t.Cleanup(forceDefinitionsDir(t, dir))
  171. catalog, source, err := resolveCatalog(stubTargetData(0x1111, 0x2222))
  172. if err != nil {
  173. t.Fatalf("resolveCatalog() error = %v", err)
  174. }
  175. if catalog != nil || source != "" {
  176. t.Errorf("resolveCatalog() = %v, %q, want nil, \"\"", catalog, source)
  177. }
  178. }
  179. // definitionFlagRestore resets --definition and returns a cleanup that restores
  180. // it, so a test can use the flag without leaking it into the next one.
  181. func definitionFlagRestore(t *testing.T) func() {
  182. t.Helper()
  183. original := definitionFlag
  184. return func() { definitionFlag = original }
  185. }
  186. // forceDefinitionsDir points the data directory at a test directory. The tool
  187. // looks for it next to the executable and then in the working directory, so a
  188. // test that wants its own has to make that lookup find it.
  189. func forceDefinitionsDir(t *testing.T, dir string) func() {
  190. t.Helper()
  191. original := definitionsDirOverride
  192. definitionsDirOverride = dir
  193. return func() { definitionsDirOverride = original }
  194. }
  195. // stubHTTPGet answers every request from a function instead of a network.
  196. func stubHTTPGet(t *testing.T, fn func(url string) ([]byte, int, error)) {
  197. t.Helper()
  198. original := httpGet
  199. httpGet = fn
  200. t.Cleanup(func() { httpGet = original })
  201. }
  202. // stubPrepareTarget makes the connected keyboard a fixed one, so a test does
  203. // not need hardware to describe a board.
  204. func stubPrepareTarget(t *testing.T, vendorID, productID uint16) {
  205. t.Helper()
  206. original := prepareTarget
  207. prepareTarget = func(string) (targetDeviceData, error) { return stubTargetData(vendorID, productID), nil }
  208. t.Cleanup(func() { prepareTarget = original })
  209. }
  210. // stubTargetData is a target for a board, for the catalog lookup tests.
  211. func stubTargetData(vendorID, productID uint16) targetDeviceData {
  212. return targetDeviceData{
  213. Device: intdevice.Device{VendorID: vendorID, ProductID: productID},
  214. Display: map[uint16]string{},
  215. }
  216. }
  217. // keyboard definitions is structured data, so --json has to reach it like every other
  218. // listing command; text by default is no excuse for a missing machine shape. The
  219. // definitions built into the binary are listed beside the ones in the user
  220. // directory, and each carries which of the two it is.
  221. func TestDefinitionListHonoursTheJSONFlag(t *testing.T) {
  222. dir := t.TempDir()
  223. if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{"name":"Test Board","vendorId":"0x1234","productId":"0x5678"}`), 0o600); err != nil {
  224. t.Fatal(err)
  225. }
  226. t.Cleanup(forceDefinitionsDir(t, dir))
  227. var out, errOut strings.Builder
  228. cmd := NewKeyboardDefinitionsCmd()
  229. cmd.SetOut(&out)
  230. cmd.SetErr(&errOut)
  231. withJSON(t)
  232. if err := cmd.Execute(); err != nil {
  233. t.Fatalf("keyboard definitions --json error = %v", err)
  234. }
  235. var payload struct {
  236. Definitions []struct {
  237. Name string `json:"name"`
  238. VendorID string `json:"vendorId"`
  239. ProductID string `json:"productId"`
  240. Path string `json:"path"`
  241. Source string `json:"source"`
  242. } `json:"definitions"`
  243. }
  244. if err := json.Unmarshal([]byte(out.String()), &payload); err != nil {
  245. t.Fatalf("output is not JSON: %v (%q)", err, out.String())
  246. }
  247. var found *struct {
  248. Name string `json:"name"`
  249. VendorID string `json:"vendorId"`
  250. ProductID string `json:"productId"`
  251. Path string `json:"path"`
  252. Source string `json:"source"`
  253. }
  254. for i := range payload.Definitions {
  255. if payload.Definitions[i].Name == "Test Board" {
  256. found = &payload.Definitions[i]
  257. }
  258. }
  259. if found == nil {
  260. t.Fatalf("payload = %+v, want the file in the user directory", payload.Definitions)
  261. }
  262. if found.VendorID != "0x1234" || found.ProductID != "0x5678" {
  263. t.Errorf("identifiers = %s/%s, want 0x1234/0x5678", found.VendorID, found.ProductID)
  264. }
  265. if found.Source != "user" {
  266. t.Errorf("source = %q, want %q: a file in the user directory", found.Source, "user")
  267. }
  268. }
  269. // A board whose definition is built in and whose file is not in the user
  270. // directory still appears, marked as built-in. Without it a listing would say the
  271. // tool has no definition for the board it is driving.
  272. func TestDefinitionListShowsTheBuiltInDefinitions(t *testing.T) {
  273. t.Cleanup(forceDefinitionsDir(t, t.TempDir()))
  274. var out, errOut strings.Builder
  275. cmd := NewKeyboardDefinitionsCmd()
  276. cmd.SetOut(&out)
  277. cmd.SetErr(&errOut)
  278. withJSON(t)
  279. if err := cmd.Execute(); err != nil {
  280. t.Fatalf("keyboard definitions --json error = %v", err)
  281. }
  282. var payload struct {
  283. Definitions []struct {
  284. Name string `json:"name"`
  285. Source string `json:"source"`
  286. } `json:"definitions"`
  287. }
  288. if err := json.Unmarshal([]byte(out.String()), &payload); err != nil {
  289. t.Fatalf("output is not JSON: %v (%q)", err, out.String())
  290. }
  291. if len(payload.Definitions) == 0 {
  292. t.Fatal("definitions = none, want the file built into the binary")
  293. }
  294. for _, d := range payload.Definitions {
  295. if d.Source != "built-in" {
  296. t.Errorf("%s source = %q, want %q", d.Name, d.Source, "built-in")
  297. }
  298. }
  299. }
  300. // A file in the user directory that describes a board the binary also carries
  301. // overrides it, and the listing says so, because the two files disagree and only
  302. // one of them is read.
  303. func TestDefinitionListReportsThatAUserFileShadowsABuiltInOne(t *testing.T) {
  304. dir := t.TempDir()
  305. replacement := `{"name":"Impact 80","vendorId":"0x36B0","productId":"0x309F"}`
  306. if err := os.WriteFile(filepath.Join(dir, "impact80.json"), []byte(replacement), 0o600); err != nil {
  307. t.Fatal(err)
  308. }
  309. t.Cleanup(forceDefinitionsDir(t, dir))
  310. var out, errOut strings.Builder
  311. cmd := NewKeyboardDefinitionsCmd()
  312. cmd.SetOut(&out)
  313. cmd.SetErr(&errOut)
  314. if err := cmd.Execute(); err != nil {
  315. t.Fatalf("keyboard definitions error = %v", err)
  316. }
  317. if !strings.Contains(out.String(), "shadows the built-in copy") {
  318. t.Errorf("stdout = %q, want it to say the user file overrides the built-in one", out.String())
  319. }
  320. }
  321. func TestDefinitionListPrintsTextByDefault(t *testing.T) {
  322. dir := t.TempDir()
  323. if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{"name":"Test Board","vendorId":"0x1234","productId":"0x5678"}`), 0o600); err != nil {
  324. t.Fatal(err)
  325. }
  326. t.Cleanup(forceDefinitionsDir(t, dir))
  327. var out, errOut strings.Builder
  328. cmd := NewKeyboardDefinitionsCmd()
  329. cmd.SetOut(&out)
  330. cmd.SetErr(&errOut)
  331. if err := cmd.Execute(); err != nil {
  332. t.Fatalf("keyboard definitions error = %v", err)
  333. }
  334. var probe any
  335. if err := json.Unmarshal([]byte(out.String()), &probe); err == nil {
  336. t.Errorf("stdout = %q, want text, not JSON", out.String())
  337. }
  338. if !strings.Contains(out.String(), "Test Board") {
  339. t.Errorf("stdout = %q, want the board name", out.String())
  340. }
  341. }
  342. // writeDefinition puts a definition file into a directory, for the tests that
  343. // need a board described by one.
  344. func writeDefinition(t *testing.T, dir, name, content string) {
  345. t.Helper()
  346. if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
  347. t.Fatal(err)
  348. }
  349. }