package rgb import ( "os" "path/filepath" "testing" "netdome.biz/paul/qmk-rgb/internal/via" ) func writeNames(t *testing.T, body string) *Names { t.Helper() path := filepath.Join(t.TempDir(), "board.json") if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } names, err := LoadNamesFile(path) if err != nil { t.Fatalf("LoadNamesFile() error = %v", err) } return names } // A name the user wrote is theirs, and the tool has to be able to say so wherever // it prints the name: nothing in a file can be read back off a keyboard, so a name // is the one value it cannot verify, and a user's name is a fact about them. func TestNamesCarryTheirSource(t *testing.T) { names := writeNames(t, `{ "vendorId": "0x1234", "productId": "0x5678", "channels": {"rgb_matrix": {"7": "rainbow_moving_chevron"}} }`) catalog := names.Apply(nil) if got := catalog.EffectName(via.ChannelRgbMatrix, 7); got != "rainbow_moving_chevron" { t.Errorf("EffectName() = %q, want the name the user wrote", got) } if got := catalog.EffectSource(via.ChannelRgbMatrix, 7); got != SourceUser { t.Errorf("EffectSource() = %q, want %q", got, SourceUser) } } // A board with no definition file is exactly the board a user writes names for, so // a names file alone has to produce a catalog. The manufacturer's file is the one // that is missing, not the names. func TestNamesAloneMakeACatalogForABoardWithNoDefinition(t *testing.T) { names := writeNames(t, `{ "vendorId": "0x1234", "productId": "0x5678", "channels": {"rgblight": {"0": "none", "1": "wave"}} }`) catalog := names.Apply(nil) effects := catalog.Effects(via.ChannelRgblight) if len(effects) != 2 { t.Fatalf("effects = %v, want two", effects) } if effects[0].ID != 0 || effects[0].Name != "none" { t.Errorf("effects[0] = %v, want ID 0 named none", effects[0]) } if effects[1].ID != 1 || effects[1].Name != "wave" { t.Errorf("effects[1] = %v, want ID 1 named wave", effects[1]) } // A name the user wrote resolves back to its number, or naming it would be // decoration. id, ok := catalog.EffectID(via.ChannelRgblight, "wave") if !ok || id != 1 { t.Errorf("EffectID(\"wave\") = %d, %t, want 1, true", id, ok) } } // A user's name replaces the vendor's for that one effect and leaves the rest of // the file alone: overriding one name must not mean restating the ones that were // already right. func TestNamesOverrideOneEffectAndLeaveTheRest(t *testing.T) { base := NewCatalog("Test Board", map[via.Channel][]Effect{ via.ChannelRgbMatrix: { {ID: 0, Name: "none"}, {ID: 1, Name: "solid_color"}, {ID: 2, Name: "breathing"}, }, }) names := writeNames(t, `{ "vendorId": "0x1234", "productId": "0x5678", "channels": {"rgb_matrix": {"2": "pulsing"}} }`) catalog := names.Apply(base) if got := catalog.EffectName(via.ChannelRgbMatrix, 2); got != "pulsing" { t.Errorf("EffectName(2) = %q, want the user's name", got) } if got := catalog.EffectSource(via.ChannelRgbMatrix, 2); got != SourceUser { t.Errorf("EffectSource(2) = %q, want %q", got, SourceUser) } if got := catalog.EffectName(via.ChannelRgbMatrix, 1); got != "solid_color" { t.Errorf("EffectName(1) = %q, want the vendor's name left alone", got) } if got := catalog.EffectSource(via.ChannelRgbMatrix, 1); got != SourceVendor { t.Errorf("EffectSource(1) = %q, want an empty source for the vendor's", got) } } // A definition may stop short of the board's highest ID, and a user naming that // one is telling the tool something the file did not. So the entry is added rather // than refused. func TestNamesAddAnEffectTheDefinitionDoesNotHave(t *testing.T) { base := NewCatalog("Test Board", map[via.Channel][]Effect{ via.ChannelRgbMatrix: {{ID: 0, Name: "none"}}, }) names := writeNames(t, `{ "vendorId": "0x1234", "productId": "0x5678", "channels": {"rgb_matrix": {"46": "riverflow"}} }`) catalog := names.Apply(base) effects := catalog.Effects(via.ChannelRgbMatrix) if len(effects) != 2 { t.Fatalf("effects = %v, want the definition's and the user's", effects) } if effects[1].ID != 46 || effects[1].Name != "riverflow" { t.Errorf("effects[1] = %v, want ID 46 named riverflow", effects[1]) } } // Applying names must not reach back into the definition it was given, or a second // lookup for the same board would see the first one's overrides. func TestApplyDoesNotMutateTheCatalogItWasGiven(t *testing.T) { base := NewCatalog("Test Board", map[via.Channel][]Effect{ via.ChannelRgbMatrix: {{ID: 1, Name: "solid_color"}}, }) names := writeNames(t, `{ "vendorId": "0x1234", "productId": "0x5678", "channels": {"rgb_matrix": {"1": "steady"}} }`) names.Apply(base) if got := base.EffectName(via.ChannelRgbMatrix, 1); got != "solid_color" { t.Errorf("the definition's catalog now says %q, want it untouched", got) } } // A file that is not a names file is a user error worth reporting. Answering // "no names" instead would read as the keyboard having none. func TestLoadNamesFileRejectsAChannelThatIsNotOne(t *testing.T) { path := filepath.Join(t.TempDir(), "board.json") if err := os.WriteFile(path, []byte(`{ "vendorId": "0x1234", "productId": "0x5678", "channels": {"keymap": {"1": "x"}} }`), 0o644); err != nil { t.Fatal(err) } if _, err := LoadNamesFile(path); err == nil { t.Error("LoadNamesFile() = nil error, want a channel that is not a lighting channel refused") } } func TestLoadNamesFileRejectsAnEffectIDThatIsNotOne(t *testing.T) { path := filepath.Join(t.TempDir(), "board.json") if err := os.WriteFile(path, []byte(`{ "vendorId": "0x1234", "productId": "0x5678", "channels": {"rgb_matrix": {"two": "x"}} }`), 0o644); err != nil { t.Fatal(err) } if _, err := LoadNamesFile(path); err == nil { t.Error("LoadNamesFile() = nil error, want an effect ID that is not one refused") } } // An effect ID is a JSON object key, so it arrives as a string, and both spellings // the tool prints elsewhere are worth accepting. func TestEffectIDIsReadInEitherSpelling(t *testing.T) { tests := []struct { raw string want uint8 }{ {"7", 7}, {"0x07", 7}, {"46", 46}, {"0x2E", 46}, } for _, tt := range tests { got, err := parseEffectID(tt.raw) if err != nil { t.Errorf("parseEffectID(%q) error = %v", tt.raw, err) continue } if got != tt.want { t.Errorf("parseEffectID(%q) = %d, want %d", tt.raw, got, tt.want) } } }