Files
kenaito-kopsc/internal/resolve/resolve_test.go
T
odboy 6b6f8619df feat: 优化非 Docker 命令的易用性,修复带 digest 镜像无法按名称查找
- docker save/rmi:镜像名不再拼接 digest({{.Digest}} 对多数镜像输出
  sha256:…,导致 alpine:3.20 被存成 alpine:3.20@sha256:… 而查不到),
  digest 单独保存;同时修正自定义 --format 输出中 IMAGE ID 表头未被过滤
- diagnose:目标不存在时给出相似名称候选,无相似时列出当前存在的对象
- net.pids:无进程占用时向 stderr 提示且 stdout 保持为空,新增 --pid-file
- net.ports:默认输出跨平台一致的端口表格,--raw 保留原生命令输出
- disk.usage:路径可省略(默认当前目录),新增 --depth N 控制层级
2026-11-01 08:37:44 +08:00

289 lines
7.9 KiB
Go

package resolve
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
)
func env(osName, arch, distro string, like []string, runtimes ...string) platform.Info {
return platform.Info{
OS: osName,
Arch: arch,
Distro: distro,
DistroLike: like,
Runtimes: runtimes,
}
}
func builtin(t *testing.T, id string) *registry.Command {
t.Helper()
cmd, ok := registry.Builtin().Lookup(id)
if !ok {
t.Fatalf("builtin command %q missing", id)
}
return cmd
}
func TestResolveBuiltin(t *testing.T) {
tests := []struct {
name string
id string
info platform.Info
wantArgv []string
wantScore int
wantIdx int
}{
{
name: "centos with docker",
id: "docker.clearsafe",
info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 0,
},
{
name: "generic linux with docker",
id: "docker.clearsafe",
info: env("linux", "amd64", "ubuntu", []string{"debian"}, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 0,
},
{
name: "rocky inherits the generic linux docker variant",
id: "docker.clearsafe",
info: env("linux", "amd64", "rocky", []string{"rhel", "centos"}, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 0,
},
{
name: "darwin docker",
id: "docker.clearsafe",
info: env("darwin", "arm64", "", nil, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 1,
},
{
name: "windows docker",
id: "docker.clearsafe",
info: env("windows", "amd64", "", nil, "docker"),
wantArgv: []string{"docker", "system", "prune", "-f"},
wantScore: 2,
wantIdx: 2,
},
{
name: "centos refreshes with dnf exactly",
id: "pkg.refreshindex",
info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}),
wantArgv: []string{"dnf", "makecache"},
wantScore: 3,
wantIdx: 0,
},
{
name: "rocky inherits rhel-like dnf",
id: "pkg.refreshindex",
info: env("linux", "amd64", "rocky", []string{"rhel"}),
wantArgv: []string{"dnf", "makecache"},
wantScore: 2,
wantIdx: 1,
},
{
name: "ubuntu resolves through ID_LIKE debian",
id: "pkg.refreshindex",
info: env("linux", "amd64", "ubuntu", []string{"debian"}),
wantArgv: []string{"apt-get", "update"},
wantScore: 2,
wantIdx: 2,
},
{
name: "alpine has its own distro variant",
id: "pkg.refreshindex",
info: env("linux", "amd64", "alpine", nil),
wantArgv: []string{"apk", "update"},
wantScore: 3,
wantIdx: 3,
},
{
name: "darwin homebrew",
id: "pkg.refreshindex",
info: env("darwin", "arm64", "", nil),
wantArgv: []string{"brew", "update"},
wantIdx: 4,
},
{
name: "windows winget",
id: "pkg.refreshindex",
info: env("windows", "amd64", "", nil),
wantArgv: []string{"winget", "source", "update"},
wantIdx: 5,
},
{
// net.ports is now a builtin: the resolver picks the platform
// variant and the builtin renders the table (or --raw output).
name: "linux listens with the builtin",
id: "net.ports",
info: env("linux", "amd64", "centos", []string{"rhel"}),
wantArgv: nil,
wantIdx: 0,
},
{
name: "darwin listens with the builtin",
id: "net.ports",
info: env("darwin", "amd64", "", nil),
wantArgv: nil,
wantIdx: 1,
},
{
name: "windows listens with the builtin",
id: "net.ports",
info: env("windows", "amd64", "", nil),
wantArgv: nil,
wantIdx: 2,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := Resolve(builtin(t, tc.id), tc.info)
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if !reflect.DeepEqual(got.Variant.Argv, tc.wantArgv) {
t.Errorf("argv = %v, want %v", got.Variant.Argv, tc.wantArgv)
}
if got.VariantIdx != tc.wantIdx {
t.Errorf("variant index = %d, want %d", got.VariantIdx, tc.wantIdx)
}
if got.Score != tc.wantScore {
t.Errorf("score = %d, want %d", got.Score, tc.wantScore)
}
if len(got.Candidates) != len(got.Command.Variants) {
t.Errorf("candidates = %d, want %d", len(got.Candidates), len(got.Command.Variants))
}
})
}
}
func TestResolveNoMatch(t *testing.T) {
info := env("linux", "amd64", "centos", []string{"rhel"}) // no container runtime on PATH
cmd := builtin(t, "docker.clearsafe")
_, err := Resolve(cmd, info)
if err == nil {
t.Fatal("expected an error without a container runtime")
}
if !errors.Is(err, ErrNoMatch) {
t.Fatalf("error = %v, want ErrNoMatch", err)
}
var noMatch *NoMatchError
if !errors.As(err, &noMatch) {
t.Fatalf("error type = %T, want *NoMatchError", err)
}
if len(noMatch.Candidates) != len(cmd.Variants) {
t.Errorf("candidates = %d, want %d", len(noMatch.Candidates), len(cmd.Variants))
}
for _, c := range noMatch.Candidates {
if c.Matched {
t.Errorf("candidate %v should not match", c.Variant.Argv)
}
if c.Reason == "" {
t.Errorf("candidate %v has no reason", c.Variant.Argv)
}
}
if !strings.Contains(err.Error(), "runtime") && !strings.Contains(err.Error(), "不支持") {
t.Errorf("error message lacks detail: %v", err)
}
}
func TestResolveAmbiguous(t *testing.T) {
cmd := &registry.Command{
ID: "demo.ambiguous",
Summary: "two equally generic variants",
Variants: []registry.Variant{
{Argv: []string{"one"}},
{Argv: []string{"two"}},
},
}
_, err := Resolve(cmd, env("linux", "amd64", "", nil))
if err == nil {
t.Fatal("expected an ambiguity error")
}
if !errors.Is(err, ErrAmbiguous) {
t.Fatalf("error = %v, want ErrAmbiguous", err)
}
var ambiguous *AmbiguousError
if !errors.As(err, &ambiguous) {
t.Fatalf("error type = %T, want *AmbiguousError", err)
}
}
func TestResolveArchMismatch(t *testing.T) {
cmd := &registry.Command{
ID: "demo.arch",
Summary: "arm64 only",
Variants: []registry.Variant{
{Match: registry.Match{OS: "linux", Arch: "arm64"}, Argv: []string{"arm"}},
},
}
_, err := Resolve(cmd, env("linux", "amd64", "", nil))
if !errors.Is(err, ErrNoMatch) {
t.Fatalf("error = %v, want ErrNoMatch", err)
}
}
func TestResolveSpecificBeatsGeneric(t *testing.T) {
cmd := &registry.Command{
ID: "demo.specific",
Summary: "specific and generic",
Variants: []registry.Variant{
{Argv: []string{"generic"}},
{Match: registry.Match{OS: "linux"}, Argv: []string{"linux"}},
},
}
got, err := Resolve(cmd, env("linux", "amd64", "centos", []string{"rhel"}))
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
// Both score 0 here, so the runtime tier and then declaration order decide;
// the important property is that a single variant wins.
if len(got.Variant.Argv) == 0 {
t.Fatal("no variant chosen")
}
}
func TestEvaluateRejects(t *testing.T) {
info := env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker")
cases := []struct {
name string
match registry.Match
want string
}{
{"os", registry.Match{OS: "darwin"}, "os=darwin"},
{"arch", registry.Match{Arch: "arm64"}, "arch=arm64"},
{"distro", registry.Match{Distro: "debian"}, "distro=debian"},
{"distro like", registry.Match{DistroLike: []string{"debian"}}, "ID_LIKE"},
{"runtime", registry.Match{Runtime: "cri-o"}, "cri-o"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, matched, reason := Evaluate(tc.match, info)
if matched {
t.Fatalf("match should fail: %+v", tc.match)
}
if !strings.Contains(reason, tc.want) {
t.Fatalf("reason %q does not contain %q", reason, tc.want)
}
})
}
}