package main import ( "bytes" "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) } } 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 originalZone := targetZone t.Cleanup(func() { discoverAll = originalDiscover targetDevice = originalTarget targetZone = originalZone }) discoverAll = func() ([]intdevice.Device, error) { return devices, nil } targetDevice = "2" targetZone = "logo" got, err := prepareTarget() 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 value, and returns the restore function. func stubTargetForProfileTest(t *testing.T, proto rgbProtocol, zoneFlag string, display map[uint16]string) func() { t.Helper() originalTarget := openTarget originalZone := targetZone openTarget = func() (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, } requested, err := resolveZoneName(zoneFlag, display, nil) if err != nil { return nil, target, nil, err } target.Requested = requested resolved, err := resolveChannels(proto, target) if err != nil { return nil, target, nil, err } return proto, target, resolved, nil } targetZone = zoneFlag return func() { openTarget = originalTarget targetZone = originalZone } } // 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() (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()) } } // 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 `--zone backlight` 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()) } } }