package main import ( "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 a generated names // file can be checked without hardware. The Vial fields are what a keyboard // running Vial firmware would answer; a zero version reads as one that is not // Vial. type generateStub struct { tops map[intvia.Channel]int vial bool vialIDs []uint16 } func (g generateStub) EffectTop(ch intvia.Channel) (int, error) { return g.tops[ch], nil } func (g generateStub) VialVersion() (uint32, bool, error) { if !g.vial { return 0, false, nil } return 6, true, nil } func (g generateStub) VialEffectIDs() ([]uint16, error) { return g.vialIDs, 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 } // stubGenerateTarget makes the connected keyboard a fixed one. The identifiers are // a parameter because a board the binary carries a definition for is a different // case from one it does not, and a stub for the wrong board would test nothing. func stubGenerateTarget(t *testing.T, vendorID, productID uint16, channels []intvia.Channel, stub generateStub) { t.Helper() original := openTarget t.Cleanup(func() { openTarget = original }) openTarget = func(string) (rgbProtocol, targetDeviceData, []intvia.Channel, error) { return stub, stubTargetData(vendorID, productID), channels, nil } } func forceGenerateRestore(t *testing.T) func() { t.Helper() original := generateForce return func() { generateForce = original } } // forceNamesDir points the names directory at a test directory. Without it a test // that writes a names file writes into the user's real data directory, which is // what happened when this override did not exist yet. func forceNamesDir(t *testing.T, dir string) func() { t.Helper() original := namesDirOverride namesDirOverride = dir return func() { namesDirOverride = original } } // generateSetup points every writable directory at one temporary directory, so a // test cannot reach the user's. // generateSetup points the writable directories at temporary ones and returns the // names directory. They are two directories on purpose: a names file in the // definitions directory parses as a definition — it carries the board's // identifiers and no menus — and takes the board's place, which is exactly what // TestNamesFileInTheDefinitionsDirectoryIsNotADefinition is about. func generateSetup(t *testing.T) string { t.Helper() names := t.TempDir() t.Cleanup(definitionFlagRestore(t)) t.Cleanup(forceDefinitionsDir(t, t.TempDir())) t.Cleanup(forceNamesDir(t, names)) t.Cleanup(forceGenerateRestore(t)) return names } func runGenerate(t *testing.T) (string, string) { t.Helper() 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()) } return out.String(), errOut.String() } func onlyNamesFile(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("names dir = %v, want one 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) } // A generated file names no effect, so a board reports the same "no names" it // reported before one existed. A name the tool wrote would be indistinguishable // from the manufacturer's, and nothing in a file can be read back off a keyboard // to check it. func TestGeneratedNamesFileNamesNoEffectUntilTheUserFillsItIn(t *testing.T) { dir := generateSetup(t) stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix}, generateStub{tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}}) runGenerate(t) path := onlyNamesFile(t, dir) names, err := intrgbLoadNamesFile(path) if err != nil { t.Fatalf("LoadNamesFile(%s) error = %v; the generated file has to load", path, err) } if got := len(names.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 says which IDs // the board has and leaves the naming to the user. body := mustRead(t, path) for _, want := range []string{`"0": ""`, `"7": ""`, `"45": ""`} { if !strings.Contains(body, want) { t.Errorf("generated file has no empty slot %s", want) } } // And it is one line per effect, not a nested menu description to dig through. if lines := strings.Count(body, "\n"); lines > 60 { t.Errorf("generated file is %d lines, want roughly one per effect", lines) } } // A names file is the one the user would have run the command to write, so a // second run must not replace it. func TestGenerateDoesNotReplaceAStoredNamesFile(t *testing.T) { dir := generateSetup(t) stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix}, generateStub{tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}}) written := `{"vendorId":"0x1234","productId":"0x5678","channels":{"rgb_matrix":{"7":"mine"}}}` if err := os.WriteFile(filepath.Join(dir, "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 names file") } if !strings.Contains(err.Error(), "--force") { t.Errorf("error = %q, want it to offer --force", err) } if got := mustRead(t, filepath.Join(dir, "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: // 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 := generateSetup(t) stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix}, generateStub{tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}}) runGenerate(t) note := mustRead(t, strings.TrimSuffix(onlyNamesFile(t, dir), ".json")+spottedNoteSuffix) for _, want := range []string{ "rgb_matrix (channel 3)", "rainbow_moving_chevron", "boards:", "keychron", string(slotsMeasured), } { if !strings.Contains(note, want) { t.Errorf("note does not carry %q", want) } } } // A Vial keyboard lists its own rgb_matrix effect IDs, and the tool must use those // rather than writing above the top: the list is the firmware answering without // being touched. func TestGenerateUsesVialsOwnListWhenTheFirmwareIsVial(t *testing.T) { dir := generateSetup(t) stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix, intvia.ChannelRgblight}, generateStub{ tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45, intvia.ChannelRgblight: 6}, vial: true, vialIDs: []uint16{0, 1, 2, 40, 45}, }) out, _ := runGenerate(t) if !strings.Contains(out, "Vial firmware") { t.Errorf("stdout = %q, want it to say the keyboard is Vial", out) } if !strings.Contains(out, experimentalVial) { t.Errorf("stdout = %q, want the Vial path marked %s", out, experimentalVial) } // rgb_matrix keeps the firmware's own numbering, gaps and all; rgblight has no // Vial list and falls back to the clamp. if !strings.Contains(out, "0, 1, 2, 40, 45") { t.Errorf("stdout = %q, want the rgb_matrix slots in the firmware's numbering", out) } if !strings.Contains(out, string(slotsReported)) || !strings.Contains(out, string(slotsMeasured)) { t.Errorf("stdout = %q, want both sources named per channel", out) } // The note must not put QMK's spellings next to numbers that are not QMK's. note := mustRead(t, strings.TrimSuffix(onlyNamesFile(t, dir), ".json")+spottedNoteSuffix) if strings.Contains(note, "rainbow_moving_chevron") { t.Errorf("note = %q, want no QMK spellings beside a Vial numbering", note) } if !strings.Contains(note, "VIALRGB_EFFECT_*") { t.Errorf("note = %q, want it to say the numbering is the firmware's own", note) } } // A Vial keyboard built without VIALRGB_ENABLE answers nothing, and a board that // cannot list its effects can still be asked by writing above the top. func TestGenerateFallsBackToTheClampWhenVialListsNothing(t *testing.T) { dir := generateSetup(t) stubGenerateTarget(t, 0x1234, 0x5678, []intvia.Channel{intvia.ChannelRgbMatrix}, generateStub{tops: map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45}, vial: true}) out, _ := runGenerate(t) if !strings.Contains(out, string(slotsMeasured)) { t.Errorf("stdout = %q, want the clamp used when Vial lists nothing", out) } note := mustRead(t, strings.TrimSuffix(onlyNamesFile(t, dir), ".json")+spottedNoteSuffix) if !strings.Contains(note, "rainbow_moving_chevron") { t.Errorf("note = %q, want the QMK spellings again, which is what the clamp gives", note) } } // The point of storing names apart from the definition: a name the user writes // replaces the vendor's for that one effect, and the rest of the file still stands. // The board is the Impact 80, whose definition the binary carries, so "the rest" // means real names the vendor wrote. func TestANameTheUserWroteOverridesOneEffectAndLeavesTheRest(t *testing.T) { generateSetup(t) names := t.TempDir() t.Cleanup(forceNamesDir(t, names)) body := `{"name":"Impact 80","vendorId":"0x36B0","productId":"0x309F", "channels":{"rgb_matrix":{"7":"my_chevron"}}}` if err := os.WriteFile(filepath.Join(names, "impact80.json"), []byte(body), 0o644); err != nil { t.Fatal(err) } catalog, _, err := resolveCatalogFor(0x36B0, 0x309F) if err != nil { t.Fatalf("resolveCatalogFor() error = %v", err) } if got := catalog.EffectName(intvia.ChannelRgbMatrix, 7); got != "my_chevron" { t.Errorf("EffectName(7) = %q, want the user's name", got) } if got := catalog.EffectSource(intvia.ChannelRgbMatrix, 7); got != intrgbSourceUser { t.Errorf("EffectSource(7) = %q, want %q", got, intrgbSourceUser) } // ID 8 is band_pinwheel_sat in the Impact 80's own file, and the user's file // said nothing about it, so it has to still be the vendor's name and carry no // source of its own. if got := catalog.EffectName(intvia.ChannelRgbMatrix, 8); got != "band_pinwheel_sat" { t.Errorf("EffectName(8) = %q, want the vendor's name left alone", got) } if got := catalog.EffectSource(intvia.ChannelRgbMatrix, 8); got != intrgbSourceVendor { t.Errorf("EffectSource(8) = %q, want an empty source for the vendor's", got) } } // intrgbLoadNamesFile and intrgbSourceUser keep the test reading through the // internal package without importing it twice under two names. func intrgbLoadNamesFile(path string) (*intrgb.Names, error) { return intrgb.LoadNamesFile(path) } const ( intrgbSourceUser = intrgb.SourceUser intrgbSourceVendor = intrgb.SourceVendor )