package main import ( "bytes" "reflect" "strings" "testing" intdevice "netdome.biz/paul/qmk-rgb/internal/device" "netdome.biz/paul/qmk-rgb/internal/via" ) func TestResolveZoneNameAcceptsASubsystemName(t *testing.T) { got, err := resolveZoneName("rgb_matrix", map[uint16]string{2: "logo"}, nil) if err != nil { t.Fatalf("resolveZoneName() error = %v", err) } if len(got) != 1 || got[0] != via.ChannelRgbMatrix { t.Errorf("resolveZoneName() = %v, want [3]", got) } } func TestResolveZoneNameAcceptsADisplayName(t *testing.T) { got, err := resolveZoneName("logo", map[uint16]string{2: "logo", 3: "backlight", 4: "side"}, nil) if err != nil { t.Fatalf("resolveZoneName() error = %v", err) } if len(got) != 1 || got[0] != via.ChannelRgblight { t.Errorf("resolveZoneName() = %v, want [2]", got) } } func TestResolveZoneNameRejectsAnUnknownName(t *testing.T) { _, err := resolveZoneName("nope", map[uint16]string{2: "logo"}, nil) if err == nil { t.Fatal("resolveZoneName() expected an error, got nil") } if !strings.Contains(err.Error(), "rgb_matrix") { t.Errorf("error = %q, want it to name an accepted form", err) } } // A board that supplies no display names keeps the subsystem vocabulary, so the // physical names that work on the Impact 80 do not work elsewhere. func TestResolveZoneNameIgnoresDisplayNamesForAnotherBoard(t *testing.T) { if _, err := resolveZoneName("logo", nil, nil); err == nil { t.Fatal("resolveZoneName(\"logo\", nil, nil) expected an error, got nil") } } // Two channels carrying the same display name are ambiguous, and the tool // reports that rather than picking one. Whether a display name shadows another // channel's subsystem name cannot be decided here, because presence is only // known after the probe; displayNameConflicts decides that. func TestResolveZoneNameReportsAnAmbiguousName(t *testing.T) { _, err := resolveZoneName("backlight", map[uint16]string{2: "logo", 3: "backlight", 4: "backlight"}, nil) if err == nil { t.Fatal("resolveZoneName() expected an error for a name two channels answer to") } if !strings.Contains(err.Error(), "backlight") { t.Errorf("error = %q, want it to name the conflicting name", err) } } // A zone is one name or several written comma separated, and the channels come // back in channel order however they were written. A script that parses the // result sees one order for every spelling of one selection, so the order the // user typed in is not a thing to depend on. func TestResolveZoneNameReturnsAChannelOrderWhateverTheSpelling(t *testing.T) { display := map[uint16]string{2: "logo", 3: "backlight", 4: "side"} cases := []struct { zone string want []via.Channel }{ {"logo", []via.Channel{via.ChannelRgblight}}, {"logo,side", []via.Channel{via.ChannelRgblight, via.ChannelAudio}}, {"side,logo", []via.Channel{via.ChannelRgblight, via.ChannelAudio}}, {" logo , side ", []via.Channel{via.ChannelRgblight, via.ChannelAudio}}, {"side,logo,backlight", []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio}}, // A name twice, and a name that reaches one channel by two of its names, // are one channel each: a double write is not what was asked for. {"logo,logo", []via.Channel{via.ChannelRgblight}}, {"logo,rgblight", []via.Channel{via.ChannelRgblight}}, // The QMK subsystem name and the definition's label for the same channel. {"backlight", []via.Channel{via.ChannelRgbMatrix}}, {"Backlight", []via.Channel{via.ChannelRgbMatrix}}, // A board that renames a channel keeps the subsystem name working. {"rgblight", []via.Channel{via.ChannelRgblight}}, } for _, tc := range cases { t.Run(tc.zone, func(t *testing.T) { got, err := resolveZoneName(tc.zone, display, map[uint16][]string{3: {"rgb_matrix"}}) if err != nil { t.Fatalf("resolveZoneName(%q) error = %v", tc.zone, err) } if !reflect.DeepEqual(got, tc.want) { t.Errorf("resolveZoneName(%q) = %v, want %v", tc.zone, got, tc.want) } }) } } // all is every channel, and naming it beside one more channel is the same // request as naming it alone. The nil slice is how "every channel" travels: a // command that only read asks for no zone at all and gets the same thing. func TestResolveZoneNameTreatsAllAsEveryChannel(t *testing.T) { display := map[uint16]string{2: "logo", 3: "backlight", 4: "side"} for _, zone := range []string{"all", "ALL", "All", " all ", "side,all", "all,logo"} { t.Run(zone, func(t *testing.T) { got, err := resolveZoneName(zone, display, nil) if err != nil { t.Fatalf("resolveZoneName(%q) error = %v", zone, err) } if got != nil { t.Errorf("resolveZoneName(%q) = %v, want nil, which means every channel", zone, got) } }) } } // A list that names no channel is a typo, and guessing which one was meant writes // to a channel nobody asked for. func TestResolveZoneNameRefusesAnEmptyNameInAList(t *testing.T) { cases := []struct { zone string want string }{ {"logo,", "logo,"}, {",logo", ",logo"}, {"logo,,side", "logo,,side"}, {"logo, ", "logo,"}, {",", ","}, } for _, tc := range cases { t.Run(tc.zone, func(t *testing.T) { _, err := resolveZoneName(tc.zone, map[uint16]string{2: "logo", 4: "side"}, nil) if err == nil { t.Fatalf("resolveZoneName(%q) = nil error, want the empty name refused", tc.zone) } if !strings.Contains(err.Error(), "empty") { t.Errorf("error = %q, want it to say the list names no channel", err) } if !strings.Contains(err.Error(), tc.want) { t.Errorf("error = %q, want it to quote %q", err, tc.want) } }) } } // A name the tool cannot place is reported with the list it came from, because // "unknown zone" on its own would be a claim about a word the user never wrote. func TestResolveZoneNameNamesTheListItCameFrom(t *testing.T) { _, err := resolveZoneName("logo,nope", map[uint16]string{2: "logo"}, nil) if err == nil { t.Fatal("resolveZoneName() = nil error, want the unknown name refused") } for _, want := range []string{`"nope"`, `in "logo,nope"`, zoneAll} { if !strings.Contains(err.Error(), want) { t.Errorf("error = %q, want it to contain %q", err, want) } } } // One ambiguous name refuses the whole list, before the other names are written // to. A board that gave the same name to two channels has a name that means both // or neither, and resolving it to one of them is a retarget nobody asked for. func TestResolveZoneNameRefusesAWholeListOverOneAmbiguousName(t *testing.T) { _, err := resolveZoneName("logo,backlight", map[uint16]string{2: "logo", 3: "backlight", 4: "backlight"}, nil) if err == nil { t.Fatal("resolveZoneName() = nil error, want the ambiguous name refused") } if !strings.Contains(err.Error(), "several channels") { t.Errorf("error = %q, want it to say the name is ambiguous", err) } } func TestDisplayNameConflictsRejectsAShadowedSubsystemName(t *testing.T) { // Channel 1 is present and its subsystem is "backlight", while the file // also calls channel 3 "backlight": two channels, one name. err := displayNameConflicts( map[uint16]string{3: "backlight"}, []via.Channel{via.ChannelBacklight, via.ChannelRgbMatrix}, ) if err == nil { t.Fatal("displayNameConflicts() expected an error, got nil") } if !strings.Contains(err.Error(), "backlight") { t.Errorf("error = %q, want it to name the conflicting name", err) } } func TestDisplayNameConflictsAllowsTheImpact80Naming(t *testing.T) { // The Impact 80 calls channel 3 "backlight" and has no channel 1, so // nothing shadows anything. err := displayNameConflicts( map[uint16]string{2: "logo", 3: "backlight", 4: "side"}, []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio}, ) if err != nil { t.Fatalf("displayNameConflicts() error = %v, want nil", err) } } func TestChannelNamePrefersTheDisplayName(t *testing.T) { display := map[uint16]string{2: "logo"} if got := channelName(via.ChannelRgblight, display); got != "logo" { t.Errorf("channelName(2) = %q, want %q", got, "logo") } if got := channelName(via.ChannelRgbMatrix, display); got != "rgb_matrix" { t.Errorf("channelName(3) = %q, want %q", got, "rgb_matrix") } } // The display names must come from the keyboard the command targets, not from // whichever one enumeration returned first. They come from that board's // definition file, which is where a channel's name lives now. func TestPrepareTargetUsesTheSelectedKeyboard(t *testing.T) { devices := []intdevice.Device{ {VendorID: 0x6666, ProductID: 0x0001}, {VendorID: 0x36B0, ProductID: 0x309F, Name: "Impact 80"}, } dir := t.TempDir() writeDefinition(t, dir, "rainy75.json", `{ "name": "Rainy 75", "vendorId": "0x6666", "productId": "0x0001", "menus": [{"label":"Lighting","content":[{"label":"deck","content":[ {"label":"Effect","type":"dropdown","content":["id_qmk_rgblight_effect",2,2],"options":["none"]}]}]}]}`) writeDefinition(t, dir, "impact80.json", `{ "name": "Impact 80", "vendorId": "0x36B0", "productId": "0x309F", "menus": [{"label":"Lighting","content":[{"label":"logo","content":[ {"label":"Effect","type":"dropdown","content":["id_qmk_rgblight_effect",2,2],"options":["none","wave"]}]}]}]}`) t.Cleanup(forceDefinitionsDir(t, dir)) originalDiscover := discoverAll originalTarget := targetDevice t.Cleanup(func() { discoverAll = originalDiscover targetDevice = originalTarget }) discoverAll = func() ([]intdevice.Device, error) { return devices, nil } targetDevice = "2" got, err := prepareTarget("logo") if err != nil { t.Fatalf("prepareTarget() error = %v", err) } if got.Display[2] != "logo" { t.Errorf("display names = %v, want the Impact 80's", got.Display) } if got.Device.Name != "Impact 80" { t.Errorf("name = %q, want the name the definition gives the board", got.Device.Name) } } // A board renamed after a profile was written leaves keys that resolve to // nothing. Each one must be reported by name, and nothing may be written. func TestLoadWarnsForEveryUnresolvableZoneKey(t *testing.T) { t.Cleanup(vendoredDefinitions(t)) dir := t.TempDir() profile := Profile{ Name: "renamed", Version: 1, Zones: map[string]*ZoneSettings{ "old-logo": {Enabled: true, Effect: "light", Brightness: 100, Speed: 1, Color: "00ff"}, "old-backlight": {Enabled: true, Effect: "wave", Brightness: 100, Speed: 1, Color: "00ff"}, }, } originalDir := profilesDirOverride t.Cleanup(func() { profilesDirOverride = originalDir }) profilesDirOverride = dir if err := profile.Save(); err != nil { t.Fatalf("save profile: %v", err) } proto := &verifyingProtocol{} t.Cleanup(stubTargetForProfileTest(t, proto, "", impact80Display())) var stderr bytes.Buffer cmd := NewProfileLoadCmd() cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&stderr) cmd.SetArgs([]string{"renamed"}) if err := cmd.Execute(); err != nil { t.Fatalf("load returned error: %v", err) } for _, want := range []string{"old-logo", "old-backlight"} { if !strings.Contains(stderr.String(), want) { t.Errorf("stderr = %q, want it to name the unresolved key %q", stderr.String(), want) } } if len(proto.reports) != 0 { t.Errorf("reports = %v, want nothing written for unresolvable keys", proto.reports) } } // stubTargetForProfileTest points the profile commands at one protocol, one // profile directory and one zone, and returns the restore function. func stubTargetForProfileTest(t *testing.T, proto rgbProtocol, zone string, display map[uint16]string) func() { t.Helper() originalTarget := openTarget openTarget = func(requested string) (rgbProtocol, targetDeviceData, []via.Channel, error) { // The board identity matters: the catalog is selected by VID/PID, and a // profile that names an effect needs one to resolve it against. target := targetDeviceData{ Device: intdevice.Device{VendorID: 0x36B0, ProductID: 0x309F}, Display: display, } // A profile test says which zone the command was given; the seam falls // back to the one the test fixed so the stub works either way. if requested == "" { requested = zone } channels, err := resolveZoneName(requested, display, nil) if err != nil { return nil, target, nil, err } target.Requested = channels resolved, err := resolveChannels(proto, target) if err != nil { return nil, target, nil, err } return proto, target, resolved, nil } return func() { openTarget = originalTarget } } // A key that names a channel this keyboard does not have must be reported like // any other key it cannot place. "backlight" is the case that matters: it is a // display name on the Impact 80 and QMK's subsystem name for channel 1, so it // still resolves after a board renames channel 3 — to a channel that is absent. func TestLoadWarnsWhenAKeyNamesAnAbsentChannel(t *testing.T) { t.Cleanup(vendoredDefinitions(t)) dir := t.TempDir() originalDir := profilesDirOverride t.Cleanup(func() { profilesDirOverride = originalDir }) profilesDirOverride = dir profile := Profile{ Name: "renamed", Version: 1, Zones: map[string]*ZoneSettings{ "backlight": {Enabled: true, Effect: "light", Brightness: 100, Speed: 1, Color: "00ff"}, }, } if err := profile.Save(); err != nil { t.Fatalf("save profile: %v", err) } proto := &verifyingProtocol{} t.Cleanup(stubTargetForProfileTest(t, proto, "", nil)) var stderr bytes.Buffer cmd := NewProfileLoadCmd() cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&stderr) cmd.SetArgs([]string{"renamed"}) if err := cmd.Execute(); err != nil { t.Fatalf("load returned error: %v", err) } if !strings.Contains(stderr.String(), "backlight") { t.Errorf("stderr = %q, want the key reported by name", stderr.String()) } if len(proto.reports) != 0 { t.Errorf("reports = %v, want nothing written for an absent channel", proto.reports) } } // Without a catalog there are no effect names to look the profile's value up // in. The load must say that, not claim the name was not found. func TestLoadSaysSoWhenTheBoardHasNoCatalog(t *testing.T) { dir := t.TempDir() originalDir := profilesDirOverride t.Cleanup(func() { profilesDirOverride = originalDir }) profilesDirOverride = dir profile := Profile{ Name: "p", Version: 1, Zones: map[string]*ZoneSettings{ "rgb_matrix": {Enabled: true, Effect: "breathing", Brightness: 100, Speed: 1, Color: "00ff"}, }, } if err := profile.Save(); err != nil { t.Fatalf("save profile: %v", err) } proto := &verifyingProtocol{applied: map[via.Channel]uint8{ via.ChannelRgblight: 160, via.ChannelRgbMatrix: 255, via.ChannelAudio: 160, }} restore := stubTargetForUnknownBoard(t, proto, dir) t.Cleanup(restore) var stderr bytes.Buffer cmd := NewProfileLoadCmd() cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&stderr) cmd.SetArgs([]string{"p"}) if err := cmd.Execute(); err != nil { t.Fatalf("load returned error: %v", err) } if !strings.Contains(stderr.String(), "no effect names") { t.Errorf("stderr = %q, want the missing names named", stderr.String()) } if strings.Contains(stderr.String(), `effect "breathing" not found`) { t.Errorf("stderr = %q, must not blame the effect name for a missing catalog", stderr.String()) } if len(proto.reports) != 0 { t.Errorf("reports = %v, want nothing written without a catalog", proto.reports) } } // stubTargetForUnknownBoard points the commands at a board that has channels but // no definition and no compiled-in catalog, so it has no effect names. func stubTargetForUnknownBoard(t *testing.T, proto rgbProtocol, dir string) func() { t.Helper() originalTarget := openTarget originalDir := profilesDirOverride openTarget = func(string) (rgbProtocol, targetDeviceData, []via.Channel, error) { return proto, targetDeviceData{ Device: intdevice.Device{VendorID: 0x6666, ProductID: 0x0001}, }, impact80Channels(), nil } profilesDirOverride = dir return func() { openTarget = originalTarget profilesDirOverride = originalDir } } // A board without a catalog cannot name its effects, so a saved profile records // "unknown" for every channel and cannot be loaded back. That is worth saying at // save time, where the user can still do something about it. func TestSaveWarnsThatEffectNamesCannotBeRecorded(t *testing.T) { dir := t.TempDir() originalDir := profilesDirOverride t.Cleanup(func() { profilesDirOverride = originalDir }) profilesDirOverride = dir proto := &verifyingProtocol{applied: map[via.Channel]uint8{ via.ChannelRgblight: 160, via.ChannelRgbMatrix: 255, via.ChannelAudio: 160, }} restore := stubTargetForUnknownBoard(t, proto, dir) t.Cleanup(restore) var stderr bytes.Buffer cmd := NewProfileSaveCmd() cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&stderr) cmd.SetArgs([]string{"p"}) if err := cmd.Execute(); err != nil { t.Fatalf("save returned error: %v", err) } if !strings.Contains(stderr.String(), "unknown") { t.Errorf("stderr = %q, want the effect name it cannot record named", stderr.String()) } if !strings.Contains(stderr.String(), "no effect names") { t.Errorf("stderr = %q, want the missing names named", stderr.String()) } // The way out it names has to run. `effect` takes the zone as its first // argument, so a bare `effect ` would be read as a channel name. if !strings.Contains(stderr.String(), "effect ") { t.Errorf("stderr = %q, want the way out to name the zone as well as the ID", stderr.String()) } } // A channel's own definition label and the subsystem name it replaces can both // match one name, and that is one channel rather than a conflict: the // documented `brightness backlight 100` has to keep working on a board whose // definition calls the channel "Backlight". func TestResolveZoneNameAcceptsTheNameADefinitionReplaced(t *testing.T) { display := map[uint16]string{2: "logo", 3: "Backlight", 4: "side"} alternatives := map[uint16][]string{3: {"backlight"}} for _, name := range []string{"backlight", "Backlight", "BACKLIGHT"} { got, err := resolveZoneName(name, display, alternatives) if err != nil { t.Errorf("resolveZoneName(%q) error = %v", name, err) continue } if len(got) != 1 || got[0] != 3 { t.Errorf("resolveZoneName(%q) = %v, want [3]", name, got) } } } // Two different channels answering to one name is still a conflict, whichever // source the names come from. func TestResolveZoneNameStillReportsARealConflict(t *testing.T) { display := map[uint16]string{2: "Backlight", 3: "backlight"} if _, err := resolveZoneName("backlight", display, map[uint16][]string{4: {"backlight"}}); err == nil { t.Error("resolveZoneName() = nil error, want a conflict for one name on two channels") } } // A board whose definition calls channel 3 "Backlight" has no channel 1, and // naming an unused channel would make "backlight" reach two channels at once — // the subsystem name of the one it does not have, and the label of the one it // does. The label has to win, because it is what the board calls that channel. func TestZoneNamePrefersTheDefinitionLabelOverAnotherChannelsSubsystem(t *testing.T) { dir := t.TempDir() writeDefinition(t, dir, "impact80.json", `{ "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":[["cycle_left_right",13]]}]}]}]}`) t.Cleanup(forceDefinitionsDir(t, dir)) display, alternatives := applyDefinitionLabels(0x36B0, 0x309F) if display[3] != "Backlight" { t.Errorf("display = %v, want channel 3 named by the definition", display) } if _, ok := display[1]; ok { t.Errorf("display = %v, want no entry for a channel the definition does not name", display) } for _, name := range []string{"backlight", "Backlight", "BACKLIGHT", "rgb_matrix"} { got, err := resolveZoneName(name, display, alternatives) if err != nil { t.Errorf("resolveZoneName(%q) error = %v, want channel 3", name, err) continue } if len(got) != 1 || got[0] != 3 { t.Errorf("resolveZoneName(%q) = %v, want [3]", name, got) } } } // Fifteen commands in one flat list is where a user starts reading the help to // find a command and gives up. The groups say what a command is for, and a // command that belongs to none of them is a hole in the list. func TestRootHelpGroupsTheCommands(t *testing.T) { // The same registration the binary runs, so a command that someone adds // without a group is caught here rather than in the help output. root := newRootCommand() registerCommands(root) var out bytes.Buffer root.SetOut(&out) root.SetArgs([]string{"--help"}) if err := root.Execute(); err != nil { t.Fatalf("--help returned error: %v", err) } help := out.String() for _, want := range []string{ "Lighting Commands:", "Profile Commands:", "Keyboard Commands:", "save", "load", "list", "delete", "effect", "keyboard", } { if !strings.Contains(help, want) { t.Errorf("--help does not mention %q", want) } } // Every command the tool adds has to be in a group, or it lands in // "Additional Commands" where the grouping says nothing about it. grouped := make(map[string]bool) for _, c := range root.Commands() { if c.GroupID != "" { grouped[c.Name()] = true } } for _, c := range root.Commands() { if c.Name() == "help" || c.Name() == "completion" { continue } if !grouped[c.Name()] { t.Errorf("command %q is in no group, so it appears ungrouped in --help", c.Name()) } } }