package main import ( "encoding/json" "os" "path/filepath" "strings" "testing" intrgb "netdome.biz/paul/qmk-rgb/internal/rgb" intvia "netdome.biz/paul/qmk-rgb/internal/via" ) // generateStub is a keyboard that answers an effect range, so the generated // scaffold can be checked without hardware. type generateStub struct { tops map[intvia.Channel]int } func (g generateStub) EffectTop(ch intvia.Channel) (int, error) { return g.tops[ch], nil } func (g generateStub) GetValue(intvia.Channel, uint8) ([]byte, error) { return []byte{0}, nil } func (g generateStub) SetValue(intvia.Channel, uint8, uint8) error { return nil } func (g generateStub) SetColor(intvia.Channel, uint8, uint8) error { return nil } func (g generateStub) DetectChannels() ([]intvia.Channel, error) { return nil, nil } func (g generateStub) Close() error { return nil } func stubGenerateTarget(t *testing.T, channels []intvia.Channel, tops map[intvia.Channel]int) { t.Helper() original := openTarget t.Cleanup(func() { openTarget = original }) openTarget = func(string) (rgbProtocol, targetDeviceData, []intvia.Channel, error) { return generateStub{tops: tops}, stubTargetData(0x36B0, 0x309F), channels, nil } } func forceGenerateRestore(t *testing.T) func() { t.Helper() original := generateForce return func() { generateForce = original } } // The whole point of the command: a generated file names no effect, so a board // with a generated definition reports the same "no names" it reported before one // existed. A scaffold that claimed names would be a guess the tool could not // read back off the keyboard. func TestGeneratedDefinitionNamesNoEffectUntilTheUserFillsItIn(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) t.Cleanup(forceGenerateRestore(t)) stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}) cmd := NewKeyboardDefinitionsGenerateCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) if err := cmd.Execute(); err != nil { t.Fatalf("definitions generate error = %v (stderr %q)", err, errOut.String()) } path := onlyDefinition(t, dir) def, err := intrgb.ParseDefinition(path, []byte(mustRead(t, path))) if err != nil { t.Fatalf("ParseDefinition(%s) error = %v; the generated file has to load", path, err) } if got := len(def.Catalog.Effects(intvia.ChannelRgbMatrix)); got != 0 { t.Errorf("effects on rgb_matrix = %d, want 0 until the names are written in", got) } // The slots are still there, which is the other half: the file states which // IDs exist and leaves the naming to the user. var file struct { Menus []struct { Content []struct { Label string `json:"label"` Type string `json:"type"` Content []any `json:"content"` Options []any `json:"options"` } `json:"content"` } `json:"menus"` } if err := json.Unmarshal([]byte(mustRead(t, path)), &file); err != nil { t.Fatalf("unmarshal generated file: %v", err) } var options []any for _, menu := range file.Menus { for _, entry := range menu.Content { if entry.Type == "dropdown" { options = entry.Options } } } if len(options) != 46 { t.Fatalf("effect options = %d, want 46, one per ID from 0 to 45", len(options)) } for i, option := range options { pair, ok := option.([]any) if !ok || len(pair) != 2 { t.Fatalf("option %d = %v, want a name and a number", i, option) } if name, _ := pair[0].(string); name != "" { t.Errorf("option %d is named %q, want a slot with no name", i, name) } if number, _ := pair[1].(float64); int(number) != i { t.Errorf("option %d carries ID %v, want the slot numbered %d", i, pair[1], i) } } // Every control has to be addressed by a value key the parser recognises. A // key built by trimming a suffix and appending without the underscore is // `id_qmk_rgb_matrixbrightness`, which names nothing, and nothing in the // output above would say so. wantKeys := map[string]bool{ "id_qmk_rgb_matrix_brightness": false, "id_qmk_rgb_matrix_effect": false, "id_qmk_rgb_matrix_effect_speed": false, } for _, menu := range file.Menus { for _, entry := range menu.Content { key, _ := entry.Content[0].(string) if _, ok := wantKeys[key]; !ok { t.Errorf("control %q is addressed by %q, which is not a VIA value key", entry.Label, key) continue } wantKeys[key] = true } } for key, found := range wantKeys { if !found { t.Errorf("no control addresses %q", key) } } } // A definition a user has written is the one command they would have run to write // it, so it must survive a second run of the command. func TestGenerateDoesNotReplaceAStoredDefinition(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) t.Cleanup(forceGenerateRestore(t)) stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}) written := `{"name":"Test Board","vendorId":"0x36B0","productId":"0x309F","menus":[],"note":"hand written"}` if err := os.WriteFile(filepath.Join(dir, "test_board.json"), []byte(written), 0o644); err != nil { t.Fatal(err) } cmd := NewKeyboardDefinitionsGenerateCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) err := cmd.Execute() if err == nil { t.Fatal("definitions generate = nil error, want a refusal to replace a stored definition") } if !strings.Contains(err.Error(), "--force") { t.Errorf("error = %q, want it to offer --force", err) } if got := mustRead(t, filepath.Join(dir, "test_board.json")); got != written { t.Errorf("stored file = %q, want it untouched (%q)", got, written) } } // The note beside the file is what carries the names, because a JSON file cannot // hold them: the tool would have to read comments and VIA's parser would reject // them. It has to say who wrote what, or a name is a guess wearing a count. func TestGeneratedNoteCarriesTheSpellingsAndWhoWroteThem(t *testing.T) { dir := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, dir)) t.Cleanup(forceGenerateRestore(t)) stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}) cmd := NewKeyboardDefinitionsGenerateCmd() var out, errOut strings.Builder cmd.SetOut(&out) cmd.SetErr(&errOut) if err := cmd.Execute(); err != nil { t.Fatalf("definitions generate error = %v", err) } note := mustRead(t, strings.TrimSuffix(onlyDefinition(t, dir), ".json")+spottedNoteSuffix) if !strings.Contains(note, "rgb_matrix (channel 3), IDs 0 to 45") { t.Errorf("note = %q, want it to name the channel and the ID range", note) } // The measurement behind the names: a spelling, how many boards wrote it, and // which manufacturer most of them were. if !strings.Contains(note, "rainbow_moving_chevron") { t.Errorf("note = %q, want the spellings other definitions use", note) } if !strings.Contains(note, "boards:") { t.Errorf("note = %q, want a count of boards next to every name", note) } if !strings.Contains(note, "keychron") { t.Errorf("note = %q, want the manufacturer behind most of a name's spellings", note) } // Effect 23 is where the collection disagrees about what the number even is, // so the note has to show the runner-up rather than pick a winner. if !strings.Contains(note, "ID 23 ") { t.Errorf("note = %q, want a line for ID 23", note) } } func onlyDefinition(t *testing.T, dir string) string { t.Helper() matches, err := filepath.Glob(filepath.Join(dir, "*.json")) if err != nil { t.Fatal(err) } if len(matches) != 1 { t.Fatalf("definitions dir = %v, want one JSON file", matches) } return matches[0] } func mustRead(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(data) }