package main import ( "errors" "os" "path/filepath" "strings" "testing" "github.com/spf13/cobra" intdevice "netdome.biz/paul/qmk-rgb/internal/device" ) // The tool knows what a zone may be, but the shell only offers what the binary // tells it about. The zone is a positional argument, so the completion hangs off // the command rather than off a flag, and a board's own channel names come from // a definition file the tool reads. func TestZoneCompletionOffersSubsystemsAndTheBoardsOwnNames(t *testing.T) { dir := t.TempDir() 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"]}]}, {"label":"Backlight","content":[{"label":"Effect","type":"dropdown", "content":["id_qmk_rgb_matrix_effect",3,2],"options":["none"]}]}]}]}`) t.Cleanup(forceDefinitionsDir(t, dir)) got, directive := completeZoneNames(nil, nil, "") if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("directive = %v, want NoFileComp so the shell does not fall back to paths", directive) } for _, want := range []string{"backlight", "rgblight", "rgb_matrix", "audio", "led_matrix", "logo", "Backlight"} { if !contains(got, want) { t.Errorf("completeZoneNames() = %v, want it to offer %q", got, want) } } // Nothing is offered twice: Backlight is both a label and close to // backlight, and a list with repeats reads as a bug. if len(got) != countDistinct(got) { t.Errorf("completeZoneNames() = %v, want no duplicates", got) } } // The shell is completing as the user types, so a prefix has to narrow the list. func TestZoneCompletionNarrowsToThePrefix(t *testing.T) { t.Cleanup(forceDefinitionsDir(t, t.TempDir())) got, directive := completeZoneNames(nil, nil, "rgb") if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("directive = %v, want NoFileComp so the shell does not fall back to paths", directive) } for _, g := range got { if !strings.HasPrefix(g, "rgb") { t.Errorf("completeZoneNames(%q) = %v, want only names with that prefix", "rgb", got) } } if !contains(got, "rgb_matrix") { t.Errorf("completeZoneNames(\"rgb\") = %v, want rgb_matrix", got) } } // `all` is a zone name like any other, and the shell has to offer it: it is the // one spelling that reaches every channel, and a user who cannot see it will not // reach every channel. func TestZoneCompletionOffersAll(t *testing.T) { t.Cleanup(forceDefinitionsDir(t, t.TempDir())) got, _ := completeZoneNames(nil, nil, "") if !contains(got, "all") { t.Errorf("completeZoneNames() = %v, want it to offer \"all\"", got) } } // A board's own names come from a definition file, and the one built into the // binary counts as much as one in the data directory. `go install` creates no // data directory, so a shell that only read that one would offer a board nothing // but the subsystem names — and the subsystem names a board renamed are not the // names it answers to. func TestZoneCompletionOffersTheBuiltInDefinitionsToo(t *testing.T) { t.Cleanup(forceDefinitionsDir(t, t.TempDir())) got, _ := completeZoneNames(nil, nil, "") // The Impact 80's file is the one that ships, and it names three channels. for _, want := range []string{"logo", "Backlight", "side"} { if !contains(got, want) { t.Errorf("completeZoneNames() = %v, want the built-in definition's name %q", got, want) } } } // The zone is a positional argument now, so the completion hangs off the command // rather than off a flag, and it stops after the first argument rather than // offering a channel where a value belongs. func TestZoneCompletionIsOnTheCommandNotTheFlag(t *testing.T) { lighting := map[string]*cobra.Command{ "effect": NewEffectCmd(), "brightness": NewBrightnessCmd(), "speed": NewSpeedCmd(), "color": NewColorCmd(), "enable": NewEnableCmd(), "disable": NewDisableCmd(), "info": NewInfoCmd(), } for name, cmd := range lighting { t.Run(name, func(t *testing.T) { if cmd.ValidArgsFunction == nil { t.Fatalf("%s has no ValidArgsFunction, want the shell offered the zone names", name) } got, _ := cmd.ValidArgsFunction(cmd, nil, "") if !contains(got, "all") { t.Errorf("%s completion = %v, want it to offer the zones", name, got) } // After the zone, the next argument is a value or a name the tool // cannot know without opening the keyboard. after, directive := cmd.ValidArgsFunction(cmd, []string{"logo"}, "") if len(after) != 0 { t.Errorf("%s completion after a zone = %v, want nothing", name, after) } if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("%s directive = %v, want NoFileComp", name, directive) } }) } } // `load` takes a profile name first and a zone second, in that order, so the two // completions cannot be swapped without offering something that does not exist. func TestLoadCompletionIsAProfileThenAZone(t *testing.T) { t.Cleanup(forceDefinitionsDir(t, t.TempDir())) cmd := NewProfileLoadCmd() first, _ := cmd.ValidArgsFunction(cmd, nil, "") if !contains(first, "lava") { t.Errorf("load completion = %v, want the profile names", first) } second, _ := cmd.ValidArgsFunction(cmd, []string{"lava"}, "") if contains(second, "lava") { t.Errorf("load completion after a name = %v, want zones, not more profiles", second) } if !contains(second, "all") { t.Errorf("load completion after a name = %v, want the zone names", second) } } // A command that cannot address a channel offers no zone, so the shell does not // suggest one where there is none to use. `delete` completes a profile name, so // what matters is that the candidates are the names, not the channels. func TestCommandsWithoutAChannelOfferNoZone(t *testing.T) { for name, cmd := range map[string]*cobra.Command{ "list": NewProfileListCmd(), "save": NewProfileSaveCmd(), } { t.Run(name, func(t *testing.T) { if cmd.ValidArgsFunction != nil { t.Errorf("%s offers completion, want only the commands that name a channel to", name) } }) } profiles, _ := NewProfileDeleteCmd().ValidArgsFunction(nil, nil, "") for _, candidate := range profiles { if candidate == "all" || candidate == "rgb_matrix" { t.Errorf("delete completion = %v, want profile names, not channels", profiles) } } } // A definition that is not there must not break completion: a shell prints the // error text, which is worse than offering nothing. func TestZoneCompletionSurvivesAMissingDataDirectory(t *testing.T) { t.Cleanup(forceDefinitionsDir(t, filepath.Join(t.TempDir(), "absent"))) got, directive := completeZoneNames(nil, nil, "") if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("directive = %v, want NoFileComp so the shell does not fall back to paths", directive) } if !contains(got, "rgb_matrix") { t.Errorf("completeZoneNames() = %v, want the QMK subsystem names at least", got) } } // --device takes the numbers keyboard info prints, which come from enumeration // alone and need no open keyboard. func TestDeviceCompletionOffersTheConnectedKeyboards(t *testing.T) { original := discoverAll discoverAll = func() ([]intdevice.Device, error) { return []intdevice.Device{{Index: 1}, {Index: 2}}, nil } t.Cleanup(func() { discoverAll = original }) got, directive := completeDeviceNumbers(nil, nil, "") if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("directive = %v, want NoFileComp so the shell does not fall back to paths", directive) } if len(got) != 2 || got[0] != "1" || got[1] != "2" { t.Errorf("completeDeviceNumbers() = %v, want [1 2]", got) } } func TestDeviceCompletionSurvivesADiscoveryFailure(t *testing.T) { original := discoverAll discoverAll = func() ([]intdevice.Device, error) { return nil, errors.New("no keyboard") } t.Cleanup(func() { discoverAll = original }) got, directive := completeDeviceNumbers(nil, nil, "") if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("directive = %v, want NoFileComp so the shell does not fall back to paths", directive) } if len(got) != 0 { t.Errorf("completeDeviceNumbers() = %v, want nothing when discovery fails", got) } } // load, save and delete take a profile name, and the names in profiles/ are // readable without a keyboard — which is the point, because the shell is asking // while nothing is plugged in. func TestProfileNameCompletionOffersTheFilesInTheDirectory(t *testing.T) { dir := t.TempDir() original := profilesDirOverride profilesDirOverride = dir t.Cleanup(func() { profilesDirOverride = original }) for _, name := range []string{"paul.json", "default.json", "notes.txt"} { if err := os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o600); err != nil { t.Fatal(err) } } for _, cmd := range []*cobra.Command{NewProfileLoadCmd(), NewProfileDeleteCmd()} { got, _ := completeProfileNames(cmd, nil, "") if len(got) != 2 || got[0] != "default" || got[1] != "paul" { t.Errorf("completeProfileNames() = %v, want [default paul]", got) } } } func TestProfileNameCompletionSurvivesAMissingDirectory(t *testing.T) { original := profilesDirOverride profilesDirOverride = filepath.Join(t.TempDir(), "absent") t.Cleanup(func() { profilesDirOverride = original }) got, directive := completeProfileNames(nil, nil, "") if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("directive = %v, want NoFileComp so the shell does not fall back to paths", directive) } if len(got) != 0 { t.Errorf("completeProfileNames() = %v, want nothing", got) } } func contains(list []string, want string) bool { for _, s := range list { if s == want { return true } } return false } func countDistinct(list []string) int { seen := make(map[string]bool, len(list)) for _, s := range list { seen[s] = true } return len(seen) }