package rgb import ( "os" "path/filepath" "testing" "netdome.biz/paul/qmk-rgb/internal/via" ) // A definition may attach a number to an option that is not its position in the // list, so the ID has to come from the number and not from the index. This // fixture names three effects at IDs 1, 2 and 5 and leaves 0, 3 and 4 unnamed. func TestLoadDefinitionReadsEffectIDsFromTheOptions(t *testing.T) { def := loadDefinitionFixture(t, "tuple_options.json") effects := def.Catalog.Effects(via.ChannelRgbMatrix) if len(effects) != 3 { t.Fatalf("rgb_matrix effects = %d, want 3", len(effects)) } want := []Effect{{ID: 1, Name: "Solid Color"}, {ID: 2, Name: "Breathing"}, {ID: 5, Name: "Rainbow"}} for i, w := range want { if effects[i] != w { t.Errorf("effects[%d] = %+v, want %+v", i, effects[i], w) } } // A gap must report unknown rather than the next name along. for _, id := range []uint8{0, 3, 4, 6} { if got := def.Catalog.EffectName(via.ChannelRgbMatrix, id); got != unknownEffectName { t.Errorf("EffectName(rgb_matrix, %d) = %q, want %q", id, got, unknownEffectName) } } } // The same effect is reachable by name, which is what `effect ` needs. func TestDefinitionEffectsResolveByName(t *testing.T) { def := loadDefinitionFixture(t, "tuple_options.json") id, ok := def.Catalog.EffectID(via.ChannelRgbMatrix, "Rainbow") if !ok || id != 5 { t.Errorf("EffectID(rgb_matrix, \"Rainbow\") = %d, %t, want 5, true", id, ok) } if _, ok := def.Catalog.EffectID(via.ChannelRgbMatrix, "Solid"); ok { t.Error("EffectID(rgb_matrix, \"Solid\") = found, want not found") } } // Plain string options carry no number, and the position is the ID. func TestLoadDefinitionNumbersStringOptionsByPosition(t *testing.T) { def := loadDefinitionFixture(t, "string_options.json") effects := def.Catalog.Effects(via.ChannelRgblight) want := []Effect{{ID: 0, Name: "All Off"}, {ID: 1, Name: "Solid Color"}, {ID: 2, Name: "Breathing 1"}, {ID: 3, Name: "Breathing 2"}} if len(effects) != len(want) { t.Fatalf("rgblight effects = %d, want %d", len(effects), len(want)) } for i, w := range want { if effects[i] != w { t.Errorf("effects[%d] = %+v, want %+v", i, effects[i], w) } } } // VIA serves built definitions that carry vendorProductId as a number and no // vendorId/productId pair, so both spellings have to be read. func TestDefinitionIdentifiesTheBoardFromEitherSpelling(t *testing.T) { tests := []struct { file string wantVendor uint16 wantProd uint16 }{ {"tuple_options.json", 0x1234, 0x5678}, {"string_options.json", 0x1234, 0x5678}, } for _, tt := range tests { def := loadDefinitionFixture(t, tt.file) if def.VendorID != tt.wantVendor || def.ProductID != tt.wantProd { t.Errorf("%s: vendor/product = 0x%04x/0x%04x, want 0x%04x/0x%04x", tt.file, def.VendorID, def.ProductID, tt.wantVendor, tt.wantProd) } } if got := loadDefinitionFixture(t, "string_options.json").Name; got != "Served Board" { t.Errorf("name = %q, want %q", got, "Served Board") } } // Brightness, color and speed controls are not effect lists and must not become // one. func TestLoadDefinitionIgnoresOtherControls(t *testing.T) { def := loadDefinitionFixture(t, "tuple_options.json") for _, ch := range []via.Channel{via.ChannelBacklight, via.ChannelRgbMatrix} { for _, e := range def.Catalog.Effects(ch) { if e.Name == "" { t.Errorf("channel %v has an effect without a name", ch) } } } if got := def.Catalog.Effects(via.ChannelRgbMatrix); len(got) != 3 { t.Errorf("rgb_matrix effects = %d, want 3 (brightness and color excluded)", len(got)) } } // A definition is matched to a board by its VID/PID, so a directory can hold // many of them and the wrong one must not be used. func TestFindDefinitionMatchesTheBoard(t *testing.T) { dir := t.TempDir() copyFixture(t, dir, "tuple_options.json") defs, err := LoadDefinitionsDir(dir) if err != nil { t.Fatalf("LoadDefinitionsDir() error = %v", err) } if len(defs) != 1 { t.Fatalf("definitions = %d, want 1", len(defs)) } if got := FindDefinition(defs, 0x1234, 0x5678); got == nil { t.Error("FindDefinition(matching) = nil, want the definition") } if got := FindDefinition(defs, 0x36B0, 0x309F); got != nil { t.Error("FindDefinition(other board) = a definition, want nil") } } // A file that is not a definition, or not one for any board, is reported rather // than skipped in silence. func TestLoadDefinitionsDirRejectsUnusableFiles(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{not json"), 0o600); err != nil { t.Fatal(err) } if _, err := LoadDefinitionsDir(dir); err == nil { t.Error("LoadDefinitionsDir(broken json) = nil error, want an error") } } func TestLoadDefinitionRejectsAMissingFile(t *testing.T) { if _, err := LoadDefinition(filepath.Join(t.TempDir(), "nope.json")); err == nil { t.Error("LoadDefinition(missing) = nil error, want an error") } } func loadDefinitionFixture(t *testing.T, name string) *Definition { t.Helper() def, err := LoadDefinition(filepath.Join("testdata", name)) if err != nil { t.Fatalf("LoadDefinition(%s) error = %v", name, err) } return def } func copyFixture(t *testing.T, dir, name string) { t.Helper() data, err := os.ReadFile(filepath.Join("testdata", name)) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { t.Fatal(err) } } // A definition carries the manufacturer's own spelling, and the tool's aliases // are a spelling convenience rather than board knowledge, so they have to work // whichever spelling a definition happens to use. The vendor of this board // writes "breathe" where the compiled-in catalog writes "breathing". func TestDefinitionCatalogResolvesTheToolsAliasesBothWays(t *testing.T) { names := []Effect{{ID: 0, Name: "none"}, {ID: 3, Name: "spectrum"}, {ID: 4, Name: "breathe"}, {ID: 5, Name: "light"}} catalog := NewCatalog("vendor", map[via.Channel][]Effect{via.ChannelRgblight: names}) tests := []struct { name string effect string want uint8 }{ {"the vendor's own spelling", "breathe", 4}, {"the tool's canonical name", "breathing", 4}, {"an alias that the channel has", "off", 0}, {"an alias that resolves per channel", "rainbow", 3}, {"a name no alias or entry reaches", "nonsense", 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { id, ok := catalog.EffectID(via.ChannelRgblight, tt.effect) if tt.effect == "nonsense" { if ok { t.Errorf("EffectID(%q) = %d, true, want not found", tt.effect, id) } return } if !ok || id != tt.want { t.Errorf("EffectID(%q) = %d, %t, want %d, true", tt.effect, id, ok, tt.want) } }) } } // The compiled-in catalog keeps working exactly as before. func TestBuiltInCatalogStillResolvesItsAliases(t *testing.T) { catalog := impact80(t) tests := []struct { channel via.Channel effect string want uint8 }{ {via.ChannelRgblight, "breathing", 4}, {via.ChannelRgblight, "breathe", 4}, {via.ChannelRgblight, "off", 0}, {via.ChannelRgbMatrix, "breathing", 5}, {via.ChannelRgbMatrix, "rainbow", 17}, {via.ChannelAudio, "rainbow", 3}, } for _, tt := range tests { id, ok := catalog.EffectID(tt.channel, tt.effect) if !ok || id != tt.want { t.Errorf("EffectID(%v, %q) = %d, %t, want %d, true", tt.channel, tt.effect, id, ok, tt.want) } } } // A definition writes display spellings ("fixed wave") where the tool writes // identifiers ("fixed_wave"). The difference is whitespace, not a different // effect, so both must resolve — the tool's documented name may not stop working // because a definition file happens to be present. func TestDefinitionCatalogResolvesSpacesAsUnderscores(t *testing.T) { names := []Effect{{ID: 0, Name: "none"}, {ID: 1, Name: "wave"}, {ID: 2, Name: "fixed wave"}} catalog := NewCatalog("vendor", map[via.Channel][]Effect{via.ChannelRgblight: names}) tests := []struct { effect string want uint8 }{ {"fixed wave", 2}, {"fixed_wave", 2}, {"wave", 1}, } for _, tt := range tests { id, ok := catalog.EffectID(via.ChannelRgblight, tt.effect) if !ok || id != tt.want { t.Errorf("EffectID(%q) = %d, %t, want %d, true", tt.effect, id, ok, tt.want) } } } // The compiled-in catalog has no spaced names, so the rule changes nothing there. func TestBuiltInCatalogIsUnchangedByTheSpacingRule(t *testing.T) { catalog := impact80(t) if id, ok := catalog.EffectID(via.ChannelRgblight, "fixed_wave"); !ok || id != 2 { t.Errorf("EffectID(rgblight, \"fixed_wave\") = %d, %t, want 2, true", id, ok) } if _, ok := catalog.EffectID(via.ChannelRgbMatrix, "fixed_wave"); ok { t.Error("EffectID(rgb_matrix, \"fixed_wave\") = found, want not found: only the rgblight channel has it") } } // The sub-menu a definition puts a lighting channel under is that channel's name // in VIA's own interface, and it is board data the file already carries. Using // it means the tool and VIA call the same channel the same thing, instead of // the tool inventing a third name. func TestDefinitionNamesItsChannelsBySubMenu(t *testing.T) { def := loadDefinitionFixture(t, "tuple_options.json") want := map[uint16]string{2: "logo", 3: "Backlight", 4: "side"} for channel, label := range want { if got := def.Labels[channel]; got != label { t.Errorf("Labels[%d] = %q, want %q", channel, got, label) } } if len(def.Labels) != len(want) { t.Errorf("Labels = %v, want only the lighting channels", def.Labels) } } // The label is whatever the definition calls the channel, not a name this tool // knows: a different definition calls the same channel Underglow, and that name // is the one VIA shows. func TestDefinitionTakesWhateverTheChannelIsCalled(t *testing.T) { def := loadDefinitionFixture(t, "string_options.json") if got := def.Labels[2]; got != "Underglow" { t.Errorf("Labels[2] = %q, want %q", got, "Underglow") } if len(def.Labels) != 1 { t.Errorf("Labels = %v, want only the channel the definition names", def.Labels) } } // The definition files in the repository are read at runtime, so a truncated or // edited one would fail every command that names an effect rather than fail a // test. This parses the vendored Impact 80 file and checks what it has to // contain. func TestVendoredImpact80DefinitionIsUsable(t *testing.T) { def, err := LoadDefinition(filepath.Join("..", "..", "definitions", "impact80.json")) if err != nil { t.Fatalf("LoadDefinition(definitions/impact80.json) error = %v", err) } if def.VendorID != 0x36B0 || def.ProductID != 0x309F { t.Errorf("identifiers = 0x%04X/0x%04X, want 0x36B0/0x309F", def.VendorID, def.ProductID) } want := map[via.Channel]int{ via.ChannelRgblight: 7, via.ChannelRgbMatrix: 46, via.ChannelAudio: 7, } for ch, count := range want { if got := len(def.Catalog.Effects(ch)); got != count { t.Errorf("channel %v has %d effects, want %d", ch, got, count) } } // The names are the vendor's, and the tool's spelling has to resolve too. if id, ok := def.Catalog.EffectID(via.ChannelRgblight, "fixed wave"); !ok || id != 2 { t.Errorf("EffectID(rgblight, \"fixed wave\") = %d, %t, want 2, true", id, ok) } if id, ok := def.Catalog.EffectID(via.ChannelRgblight, "fixed_wave"); !ok || id != 2 { t.Errorf("EffectID(rgblight, \"fixed_wave\") = %d, %t, want 2, true", id, ok) } if got := def.Labels[3]; got != "Backlight" { t.Errorf("Labels[3] = %q, want %q", got, "Backlight") } }