feat: add PackageUpgrade to Capabilities, exhaustive detect + CLI tests

- Add PackageUpgrade field to Capabilities struct and GetCapabilities
- Add PackageUpgrade to all 11 provider capability tests
- Add pkg-upgrade to CLI detect command output
- Expand detect tests: ByName for all managers, concurrent Reset,
  HasBinary, candidates/allManagers coverage
- Add cmd/snack unit tests: targets, opts, getManager, version
- 838 tests passing, 0 failures
This commit is contained in:
2026-03-06 00:13:41 +00:00
parent 60b68060e7
commit 1fa7de6d66
16 changed files with 306 additions and 22 deletions

View File

@@ -405,6 +405,9 @@ func TestCapabilities(t *testing.T) {
if caps.NameNormalize {
t.Error("expected NameNormalize=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}
func TestSupportsDryRun(t *testing.T) {

View File

@@ -90,6 +90,7 @@ func TestCapabilities(t *testing.T) {
{"Groups", caps.Groups, false},
{"NameNormalize", caps.NameNormalize, true},
{"DryRun", caps.DryRun, true},
{"PackageUpgrade", caps.PackageUpgrade, true},
}
for _, c := range checks {
t.Run(c.name, func(t *testing.T) {

View File

@@ -117,6 +117,7 @@ func TestCapabilities(t *testing.T) {
{"Groups", caps.Groups, false},
{"NameNormalize", caps.NameNormalize, false},
{"DryRun", caps.DryRun, false},
{"PackageUpgrade", caps.PackageUpgrade, true},
}
for _, tt := range tests {

View File

@@ -4,15 +4,16 @@ package snack
// Useful for grlx to determine what operations are available before
// attempting them.
type Capabilities struct {
VersionQuery bool
Hold bool
Clean bool
FileOwnership bool
RepoManagement bool
KeyManagement bool
Groups bool
NameNormalize bool
DryRun bool
VersionQuery bool
Hold bool
Clean bool
FileOwnership bool
RepoManagement bool
KeyManagement bool
Groups bool
NameNormalize bool
DryRun bool
PackageUpgrade bool
}
// GetCapabilities probes a Manager for all optional interface support.
@@ -26,15 +27,17 @@ func GetCapabilities(m Manager) Capabilities {
_, g := m.(Grouper)
_, nn := m.(NameNormalizer)
_, dr := m.(DryRunner)
_, pu := m.(PackageUpgrader)
return Capabilities{
VersionQuery: vq,
Hold: h,
Clean: c,
FileOwnership: fo,
RepoManagement: rm,
KeyManagement: km,
Groups: g,
NameNormalize: nn,
DryRun: dr,
VersionQuery: vq,
Hold: h,
Clean: c,
FileOwnership: fo,
RepoManagement: rm,
KeyManagement: km,
Groups: g,
NameNormalize: nn,
DryRun: dr,
PackageUpgrade: pu,
}
}

View File

@@ -77,6 +77,7 @@ func TestGetCapabilities_BaseManager(t *testing.T) {
assert.False(t, caps.Groups)
assert.False(t, caps.NameNormalize)
assert.False(t, caps.DryRun)
assert.False(t, caps.PackageUpgrade)
}
func TestGetCapabilities_FullManager(t *testing.T) {
@@ -90,4 +91,5 @@ func TestGetCapabilities_FullManager(t *testing.T) {
assert.True(t, caps.Groups)
assert.True(t, caps.NameNormalize)
assert.True(t, caps.DryRun)
assert.True(t, caps.PackageUpgrade)
}

View File

@@ -387,6 +387,9 @@ func detectCmd() *cobra.Command {
if caps.NameNormalize {
capList = append(capList, "normalize")
}
if caps.PackageUpgrade {
capList = append(capList, "pkg-upgrade")
}
capStr := ""
if len(capList) > 0 {
capStr = " [" + strings.Join(capList, ", ") + "]"

149
cmd/snack/main_test.go Normal file
View File

@@ -0,0 +1,149 @@
package main
import (
"testing"
"github.com/gogrlx/snack"
)
func TestTargets(t *testing.T) {
tests := []struct {
name string
args []string
ver string
want []snack.Target
}{
{
name: "no_args",
args: nil,
want: nil,
},
{
name: "single_no_version",
args: []string{"curl"},
want: []snack.Target{{Name: "curl"}},
},
{
name: "multiple_no_version",
args: []string{"curl", "wget"},
want: []snack.Target{{Name: "curl"}, {Name: "wget"}},
},
{
name: "single_with_version",
args: []string{"curl"},
ver: "7.88",
want: []snack.Target{{Name: "curl", Version: "7.88"}},
},
{
name: "multiple_with_version",
args: []string{"curl", "wget"},
ver: "1.0",
want: []snack.Target{{Name: "curl", Version: "1.0"}, {Name: "wget", Version: "1.0"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := targets(tt.args, tt.ver)
if len(got) != len(tt.want) {
t.Fatalf("targets() returned %d, want %d", len(got), len(tt.want))
}
for i, g := range got {
if g.Name != tt.want[i].Name {
t.Errorf("[%d] Name = %q, want %q", i, g.Name, tt.want[i].Name)
}
if g.Version != tt.want[i].Version {
t.Errorf("[%d] Version = %q, want %q", i, g.Version, tt.want[i].Version)
}
}
})
}
}
func TestOpts(t *testing.T) {
// Reset flags
flagSudo = false
flagYes = false
flagDry = false
o := opts()
if len(o) != 0 {
t.Errorf("expected 0 options with no flags, got %d", len(o))
}
flagSudo = true
o = opts()
if len(o) != 1 {
t.Errorf("expected 1 option with sudo, got %d", len(o))
}
flagYes = true
flagDry = true
o = opts()
if len(o) != 3 {
t.Errorf("expected 3 options with all flags, got %d", len(o))
}
// Clean up
flagSudo = false
flagYes = false
flagDry = false
}
func TestOptsApply(t *testing.T) {
flagSudo = true
flagYes = true
flagDry = true
defer func() {
flagSudo = false
flagYes = false
flagDry = false
}()
applied := snack.ApplyOptions(opts()...)
if !applied.Sudo {
t.Error("expected Sudo=true")
}
if !applied.AssumeYes {
t.Error("expected AssumeYes=true")
}
if !applied.DryRun {
t.Error("expected DryRun=true")
}
}
func TestGetManager(t *testing.T) {
// Default detection
flagMgr = ""
m, err := getManager()
if err != nil {
t.Skipf("no manager available: %v", err)
}
if m.Name() == "" {
t.Error("expected non-empty manager name")
}
// Explicit override
flagMgr = "apt"
m, err = getManager()
if err != nil {
t.Fatalf("getManager() with --manager=apt failed: %v", err)
}
if m.Name() != "apt" {
t.Errorf("expected Name()=apt, got %q", m.Name())
}
// Unknown manager
flagMgr = "nonexistent-manager-xyz"
_, err = getManager()
if err == nil {
t.Error("expected error for unknown manager")
}
flagMgr = ""
}
func TestVersionString(t *testing.T) {
if version == "" {
t.Error("version should not be empty")
}
}

View File

@@ -1,7 +1,10 @@
package detect
import (
"errors"
"testing"
"github.com/gogrlx/snack"
)
func TestByNameUnknown(t *testing.T) {
@@ -9,15 +12,62 @@ func TestByNameUnknown(t *testing.T) {
if err == nil {
t.Fatal("expected error for unknown manager")
}
if !errors.Is(err, snack.ErrManagerNotFound) {
t.Errorf("expected ErrManagerNotFound, got %v", err)
}
}
func TestByNameKnown(t *testing.T) {
// All known manager names should be resolvable by ByName, even if
// unavailable on this system.
knownNames := []string{"apt", "dnf", "pacman", "apk", "flatpak", "snap", "aur"}
for _, name := range knownNames {
t.Run(name, func(t *testing.T) {
m, err := ByName(name)
if err != nil {
t.Fatalf("ByName(%q) returned error: %v", name, err)
}
if m.Name() != name {
t.Errorf("ByName(%q).Name() = %q", name, m.Name())
}
})
}
}
func TestByNameReturnsCorrectType(t *testing.T) {
m, err := ByName("apt")
if err != nil {
t.Skip("apt not in allManagers on this platform")
}
if m.Name() != "apt" {
t.Errorf("expected Name()=apt, got %q", m.Name())
}
}
func TestAllReturnsSlice(t *testing.T) {
// Just verify it doesn't panic; actual availability depends on system.
_ = All()
managers := All()
// On Linux with apt installed, we should get at least 1
// But don't fail if none — could be a weird CI environment
seen := make(map[string]bool)
for _, m := range managers {
name := m.Name()
if seen[name] {
t.Errorf("duplicate manager in All(): %s", name)
}
seen[name] = true
}
}
func TestAllManagersAreAvailable(t *testing.T) {
for _, m := range All() {
if !m.Available() {
t.Errorf("All() returned unavailable manager: %s", m.Name())
}
}
}
func TestDefaultDoesNotPanic(t *testing.T) {
// May return error if no managers available; that's fine.
Reset()
_, _ = Default()
}
@@ -37,9 +87,65 @@ func TestDefaultCachesResult(t *testing.T) {
}
}
func TestDefaultReturnsAvailableManager(t *testing.T) {
Reset()
m, err := Default()
if err != nil {
t.Skipf("no manager available on this system: %v", err)
}
if !m.Available() {
t.Error("Default() returned unavailable manager")
}
if m.Name() == "" {
t.Error("Default() returned manager with empty name")
}
}
func TestResetAllowsRedetection(t *testing.T) {
_, _ = Default()
Reset()
// After reset, defaultOnce should be fresh; calling Default() again should work.
// After reset, calling Default() again should work.
_, _ = Default()
}
func TestResetConcurrent(t *testing.T) {
done := make(chan struct{})
for i := 0; i < 10; i++ {
go func() {
defer func() { done <- struct{}{} }()
Reset()
_, _ = Default()
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
func TestHasBinary(t *testing.T) {
// sh should exist on any Unix system
if !HasBinary("sh") {
t.Error("expected HasBinary(sh) = true")
}
if HasBinary("this-binary-does-not-exist-anywhere-12345") {
t.Error("expected HasBinary(nonexistent) = false")
}
}
func TestCandidatesNotEmpty(t *testing.T) {
c := candidates()
if len(c) == 0 {
t.Error("candidates() returned empty slice")
}
}
func TestAllManagersNotEmpty(t *testing.T) {
a := allManagers()
if len(a) == 0 {
t.Error("allManagers() returned empty slice")
}
// allManagers should be a superset of candidates
if len(a) < len(candidates()) {
t.Error("allManagers() should include at least all candidates")
}
}

View File

@@ -52,6 +52,7 @@ func TestGetCapabilities(t *testing.T) {
{"Groups", caps.Groups},
{"NameNormalize", caps.NameNormalize},
{"DryRun", caps.DryRun},
{"PackageUpgrade", caps.PackageUpgrade},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -117,6 +117,7 @@ func TestCapabilities(t *testing.T) {
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"Groups", caps.Groups, false},
{"PackageUpgrade", caps.PackageUpgrade, false},
}
for _, c := range checks {
t.Run(c.name, func(t *testing.T) {

View File

@@ -400,6 +400,9 @@ func TestCapabilities(t *testing.T) {
if caps.NameNormalize {
t.Error("expected NameNormalize=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}
func TestName(t *testing.T) {

View File

@@ -173,6 +173,7 @@ func TestCapabilities(t *testing.T) {
{"RepoManagement", caps.RepoManagement, false},
{"KeyManagement", caps.KeyManagement, false},
{"NameNormalize", caps.NameNormalize, false},
{"PackageUpgrade", caps.PackageUpgrade, true},
}
for _, tt := range tests {

View File

@@ -665,4 +665,7 @@ func TestCapabilities(t *testing.T) {
if caps.DryRun {
t.Error("expected DryRun=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}

View File

@@ -802,4 +802,7 @@ func TestCapabilities(t *testing.T) {
if caps.DryRun {
t.Error("expected DryRun=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}

View File

@@ -44,6 +44,7 @@ func TestGetCapabilities(t *testing.T) {
"KeyManagement": caps.KeyManagement,
"Groups": caps.Groups,
"DryRun": caps.DryRun,
"PackageUpgrade": caps.PackageUpgrade,
}
for name, got := range wantFalse {
t.Run(name+"_false", func(t *testing.T) {

View File

@@ -444,6 +444,9 @@ func TestCapabilities(t *testing.T) {
if caps.NameNormalize {
t.Error("expected NameNormalize=false")
}
if !caps.PackageUpgrade {
t.Error("expected PackageUpgrade=true")
}
}
func TestName(t *testing.T) {