definition_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  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. // A definition in the user directory may be one the user has edited, and nothing
  48. // on disk says which it is. So a second fetch must not replace it, and must say
  49. // that the file is there rather than write over it silently.
  50. func TestFetchDoesNotReplaceAStoredDefinition(t *testing.T) {
  51. dir := t.TempDir()
  52. t.Cleanup(definitionFlagRestore(t))
  53. t.Cleanup(forceDefinitionsDir(t, dir))
  54. t.Cleanup(forceFetchRestore(t))
  55. stubPrepareTarget(t, 0x1234, 0x5678)
  56. edited := `{"name":"Test Board","vendorProductId":305419896,"menus":[],"note":"hand edited"}`
  57. if err := os.WriteFile(filepath.Join(dir, "test_board.json"), []byte(edited), 0o644); err != nil {
  58. t.Fatal(err)
  59. }
  60. // No HTTP stub: a request would panic here, which is the point. The user is
  61. // told the file exists without waiting on the network.
  62. cmd := NewKeyboardFetchCmd()
  63. var out, errOut strings.Builder
  64. cmd.SetOut(&out)
  65. cmd.SetErr(&errOut)
  66. err := cmd.Execute()
  67. if err == nil {
  68. t.Fatal("keyboard fetch = nil error, want a refusal to replace a stored definition")
  69. }
  70. if !strings.Contains(err.Error(), "already at") || !strings.Contains(err.Error(), "--force") {
  71. t.Errorf("error = %q, want it to name the file and --force", err)
  72. }
  73. got, readErr := os.ReadFile(filepath.Join(dir, "test_board.json"))
  74. if readErr != nil {
  75. t.Fatal(readErr)
  76. }
  77. if string(got) != edited {
  78. t.Errorf("stored file = %q, want it untouched (%q)", got, edited)
  79. }
  80. }
  81. // --force is the only way to replace a stored definition, and it has to say that
  82. // is what happened, because the file it wrote over is gone.
  83. func TestFetchForceReplacesAStoredDefinition(t *testing.T) {
  84. dir := t.TempDir()
  85. t.Cleanup(definitionFlagRestore(t))
  86. t.Cleanup(forceDefinitionsDir(t, dir))
  87. t.Cleanup(forceFetchRestore(t))
  88. stubPrepareTarget(t, 0x1234, 0x5678)
  89. if err := os.WriteFile(filepath.Join(dir, "test_board.json"),
  90. []byte(`{"name":"Test Board","vendorProductId":305419896,"menus":[],"note":"hand edited"}`), 0o644); err != nil {
  91. t.Fatal(err)
  92. }
  93. served := `{"name":"Test Board","vendorProductId":305419896,"menus":[]}`
  94. stubHTTPGet(t, func(url string) ([]byte, int, error) {
  95. if strings.Contains(url, "/v3/") {
  96. return []byte(served), http.StatusOK, nil
  97. }
  98. return nil, http.StatusNotFound, errors.New("404")
  99. })
  100. cmd := NewKeyboardFetchCmd()
  101. var out, errOut strings.Builder
  102. cmd.SetOut(&out)
  103. cmd.SetErr(&errOut)
  104. cmd.SetArgs([]string{"--force"})
  105. if err := cmd.Execute(); err != nil {
  106. t.Fatalf("keyboard fetch --force error = %v (stderr %q)", err, errOut.String())
  107. }
  108. if !strings.Contains(out.String(), "Replaced definition") {
  109. t.Errorf("stdout = %q, want it to say the definition was replaced", out.String())
  110. }
  111. entries, err := os.ReadDir(dir)
  112. if err != nil {
  113. t.Fatal(err)
  114. }
  115. if len(entries) != 1 {
  116. t.Fatalf("definitions dir = %v, want the one file replaced in place", entries)
  117. }
  118. got, readErr := os.ReadFile(filepath.Join(dir, entries[0].Name()))
  119. if readErr != nil {
  120. t.Fatal(readErr)
  121. }
  122. if string(got) != served {
  123. t.Errorf("stored file = %q, want what the server served (%q)", got, served)
  124. }
  125. }
  126. // A definition for a different board is not a reason to refuse: the fetch is for
  127. // this one, and that one is still worth having alongside.
  128. func TestFetchStoresAlongsideAnotherBoardsDefinition(t *testing.T) {
  129. dir := t.TempDir()
  130. t.Cleanup(definitionFlagRestore(t))
  131. t.Cleanup(forceDefinitionsDir(t, dir))
  132. t.Cleanup(forceFetchRestore(t))
  133. stubPrepareTarget(t, 0x1234, 0x5678)
  134. if err := os.WriteFile(filepath.Join(dir, "other.json"),
  135. []byte(`{"name":"Other","vendorId":"0x1111","productId":"0x2222"}`), 0o644); err != nil {
  136. t.Fatal(err)
  137. }
  138. served := `{"name":"Test Board","vendorProductId":305419896,"menus":[]}`
  139. stubHTTPGet(t, func(string) ([]byte, int, error) {
  140. return []byte(served), http.StatusOK, nil
  141. })
  142. cmd := NewKeyboardFetchCmd()
  143. var out, errOut strings.Builder
  144. cmd.SetOut(&out)
  145. cmd.SetErr(&errOut)
  146. if err := cmd.Execute(); err != nil {
  147. t.Fatalf("keyboard fetch error = %v (stderr %q)", err, errOut.String())
  148. }
  149. entries, err := os.ReadDir(dir)
  150. if err != nil {
  151. t.Fatal(err)
  152. }
  153. if len(entries) != 2 {
  154. t.Errorf("definitions dir = %v, want the other board's file kept alongside", entries)
  155. }
  156. }
  157. // The vendor issues one vendor and product ID to two models, so a directory can
  158. // hold two files describing the same board. --force has to leave one, not two:
  159. // which of them is read would otherwise depend on the order the directory comes
  160. // back in.
  161. func TestFetchForceLeavesOneFileForABoardWithTwo(t *testing.T) {
  162. dir := t.TempDir()
  163. t.Cleanup(definitionFlagRestore(t))
  164. t.Cleanup(forceDefinitionsDir(t, dir))
  165. t.Cleanup(forceFetchRestore(t))
  166. stubPrepareTarget(t, 0x320F, 0x5055)
  167. for _, name := range []string{"first.json", "second.json"} {
  168. body := `{"name":"Twin","vendorId":"0x320F","productId":"0x5055","menus":[]}`
  169. if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
  170. t.Fatal(err)
  171. }
  172. }
  173. served := `{"name":"Twin","vendorProductId":839864405,"menus":[]}`
  174. stubHTTPGet(t, func(string) ([]byte, int, error) {
  175. return []byte(served), http.StatusOK, nil
  176. })
  177. cmd := NewKeyboardFetchCmd()
  178. var out, errOut strings.Builder
  179. cmd.SetOut(&out)
  180. cmd.SetErr(&errOut)
  181. cmd.SetArgs([]string{"--force"})
  182. if err := cmd.Execute(); err != nil {
  183. t.Fatalf("keyboard fetch --force error = %v (stderr %q)", err, errOut.String())
  184. }
  185. entries, err := os.ReadDir(dir)
  186. if err != nil {
  187. t.Fatal(err)
  188. }
  189. if len(entries) != 1 {
  190. names := make([]string, 0, len(entries))
  191. for _, e := range entries {
  192. names = append(names, e.Name())
  193. }
  194. t.Errorf("definitions dir = %v, want exactly one file for the board", names)
  195. }
  196. }
  197. // An unknown board is answered with a web page and a success status, so the
  198. // fetch must not store it and must say what happened instead.
  199. func TestFetchDefinitionRejectsAPageThatIsNotADefinition(t *testing.T) {
  200. dir := t.TempDir()
  201. t.Cleanup(definitionFlagRestore(t))
  202. t.Cleanup(forceDefinitionsDir(t, dir))
  203. stubPrepareTarget(t, 0x1234, 0x5678)
  204. stubHTTPGet(t, func(string) ([]byte, int, error) {
  205. return []byte("<!doctype html><html><head><title>VIA</title></head></html>"), http.StatusOK, nil
  206. })
  207. cmd := NewKeyboardFetchCmd()
  208. var out, errOut strings.Builder
  209. cmd.SetOut(&out)
  210. cmd.SetErr(&errOut)
  211. err := cmd.Execute()
  212. if err == nil {
  213. t.Fatal("keyboard fetch = nil error, want a failure for a non-definition")
  214. }
  215. if !strings.Contains(err.Error(), "no definition") {
  216. t.Errorf("error = %q, want it to say there is no definition", err)
  217. }
  218. entries, _ := os.ReadDir(dir)
  219. if len(entries) != 0 {
  220. t.Errorf("definitions dir = %v, want nothing stored", entries)
  221. }
  222. }
  223. // A definition for another board must not be used, and saying so is the whole
  224. // point of carrying the identifiers.
  225. func TestDefinitionFlagRejectsTheWrongBoard(t *testing.T) {
  226. dir := t.TempDir()
  227. path := filepath.Join(dir, "other.json")
  228. if err := os.WriteFile(path, []byte(`{"name":"Other","vendorId":"0x1111","productId":"0x2222"}`), 0o600); err != nil {
  229. t.Fatal(err)
  230. }
  231. t.Cleanup(definitionFlagRestore(t))
  232. definitionFlag = path
  233. _, _, err := resolveCatalog(stubTargetData(0x1234, 0x5678))
  234. if err == nil {
  235. t.Fatal("resolveCatalog() = nil error, want a rejection for another board")
  236. }
  237. if !strings.Contains(err.Error(), "0x1111") {
  238. t.Errorf("error = %q, want it to name the board the file is for", err)
  239. }
  240. }
  241. // A definition in the data directory is used for its board without a flag.
  242. func TestResolveCatalogPrefersTheFileForTheBoard(t *testing.T) {
  243. dir := t.TempDir()
  244. if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{
  245. "name":"From File","vendorId":"0x1234","productId":"0x5678",
  246. "menus":[{"label":"Lighting","content":[{"label":"Backlight","content":[
  247. {"label":"Effect","type":"dropdown","content":["id_qmk_rgb_matrix_effect",3,2],
  248. "options":[["Only One",4]]}]}]}]}`), 0o600); err != nil {
  249. t.Fatal(err)
  250. }
  251. t.Cleanup(definitionFlagRestore(t))
  252. t.Cleanup(forceDefinitionsDir(t, dir))
  253. catalog, source, err := resolveCatalog(stubTargetData(0x1234, 0x5678))
  254. if err != nil {
  255. t.Fatalf("resolveCatalog() error = %v", err)
  256. }
  257. if id, ok := catalog.EffectID(3, "Only One"); !ok || id != 4 {
  258. t.Errorf("EffectID(rgb_matrix, \"Only One\") = %d, %t, want 4, true", id, ok)
  259. }
  260. if !strings.Contains(source, "b.json") {
  261. t.Errorf("source = %q, want the file it came from", source)
  262. }
  263. }
  264. // The Impact 80's definition is built into the binary, because `go install`
  265. // delivers a binary and no data directory, and this board's file cannot be
  266. // fetched. It is still the vendor's file and not a hand-written list, so it is
  267. // reported as the file it is and stays overridable.
  268. func TestResolveCatalogFallsBackToTheBuiltInDefinition(t *testing.T) {
  269. dir := t.TempDir()
  270. t.Cleanup(definitionFlagRestore(t))
  271. t.Cleanup(forceDefinitionsDir(t, dir))
  272. catalog, source, err := resolveCatalog(stubTargetData(0x36B0, 0x309F))
  273. if err != nil {
  274. t.Fatalf("resolveCatalog() error = %v", err)
  275. }
  276. if catalog == nil {
  277. t.Fatal("resolveCatalog() = nil, want the built-in definition: go install ships no data directory")
  278. }
  279. if want := "definitions/impact80.json"; source != want {
  280. t.Errorf("source = %q, want %q: it is the shipped file, not a path in an install", source, want)
  281. }
  282. if id, ok := catalog.EffectID(3, "riverflow"); !ok || id != 45 {
  283. t.Errorf("EffectID(3, \"riverflow\") = %d, %t, want 45, true", id, ok)
  284. }
  285. }
  286. // A file in a definitions directory wins over the copy built into the binary, or
  287. // the shipped file could never be corrected without a rebuild. This is the
  288. // property that keeps the built-in copy from being a second source of a name:
  289. // it is consulted last, and only when nothing else has one.
  290. func TestDefinitionsDirectoryOverridesTheBuiltInFile(t *testing.T) {
  291. dir := t.TempDir()
  292. t.Cleanup(definitionFlagRestore(t))
  293. t.Cleanup(forceDefinitionsDir(t, dir))
  294. replacement := `{"name":"Impact 80","vendorId":"0x36B0","productId":"0x309F",
  295. "menus":[{"label":"Lighting","content":[{"label":"Backlight","content":[
  296. {"label":"Effect","type":"dropdown","content":["id_qmk_rgb_matrix_effect",3,2],
  297. "options":[["only one",7]]}]}]}]}`
  298. if err := os.WriteFile(filepath.Join(dir, "impact80.json"), []byte(replacement), 0o644); err != nil {
  299. t.Fatal(err)
  300. }
  301. catalog, source, err := resolveCatalog(stubTargetData(0x36B0, 0x309F))
  302. if err != nil {
  303. t.Fatalf("resolveCatalog() error = %v", err)
  304. }
  305. if source != filepath.Join(dir, "impact80.json") {
  306. t.Errorf("source = %q, want the file in the directory", source)
  307. }
  308. if id, ok := catalog.EffectID(3, "riverflow"); ok {
  309. t.Errorf("EffectID(3, \"riverflow\") = %d, true, want not found: the placed file replaces the built-in one", id)
  310. }
  311. if id, ok := catalog.EffectID(3, "only one"); !ok || id != 7 {
  312. t.Errorf("EffectID(3, \"only one\") = %d, %t, want 7, true", id, ok)
  313. }
  314. }
  315. // An unknown board with no file has no catalog at all, which the commands
  316. // already report rather than guessing.
  317. func TestResolveCatalogHasNothingForAnUnknownBoard(t *testing.T) {
  318. dir := t.TempDir()
  319. t.Cleanup(definitionFlagRestore(t))
  320. t.Cleanup(forceDefinitionsDir(t, dir))
  321. catalog, source, err := resolveCatalog(stubTargetData(0x1111, 0x2222))
  322. if err != nil {
  323. t.Fatalf("resolveCatalog() error = %v", err)
  324. }
  325. if catalog != nil || source != "" {
  326. t.Errorf("resolveCatalog() = %v, %q, want nil, \"\"", catalog, source)
  327. }
  328. }
  329. // definitionFlagRestore resets --definition and returns a cleanup that restores
  330. // it, so a test can use the flag without leaking it into the next one.
  331. func definitionFlagRestore(t *testing.T) func() {
  332. t.Helper()
  333. original := definitionFlag
  334. return func() { definitionFlag = original }
  335. }
  336. // forceFetchRestore resets --force, so a test that sets it does not leak it into
  337. // the next one.
  338. func forceFetchRestore(t *testing.T) func() {
  339. t.Helper()
  340. original := forceFetch
  341. return func() { forceFetch = original }
  342. }
  343. // forceDefinitionsDir points the data directory at a test directory. The tool
  344. // looks for it next to the executable and then in the working directory, so a
  345. // test that wants its own has to make that lookup find it.
  346. func forceDefinitionsDir(t *testing.T, dir string) func() {
  347. t.Helper()
  348. original := definitionsDirOverride
  349. definitionsDirOverride = dir
  350. return func() { definitionsDirOverride = original }
  351. }
  352. // stubHTTPGet answers every request from a function instead of a network.
  353. func stubHTTPGet(t *testing.T, fn func(url string) ([]byte, int, error)) {
  354. t.Helper()
  355. original := httpGet
  356. httpGet = fn
  357. t.Cleanup(func() { httpGet = original })
  358. }
  359. // stubPrepareTarget makes the connected keyboard a fixed one, so a test does
  360. // not need hardware to describe a board.
  361. func stubPrepareTarget(t *testing.T, vendorID, productID uint16) {
  362. t.Helper()
  363. original := prepareTarget
  364. prepareTarget = func(string) (targetDeviceData, error) { return stubTargetData(vendorID, productID), nil }
  365. t.Cleanup(func() { prepareTarget = original })
  366. }
  367. // stubTargetData is a target for a board, for the catalog lookup tests.
  368. func stubTargetData(vendorID, productID uint16) targetDeviceData {
  369. return targetDeviceData{
  370. Device: intdevice.Device{VendorID: vendorID, ProductID: productID},
  371. Display: map[uint16]string{},
  372. }
  373. }
  374. // keyboard definitions is structured data, so --json has to reach it like every other
  375. // listing command; text by default is no excuse for a missing machine shape. The
  376. // definitions built into the binary are listed beside the ones in the user
  377. // directory, and each carries which of the two it is.
  378. func TestDefinitionListHonoursTheJSONFlag(t *testing.T) {
  379. dir := t.TempDir()
  380. if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{"name":"Test Board","vendorId":"0x1234","productId":"0x5678"}`), 0o600); err != nil {
  381. t.Fatal(err)
  382. }
  383. t.Cleanup(forceDefinitionsDir(t, dir))
  384. var out, errOut strings.Builder
  385. cmd := NewKeyboardDefinitionsCmd()
  386. cmd.SetOut(&out)
  387. cmd.SetErr(&errOut)
  388. withJSON(t)
  389. if err := cmd.Execute(); err != nil {
  390. t.Fatalf("keyboard definitions --json error = %v", err)
  391. }
  392. var payload struct {
  393. Definitions []struct {
  394. Name string `json:"name"`
  395. VendorID string `json:"vendorId"`
  396. ProductID string `json:"productId"`
  397. Path string `json:"path"`
  398. Source string `json:"source"`
  399. } `json:"definitions"`
  400. }
  401. if err := json.Unmarshal([]byte(out.String()), &payload); err != nil {
  402. t.Fatalf("output is not JSON: %v (%q)", err, out.String())
  403. }
  404. var found *struct {
  405. Name string `json:"name"`
  406. VendorID string `json:"vendorId"`
  407. ProductID string `json:"productId"`
  408. Path string `json:"path"`
  409. Source string `json:"source"`
  410. }
  411. for i := range payload.Definitions {
  412. if payload.Definitions[i].Name == "Test Board" {
  413. found = &payload.Definitions[i]
  414. }
  415. }
  416. if found == nil {
  417. t.Fatalf("payload = %+v, want the file in the user directory", payload.Definitions)
  418. }
  419. if found.VendorID != "0x1234" || found.ProductID != "0x5678" {
  420. t.Errorf("identifiers = %s/%s, want 0x1234/0x5678", found.VendorID, found.ProductID)
  421. }
  422. if found.Source != "user" {
  423. t.Errorf("source = %q, want %q: a file in the user directory", found.Source, "user")
  424. }
  425. }
  426. // A board whose definition is built in and whose file is not in the user
  427. // directory still appears, marked as built-in. Without it a listing would say the
  428. // tool has no definition for the board it is driving.
  429. func TestDefinitionListShowsTheBuiltInDefinitions(t *testing.T) {
  430. t.Cleanup(forceDefinitionsDir(t, t.TempDir()))
  431. var out, errOut strings.Builder
  432. cmd := NewKeyboardDefinitionsCmd()
  433. cmd.SetOut(&out)
  434. cmd.SetErr(&errOut)
  435. withJSON(t)
  436. if err := cmd.Execute(); err != nil {
  437. t.Fatalf("keyboard definitions --json error = %v", err)
  438. }
  439. var payload struct {
  440. Definitions []struct {
  441. Name string `json:"name"`
  442. Source string `json:"source"`
  443. } `json:"definitions"`
  444. }
  445. if err := json.Unmarshal([]byte(out.String()), &payload); err != nil {
  446. t.Fatalf("output is not JSON: %v (%q)", err, out.String())
  447. }
  448. if len(payload.Definitions) == 0 {
  449. t.Fatal("definitions = none, want the file built into the binary")
  450. }
  451. for _, d := range payload.Definitions {
  452. if d.Source != "built-in" {
  453. t.Errorf("%s source = %q, want %q", d.Name, d.Source, "built-in")
  454. }
  455. }
  456. }
  457. // A file in the user directory that describes a board the binary also carries
  458. // overrides it, and the listing says so, because the two files disagree and only
  459. // one of them is read.
  460. func TestDefinitionListReportsThatAUserFileShadowsABuiltInOne(t *testing.T) {
  461. dir := t.TempDir()
  462. replacement := `{"name":"Impact 80","vendorId":"0x36B0","productId":"0x309F"}`
  463. if err := os.WriteFile(filepath.Join(dir, "impact80.json"), []byte(replacement), 0o600); err != nil {
  464. t.Fatal(err)
  465. }
  466. t.Cleanup(forceDefinitionsDir(t, dir))
  467. var out, errOut strings.Builder
  468. cmd := NewKeyboardDefinitionsCmd()
  469. cmd.SetOut(&out)
  470. cmd.SetErr(&errOut)
  471. if err := cmd.Execute(); err != nil {
  472. t.Fatalf("keyboard definitions error = %v", err)
  473. }
  474. if !strings.Contains(out.String(), "shadows the built-in copy") {
  475. t.Errorf("stdout = %q, want it to say the user file overrides the built-in one", out.String())
  476. }
  477. }
  478. func TestDefinitionListPrintsTextByDefault(t *testing.T) {
  479. dir := t.TempDir()
  480. if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{"name":"Test Board","vendorId":"0x1234","productId":"0x5678"}`), 0o600); err != nil {
  481. t.Fatal(err)
  482. }
  483. t.Cleanup(forceDefinitionsDir(t, dir))
  484. var out, errOut strings.Builder
  485. cmd := NewKeyboardDefinitionsCmd()
  486. cmd.SetOut(&out)
  487. cmd.SetErr(&errOut)
  488. if err := cmd.Execute(); err != nil {
  489. t.Fatalf("keyboard definitions error = %v", err)
  490. }
  491. var probe any
  492. if err := json.Unmarshal([]byte(out.String()), &probe); err == nil {
  493. t.Errorf("stdout = %q, want text, not JSON", out.String())
  494. }
  495. if !strings.Contains(out.String(), "Test Board") {
  496. t.Errorf("stdout = %q, want the board name", out.String())
  497. }
  498. }
  499. // writeDefinition puts a definition file into a directory, for the tests that
  500. // need a board described by one.
  501. func writeDefinition(t *testing.T, dir, name, content string) {
  502. t.Helper()
  503. if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
  504. t.Fatal(err)
  505. }
  506. }