package main import ( "encoding/json" "errors" "net/http" "os" "path/filepath" "strings" "testing" intdevice "netdome.biz/paul/qmk-rgb/internal/device" intvia "netdome.biz/paul/qmk-rgb/internal/via" ) func TestFetchDefinitionStoresTheFileForTheBoard(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) stubPrepareTarget(t, 0x1234, 0x5678) served := `{"name":"Test Board","vendorProductId":305419896,"menus":[]}` var requested []string stubHTTPGet(t, func(url string) ([]byte, int, error) { requested = append(requested, url) if strings.Contains(url, "/v3/") { return []byte(served), http.StatusOK, nil } return nil, http.StatusNotFound, errors.New("404") }) cmd := NewKeyboardFetchCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) if err := cmd.Execute(); err != nil { t.Fatalf("keyboard fetch error = %v (stderr %q)", err, errOut.String()) } if len(requested) != 1 || !strings.Contains(requested[0], "/v3/305419896.json") { t.Errorf("requested = %v, want one v3 URL for vendorProductId 305419896", requested) } if !strings.Contains(out.String(), "Test Board") { t.Errorf("stdout = %q, want the board name", out.String()) } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".json") { t.Fatalf("definitions dir = %v, want one JSON file", entries) } } // A definition in the user directory may be one the user has edited, and nothing // on disk says which it is. So a second fetch must not replace it, and must say // that the file is there rather than write over it silently. func TestFetchDoesNotReplaceAStoredDefinition(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) t.Cleanup(forceFetchRestore(t)) stubPrepareTarget(t, 0x1234, 0x5678) edited := `{"name":"Test Board","vendorProductId":305419896,"menus":[],"note":"hand edited"}` if err := os.WriteFile(filepath.Join(dir, "test_board.json"), []byte(edited), 0o644); err != nil { t.Fatal(err) } // No HTTP stub: a request would panic here, which is the point. The user is // told the file exists without waiting on the network. cmd := NewKeyboardFetchCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) err := cmd.Execute() if err == nil { t.Fatal("keyboard fetch = nil error, want a refusal to replace a stored definition") } if !strings.Contains(err.Error(), "already at") || !strings.Contains(err.Error(), "--force") { t.Errorf("error = %q, want it to name the file and --force", err) } got, readErr := os.ReadFile(filepath.Join(dir, "test_board.json")) if readErr != nil { t.Fatal(readErr) } if string(got) != edited { t.Errorf("stored file = %q, want it untouched (%q)", got, edited) } } // --force is the only way to replace a stored definition, and it has to say that // is what happened, because the file it wrote over is gone. func TestFetchForceReplacesAStoredDefinition(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) t.Cleanup(forceFetchRestore(t)) stubPrepareTarget(t, 0x1234, 0x5678) if err := os.WriteFile(filepath.Join(dir, "test_board.json"), []byte(`{"name":"Test Board","vendorProductId":305419896,"menus":[],"note":"hand edited"}`), 0o644); err != nil { t.Fatal(err) } served := `{"name":"Test Board","vendorProductId":305419896,"menus":[]}` stubHTTPGet(t, func(url string) ([]byte, int, error) { if strings.Contains(url, "/v3/") { return []byte(served), http.StatusOK, nil } return nil, http.StatusNotFound, errors.New("404") }) cmd := NewKeyboardFetchCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) cmd.SetArgs([]string{"--force"}) if err := cmd.Execute(); err != nil { t.Fatalf("keyboard fetch --force error = %v (stderr %q)", err, errOut.String()) } if !strings.Contains(out.String(), "Replaced definition") { t.Errorf("stdout = %q, want it to say the definition was replaced", out.String()) } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Fatalf("definitions dir = %v, want the one file replaced in place", entries) } got, readErr := os.ReadFile(filepath.Join(dir, entries[0].Name())) if readErr != nil { t.Fatal(readErr) } if string(got) != served { t.Errorf("stored file = %q, want what the server served (%q)", got, served) } } // A definition for a different board is not a reason to refuse: the fetch is for // this one, and that one is still worth having alongside. func TestFetchStoresAlongsideAnotherBoardsDefinition(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) t.Cleanup(forceFetchRestore(t)) stubPrepareTarget(t, 0x1234, 0x5678) if err := os.WriteFile(filepath.Join(dir, "other.json"), []byte(`{"name":"Other","vendorId":"0x1111","productId":"0x2222"}`), 0o644); err != nil { t.Fatal(err) } served := `{"name":"Test Board","vendorProductId":305419896,"menus":[]}` stubHTTPGet(t, func(string) ([]byte, int, error) { return []byte(served), http.StatusOK, nil }) cmd := NewKeyboardFetchCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) if err := cmd.Execute(); err != nil { t.Fatalf("keyboard fetch error = %v (stderr %q)", err, errOut.String()) } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Errorf("definitions dir = %v, want the other board's file kept alongside", entries) } } // The vendor issues one vendor and product ID to two models, so a directory can // hold two files describing the same board. --force has to leave one, not two: // which of them is read would otherwise depend on the order the directory comes // back in. func TestFetchForceLeavesOneFileForABoardWithTwo(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) t.Cleanup(forceFetchRestore(t)) stubPrepareTarget(t, 0x320F, 0x5055) for _, name := range []string{"first.json", "second.json"} { body := `{"name":"Twin","vendorId":"0x320F","productId":"0x5055","menus":[]}` if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } served := `{"name":"Twin","vendorProductId":839864405,"menus":[]}` stubHTTPGet(t, func(string) ([]byte, int, error) { return []byte(served), http.StatusOK, nil }) cmd := NewKeyboardFetchCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) cmd.SetArgs([]string{"--force"}) if err := cmd.Execute(); err != nil { t.Fatalf("keyboard fetch --force error = %v (stderr %q)", err, errOut.String()) } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 1 { names := make([]string, 0, len(entries)) for _, e := range entries { names = append(names, e.Name()) } t.Errorf("definitions dir = %v, want exactly one file for the board", names) } } // An unknown board is answered with a web page and a success status, so the // fetch must not store it and must say what happened instead. func TestFetchDefinitionRejectsAPageThatIsNotADefinition(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) stubPrepareTarget(t, 0x1234, 0x5678) stubHTTPGet(t, func(string) ([]byte, int, error) { return []byte("VIA"), http.StatusOK, nil }) cmd := NewKeyboardFetchCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) err := cmd.Execute() if err == nil { t.Fatal("keyboard fetch = nil error, want a failure for a non-definition") } if !strings.Contains(err.Error(), "no definition") { t.Errorf("error = %q, want it to say there is no definition", err) } entries, _ := os.ReadDir(dir) if len(entries) != 0 { t.Errorf("definitions dir = %v, want nothing stored", entries) } } // A definition for another board must not be used, and saying so is the whole // point of carrying the identifiers. func TestDefinitionFlagRejectsTheWrongBoard(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "other.json") if err := os.WriteFile(path, []byte(`{"name":"Other","vendorId":"0x1111","productId":"0x2222"}`), 0o600); err != nil { t.Fatal(err) } t.Cleanup(definitionFlagRestore(t)) definitionFlag = path _, _, err := resolveCatalog(stubTargetData(0x1234, 0x5678)) if err == nil { t.Fatal("resolveCatalog() = nil error, want a rejection for another board") } if !strings.Contains(err.Error(), "0x1111") { t.Errorf("error = %q, want it to name the board the file is for", err) } } // A definition in the data directory is used for its board without a flag. func TestResolveCatalogPrefersTheFileForTheBoard(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{ "name":"From File","vendorId":"0x1234","productId":"0x5678", "menus":[{"label":"Lighting","content":[{"label":"Backlight","content":[ {"label":"Effect","type":"dropdown","content":["id_qmk_rgb_matrix_effect",3,2], "options":[["Only One",4]]}]}]}]}`), 0o600); err != nil { t.Fatal(err) } t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) catalog, source, err := resolveCatalog(stubTargetData(0x1234, 0x5678)) if err != nil { t.Fatalf("resolveCatalog() error = %v", err) } if id, ok := catalog.EffectID(3, "Only One"); !ok || id != 4 { t.Errorf("EffectID(rgb_matrix, \"Only One\") = %d, %t, want 4, true", id, ok) } if !strings.Contains(source, "b.json") { t.Errorf("source = %q, want the file it came from", source) } } // The Impact 80's definition is built into the binary, because `go install` // delivers a binary and no data directory, and this board's file cannot be // fetched. It is still the vendor's file and not a hand-written list, so it is // reported as the file it is and stays overridable. func TestResolveCatalogFallsBackToTheBuiltInDefinition(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) catalog, source, err := resolveCatalog(stubTargetData(0x36B0, 0x309F)) if err != nil { t.Fatalf("resolveCatalog() error = %v", err) } if catalog == nil { t.Fatal("resolveCatalog() = nil, want the built-in definition: go install ships no data directory") } if want := "definitions/impact80.json"; source != want { t.Errorf("source = %q, want %q: it is the shipped file, not a path in an install", source, want) } if id, ok := catalog.EffectID(3, "riverflow"); !ok || id != 45 { t.Errorf("EffectID(3, \"riverflow\") = %d, %t, want 45, true", id, ok) } } // A file in a definitions directory wins over the copy built into the binary, or // the shipped file could never be corrected without a rebuild. This is the // property that keeps the built-in copy from being a second source of a name: // it is consulted last, and only when nothing else has one. func TestDefinitionsDirectoryOverridesTheBuiltInFile(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) replacement := `{"name":"Impact 80","vendorId":"0x36B0","productId":"0x309F", "menus":[{"label":"Lighting","content":[{"label":"Backlight","content":[ {"label":"Effect","type":"dropdown","content":["id_qmk_rgb_matrix_effect",3,2], "options":[["only one",7]]}]}]}]}` if err := os.WriteFile(filepath.Join(dir, "impact80.json"), []byte(replacement), 0o644); err != nil { t.Fatal(err) } catalog, source, err := resolveCatalog(stubTargetData(0x36B0, 0x309F)) if err != nil { t.Fatalf("resolveCatalog() error = %v", err) } if source != filepath.Join(dir, "impact80.json") { t.Errorf("source = %q, want the file in the directory", source) } if id, ok := catalog.EffectID(3, "riverflow"); ok { t.Errorf("EffectID(3, \"riverflow\") = %d, true, want not found: the placed file replaces the built-in one", id) } if id, ok := catalog.EffectID(3, "only one"); !ok || id != 7 { t.Errorf("EffectID(3, \"only one\") = %d, %t, want 7, true", id, ok) } } // An unknown board with no file has no catalog at all, which the commands // already report rather than guessing. func TestResolveCatalogHasNothingForAnUnknownBoard(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) catalog, source, err := resolveCatalog(stubTargetData(0x1111, 0x2222)) if err != nil { t.Fatalf("resolveCatalog() error = %v", err) } if catalog != nil || source != "" { t.Errorf("resolveCatalog() = %v, %q, want nil, \"\"", catalog, source) } } // definitionFlagRestore resets --definition and returns a cleanup that restores // it, so a test can use the flag without leaking it into the next one. func definitionFlagRestore(t *testing.T) func() { t.Helper() original := definitionFlag return func() { definitionFlag = original } } // forceFetchRestore resets --force, so a test that sets it does not leak it into // the next one. func forceFetchRestore(t *testing.T) func() { t.Helper() original := forceFetch return func() { forceFetch = original } } // forceDefinitionsDir points the data directory at a test directory. The tool // looks for it next to the executable and then in the working directory, so a // test that wants its own has to make that lookup find it. func forceDefinitionsDir(t *testing.T, dir string) func() { t.Helper() original := definitionsDirOverride definitionsDirOverride = dir return func() { definitionsDirOverride = original } } // stubHTTPGet answers every request from a function instead of a network. func stubHTTPGet(t *testing.T, fn func(url string) ([]byte, int, error)) { t.Helper() original := httpGet httpGet = fn t.Cleanup(func() { httpGet = original }) } // stubPrepareTarget makes the connected keyboard a fixed one, so a test does // not need hardware to describe a board. func stubPrepareTarget(t *testing.T, vendorID, productID uint16) { t.Helper() original := prepareTarget prepareTarget = func(string) (targetDeviceData, error) { return stubTargetData(vendorID, productID), nil } t.Cleanup(func() { prepareTarget = original }) } // stubTargetData is a target for a board, for the catalog lookup tests. func stubTargetData(vendorID, productID uint16) targetDeviceData { return targetDeviceData{ Device: intdevice.Device{VendorID: vendorID, ProductID: productID}, Display: map[uint16]string{}, } } // keyboard definitions is structured data, so --json has to reach it like every other // listing command; text by default is no excuse for a missing machine shape. The // definitions built into the binary are listed beside the ones in the user // directory, and each carries which of the two it is. func TestDefinitionListHonoursTheJSONFlag(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{"name":"Test Board","vendorId":"0x1234","productId":"0x5678"}`), 0o600); err != nil { t.Fatal(err) } t.Cleanup(forceDefinitionsDir(t, dir)) var out, errOut strings.Builder cmd := NewKeyboardDefinitionsCmd() cmd.SetOut(&out) cmd.SetErr(&errOut) withJSON(t) if err := cmd.Execute(); err != nil { t.Fatalf("keyboard definitions --json error = %v", err) } var payload struct { Definitions []struct { Name string `json:"name"` VendorID string `json:"vendorId"` ProductID string `json:"productId"` Path string `json:"path"` Source string `json:"source"` } `json:"definitions"` } if err := json.Unmarshal([]byte(out.String()), &payload); err != nil { t.Fatalf("output is not JSON: %v (%q)", err, out.String()) } var found *struct { Name string `json:"name"` VendorID string `json:"vendorId"` ProductID string `json:"productId"` Path string `json:"path"` Source string `json:"source"` } for i := range payload.Definitions { if payload.Definitions[i].Name == "Test Board" { found = &payload.Definitions[i] } } if found == nil { t.Fatalf("payload = %+v, want the file in the user directory", payload.Definitions) } if found.VendorID != "0x1234" || found.ProductID != "0x5678" { t.Errorf("identifiers = %s/%s, want 0x1234/0x5678", found.VendorID, found.ProductID) } if found.Source != "user" { t.Errorf("source = %q, want %q: a file in the user directory", found.Source, "user") } } // A board whose definition is built in and whose file is not in the user // directory still appears, marked as built-in. Without it a listing would say the // tool has no definition for the board it is driving. func TestDefinitionListShowsTheBuiltInDefinitions(t *testing.T) { t.Cleanup(forceDefinitionsDir(t, t.TempDir())) var out, errOut strings.Builder cmd := NewKeyboardDefinitionsCmd() cmd.SetOut(&out) cmd.SetErr(&errOut) withJSON(t) if err := cmd.Execute(); err != nil { t.Fatalf("keyboard definitions --json error = %v", err) } var payload struct { Definitions []struct { Name string `json:"name"` Source string `json:"source"` } `json:"definitions"` } if err := json.Unmarshal([]byte(out.String()), &payload); err != nil { t.Fatalf("output is not JSON: %v (%q)", err, out.String()) } if len(payload.Definitions) == 0 { t.Fatal("definitions = none, want the file built into the binary") } for _, d := range payload.Definitions { if d.Source != "built-in" { t.Errorf("%s source = %q, want %q", d.Name, d.Source, "built-in") } } } // A file in the user directory that describes a board the binary also carries // overrides it, and the listing says so, because the two files disagree and only // one of them is read. func TestDefinitionListReportsThatAUserFileShadowsABuiltInOne(t *testing.T) { dir := t.TempDir() replacement := `{"name":"Impact 80","vendorId":"0x36B0","productId":"0x309F"}` if err := os.WriteFile(filepath.Join(dir, "impact80.json"), []byte(replacement), 0o600); err != nil { t.Fatal(err) } t.Cleanup(forceDefinitionsDir(t, dir)) var out, errOut strings.Builder cmd := NewKeyboardDefinitionsCmd() cmd.SetOut(&out) cmd.SetErr(&errOut) if err := cmd.Execute(); err != nil { t.Fatalf("keyboard definitions error = %v", err) } if !strings.Contains(out.String(), "shadows the built-in copy") { t.Errorf("stdout = %q, want it to say the user file overrides the built-in one", out.String()) } } func TestDefinitionListPrintsTextByDefault(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{"name":"Test Board","vendorId":"0x1234","productId":"0x5678"}`), 0o600); err != nil { t.Fatal(err) } t.Cleanup(forceDefinitionsDir(t, dir)) var out, errOut strings.Builder cmd := NewKeyboardDefinitionsCmd() cmd.SetOut(&out) cmd.SetErr(&errOut) if err := cmd.Execute(); err != nil { t.Fatalf("keyboard definitions error = %v", err) } var probe any if err := json.Unmarshal([]byte(out.String()), &probe); err == nil { t.Errorf("stdout = %q, want text, not JSON", out.String()) } if !strings.Contains(out.String(), "Test Board") { t.Errorf("stdout = %q, want the board name", out.String()) } } // writeDefinition puts a definition file into a directory, for the tests that // need a board described by one. func writeDefinition(t *testing.T, dir, name, content string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { t.Fatal(err) } } // A names file is JSON, carries the board's vendor and product ID, and has no // menus — so read as a definition it parses, and the definitions directory is // searched before the files built into the binary. A names file sitting there // would therefore take the board's place and leave the tool with no names at all. // It declares what it is, and that declaration is what this test checks. func TestNamesFileInTheDefinitionsDirectoryIsNotADefinition(t *testing.T) { names := generateSetup(t) body := `{"kind":"names","name":"Impact 80","vendorId":"0x36B0","productId":"0x309F", "channels":{"rgb_matrix":{"7":"my_chevron"}}}` if err := os.WriteFile(filepath.Join(definitionsPath(), "oops.json"), []byte(body), 0o644); err != nil { t.Fatal(err) } _ = names // The board's real definition, built into the binary, still stands. catalog, _, err := resolveCatalogFor(0x36B0, 0x309F) if err != nil { t.Fatalf("resolveCatalogFor() error = %v", err) } if catalog == nil { t.Fatal("resolveCatalogFor() = nil catalog, want the built-in definition") } if got := len(catalog.Effects(intvia.ChannelRgbMatrix)); got != 46 { t.Errorf("effects on rgb_matrix = %d, want the definition's 46", got) } // And the names file is still read for the name it carries, from its own // directory. if err := os.MkdirAll(namesPath(), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(namesPath(), "mine.json"), []byte(body), 0o644); err != nil { t.Fatal(err) } merged, _, err := resolveCatalogFor(0x36B0, 0x309F) if err != nil { t.Fatalf("resolveCatalogFor() error = %v", err) } if got := merged.EffectName(intvia.ChannelRgbMatrix, 7); got != "my_chevron" { t.Errorf("EffectName(7) = %q, want the user's name from the names directory", got) } }