package main
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
intdevice "netdome.biz/paul/qmk-rgb/internal/device"
)
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)
}
}
// 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 }
}
// 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)
}
}