638 lines
13 KiB
Go
638 lines
13 KiB
Go
/*
|
|
©AngelaMos | 2026
|
|
version_test.go
|
|
|
|
Comprehensive PEP 440 version parsing and comparison tests for version.go
|
|
|
|
Tests:
|
|
TestParseVersion - full surface area: epochs, pre/post/dev, local, separators, implicit zeros, invalid input
|
|
TestVersionString - canonical round-trip form and normalization of spelled-out pre-release labels
|
|
TestVersionIsStable - stable/unstable classification across all pre-release and dev combinations
|
|
TestVersionCompare - total ordering across 15 versions from dev through post-release
|
|
TestVersionCompareEpoch - epoch takes priority over release segment value
|
|
TestVersionCompareImplicitZeros - 1.0, 1.0.0, and 1.0.0.0 compare as equal
|
|
TestClassifyChange - major/minor/patch classification from eight version pairs
|
|
TestLatestStable - selects highest stable version and skips pre-releases and unparseable strings
|
|
*/
|
|
|
|
package pypi
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
//nolint:gocognit,funlen
|
|
func TestParseVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want Version
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "simple three-segment",
|
|
input: "1.2.3",
|
|
want: Version{
|
|
Raw: "1.2.3", Release: []int{1, 2, 3},
|
|
Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "two segments",
|
|
input: "2.0",
|
|
want: Version{
|
|
Raw: "2.0", Release: []int{2, 0},
|
|
Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "single segment",
|
|
input: "42",
|
|
want: Version{
|
|
Raw: "42", Release: []int{42},
|
|
Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "leading v prefix",
|
|
input: "v1.0.0",
|
|
want: Version{
|
|
Raw: "v1.0.0", Release: []int{1, 0, 0},
|
|
Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "epoch",
|
|
input: "2!1.0",
|
|
want: Version{
|
|
Raw: "2!1.0", Epoch: 2, Release: []int{1, 0},
|
|
Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "alpha pre-release",
|
|
input: "1.0a1",
|
|
want: Version{
|
|
Raw: "1.0a1", Release: []int{1, 0},
|
|
PreKind: "a", PreNum: 1, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "beta pre-release",
|
|
input: "1.0b2",
|
|
want: Version{
|
|
Raw: "1.0b2", Release: []int{1, 0},
|
|
PreKind: "b", PreNum: 2, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "release candidate",
|
|
input: "1.0rc1",
|
|
want: Version{
|
|
Raw: "1.0rc1", Release: []int{1, 0},
|
|
PreKind: "rc", PreNum: 1, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "alpha spelled out",
|
|
input: "1.0alpha1",
|
|
want: Version{
|
|
Raw: "1.0alpha1", Release: []int{1, 0},
|
|
PreKind: "a", PreNum: 1, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "beta spelled out",
|
|
input: "1.0beta3",
|
|
want: Version{
|
|
Raw: "1.0beta3", Release: []int{1, 0},
|
|
PreKind: "b", PreNum: 3, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "preview normalizes to rc",
|
|
input: "1.0preview2",
|
|
want: Version{
|
|
Raw: "1.0preview2", Release: []int{1, 0},
|
|
PreKind: "rc", PreNum: 2, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "c normalizes to rc",
|
|
input: "1.0c1",
|
|
want: Version{
|
|
Raw: "1.0c1", Release: []int{1, 0},
|
|
PreKind: "rc", PreNum: 1, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "pre normalizes to rc",
|
|
input: "1.0pre4",
|
|
want: Version{
|
|
Raw: "1.0pre4", Release: []int{1, 0},
|
|
PreKind: "rc", PreNum: 4, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "pre-release with dot separator",
|
|
input: "1.0.a1",
|
|
want: Version{
|
|
Raw: "1.0.a1", Release: []int{1, 0},
|
|
PreKind: "a", PreNum: 1, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "pre-release with dash separator",
|
|
input: "1.0-alpha1",
|
|
want: Version{
|
|
Raw: "1.0-alpha1", Release: []int{1, 0},
|
|
PreKind: "a", PreNum: 1, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "pre-release with underscore separator",
|
|
input: "1.0_b2",
|
|
want: Version{
|
|
Raw: "1.0_b2", Release: []int{1, 0},
|
|
PreKind: "b", PreNum: 2, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "implicit pre-release zero",
|
|
input: "1.0a",
|
|
want: Version{
|
|
Raw: "1.0a", Release: []int{1, 0},
|
|
PreKind: "a", PreNum: 0, Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "post-release",
|
|
input: "1.0.post1",
|
|
want: Version{
|
|
Raw: "1.0.post1", Release: []int{1, 0},
|
|
Post: 1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "post-release implicit zero",
|
|
input: "1.0.post",
|
|
want: Version{
|
|
Raw: "1.0.post", Release: []int{1, 0},
|
|
Post: 0, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "implicit post via dash-number",
|
|
input: "1.0-1",
|
|
want: Version{
|
|
Raw: "1.0-1", Release: []int{1, 0},
|
|
Post: 1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "rev normalizes to post",
|
|
input: "1.0.rev2",
|
|
want: Version{
|
|
Raw: "1.0.rev2", Release: []int{1, 0},
|
|
Post: 2, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "r normalizes to post",
|
|
input: "1.0.r3",
|
|
want: Version{
|
|
Raw: "1.0.r3", Release: []int{1, 0},
|
|
Post: 3, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "dev release",
|
|
input: "1.0.dev1",
|
|
want: Version{
|
|
Raw: "1.0.dev1", Release: []int{1, 0},
|
|
Post: -1, Dev: 1,
|
|
},
|
|
},
|
|
{
|
|
name: "dev release implicit zero",
|
|
input: "1.0.dev",
|
|
want: Version{
|
|
Raw: "1.0.dev", Release: []int{1, 0},
|
|
Post: -1, Dev: 0,
|
|
},
|
|
},
|
|
{
|
|
name: "dev release with dash separator",
|
|
input: "1.0-dev3",
|
|
want: Version{
|
|
Raw: "1.0-dev3", Release: []int{1, 0},
|
|
Post: -1, Dev: 3,
|
|
},
|
|
},
|
|
{
|
|
name: "local version",
|
|
input: "1.0+ubuntu1",
|
|
want: Version{
|
|
Raw: "1.0+ubuntu1", Release: []int{1, 0},
|
|
Post: -1, Dev: -1, Local: "ubuntu1",
|
|
},
|
|
},
|
|
{
|
|
name: "full complex version",
|
|
input: "2!1.2.3a4.post5.dev6+local7",
|
|
want: Version{
|
|
Raw: "2!1.2.3a4.post5.dev6+local7",
|
|
Epoch: 2, Release: []int{1, 2, 3},
|
|
PreKind: "a", PreNum: 4,
|
|
Post: 5, Dev: 6, Local: "local7",
|
|
},
|
|
},
|
|
{
|
|
name: "post and dev combined",
|
|
input: "1.0.post1.dev2",
|
|
want: Version{
|
|
Raw: "1.0.post1.dev2", Release: []int{1, 0},
|
|
Post: 1, Dev: 2,
|
|
},
|
|
},
|
|
{
|
|
name: "pre and dev combined",
|
|
input: "1.0a1.dev1",
|
|
want: Version{
|
|
Raw: "1.0a1.dev1", Release: []int{1, 0},
|
|
PreKind: "a", PreNum: 1, Post: -1, Dev: 1,
|
|
},
|
|
},
|
|
{
|
|
name: "leading zeros normalized",
|
|
input: "1.01.010",
|
|
want: Version{
|
|
Raw: "1.01.010", Release: []int{1, 1, 10},
|
|
Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "many release segments",
|
|
input: "1.2.3.4.5",
|
|
want: Version{
|
|
Raw: "1.2.3.4.5", Release: []int{1, 2, 3, 4, 5},
|
|
Post: -1, Dev: -1,
|
|
},
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "garbage",
|
|
input: "not-a-version",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "just text",
|
|
input: "latest",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseVersion(tt.input)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf(
|
|
"ParseVersion(%q) succeeded, want error",
|
|
tt.input,
|
|
)
|
|
}
|
|
if !errors.Is(err, ErrInvalidVersion) {
|
|
t.Fatalf(
|
|
"ParseVersion(%q) err = %v, want ErrInvalidVersion",
|
|
tt.input, err,
|
|
)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("ParseVersion(%q) error: %v", tt.input, err)
|
|
}
|
|
|
|
if got.Epoch != tt.want.Epoch {
|
|
t.Errorf("Epoch = %d, want %d", got.Epoch, tt.want.Epoch)
|
|
}
|
|
if !intSliceEqual(got.Release, tt.want.Release) {
|
|
t.Errorf(
|
|
"Release = %v, want %v",
|
|
got.Release, tt.want.Release,
|
|
)
|
|
}
|
|
if got.PreKind != tt.want.PreKind {
|
|
t.Errorf(
|
|
"PreKind = %q, want %q",
|
|
got.PreKind, tt.want.PreKind,
|
|
)
|
|
}
|
|
if got.PreNum != tt.want.PreNum {
|
|
t.Errorf("PreNum = %d, want %d", got.PreNum, tt.want.PreNum)
|
|
}
|
|
if got.Post != tt.want.Post {
|
|
t.Errorf("Post = %d, want %d", got.Post, tt.want.Post)
|
|
}
|
|
if got.Dev != tt.want.Dev {
|
|
t.Errorf("Dev = %d, want %d", got.Dev, tt.want.Dev)
|
|
}
|
|
if got.Local != tt.want.Local {
|
|
t.Errorf(
|
|
"Local = %q, want %q",
|
|
got.Local, tt.want.Local,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVersionString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"1.0", "1.0"},
|
|
{"1.2.3", "1.2.3"},
|
|
{"2!1.0", "2!1.0"},
|
|
{"1.0a1", "1.0a1"},
|
|
{"1.0b2", "1.0b2"},
|
|
{"1.0rc1", "1.0rc1"},
|
|
{"1.0.post1", "1.0.post1"},
|
|
{"1.0.dev1", "1.0.dev1"},
|
|
{"1.0+local", "1.0+local"},
|
|
{"1.0alpha1", "1.0a1"},
|
|
{"1.0beta2", "1.0b2"},
|
|
{"1.0preview1", "1.0rc1"},
|
|
{"1.0c3", "1.0rc3"},
|
|
{"1.0pre1", "1.0rc1"},
|
|
{"v1.0.0", "1.0.0"},
|
|
{"1.0a", "1.0a0"},
|
|
{"1.0.post", "1.0.post0"},
|
|
{"1.0.dev", "1.0.dev0"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
v, err := ParseVersion(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseVersion(%q) error: %v", tt.input, err)
|
|
}
|
|
got := v.String()
|
|
if got != tt.want {
|
|
t.Errorf("String() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVersionIsStable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
input string
|
|
want bool
|
|
}{
|
|
{"1.0", true},
|
|
{"1.2.3", true},
|
|
{"1.0.post1", true},
|
|
{"2!1.0", true},
|
|
{"1.0+local", true},
|
|
{"1.0a1", false},
|
|
{"1.0b1", false},
|
|
{"1.0rc1", false},
|
|
{"1.0.dev1", false},
|
|
{"1.0a1.dev1", false},
|
|
{"1.0.post1.dev1", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
v, err := ParseVersion(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseVersion(%q) error: %v", tt.input, err)
|
|
}
|
|
if got := v.IsStable(); got != tt.want {
|
|
t.Errorf("IsStable() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVersionCompare(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ordered := []string{
|
|
"1.0.dev0",
|
|
"1.0.dev1",
|
|
"1.0a1.dev1",
|
|
"1.0a1",
|
|
"1.0a2",
|
|
"1.0b1",
|
|
"1.0b2",
|
|
"1.0rc1",
|
|
"1.0rc2",
|
|
"1.0",
|
|
"1.0.post0",
|
|
"1.0.post1",
|
|
"1.0.post2",
|
|
"1.1",
|
|
"2.0",
|
|
}
|
|
|
|
versions := make([]Version, len(ordered))
|
|
for i, s := range ordered {
|
|
v, err := ParseVersion(s)
|
|
if err != nil {
|
|
t.Fatalf("ParseVersion(%q) error: %v", s, err)
|
|
}
|
|
versions[i] = v
|
|
}
|
|
|
|
for i := range len(versions) {
|
|
for j := range len(versions) {
|
|
got := versions[i].Compare(versions[j])
|
|
var want int
|
|
switch {
|
|
case i < j:
|
|
want = -1
|
|
case i > j:
|
|
want = 1
|
|
default:
|
|
want = 0
|
|
}
|
|
|
|
if (want < 0 && got >= 0) ||
|
|
(want > 0 && got <= 0) ||
|
|
(want == 0 && got != 0) {
|
|
t.Errorf(
|
|
"Compare(%s, %s) = %d, want sign %d",
|
|
ordered[i], ordered[j], got, want,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVersionCompareEpoch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
low, err := ParseVersion("99.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
high, err := ParseVersion("2!1.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := low.Compare(high); got >= 0 {
|
|
t.Errorf("99.0 should be less than 2!1.0, got %d", got)
|
|
}
|
|
if got := high.Compare(low); got <= 0 {
|
|
t.Errorf("2!1.0 should be greater than 99.0, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestVersionCompareImplicitZeros(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a, err := ParseVersion("1.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := ParseVersion("1.0.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := ParseVersion("1.0.0.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := a.Compare(b); got != 0 {
|
|
t.Errorf("1.0 vs 1.0.0 = %d, want 0", got)
|
|
}
|
|
if got := b.Compare(c); got != 0 {
|
|
t.Errorf("1.0.0 vs 1.0.0.0 = %d, want 0", got)
|
|
}
|
|
if got := a.Compare(c); got != 0 {
|
|
t.Errorf("1.0 vs 1.0.0.0 = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestClassifyChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
from string
|
|
to string
|
|
want ChangeKind
|
|
}{
|
|
{"1.0.0", "1.0.1", Patch},
|
|
{"1.0.0", "1.1.0", Minor},
|
|
{"1.0.0", "2.0.0", Major},
|
|
{"1.0", "1.1", Minor},
|
|
{"1.0", "2.0", Major},
|
|
{"3.2.0", "4.2.8", Major},
|
|
{"2.28.1", "2.31.0", Minor},
|
|
{"7.4.0", "7.4.3", Patch},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.from+"->"+tt.to, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
from, err := ParseVersion(tt.from)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
to, err := ParseVersion(tt.to)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := ClassifyChange(from, to); got != tt.want {
|
|
t.Errorf(
|
|
"ClassifyChange(%s, %s) = %s, want %s",
|
|
tt.from, tt.to, got, tt.want,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLatestStable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
versions := []string{
|
|
"1.0",
|
|
"1.1",
|
|
"1.2a1",
|
|
"1.2b1",
|
|
"1.2rc1",
|
|
"1.2.dev1",
|
|
"1.1.1",
|
|
"1.1.2",
|
|
"0.9",
|
|
}
|
|
|
|
got, err := LatestStable(versions)
|
|
if err != nil {
|
|
t.Fatalf("LatestStable() error: %v", err)
|
|
}
|
|
if got.String() != "1.1.2" {
|
|
t.Errorf("LatestStable() = %s, want 1.1.2", got.String())
|
|
}
|
|
}
|
|
|
|
func TestLatestStableNoStable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
versions := []string{"1.0a1", "1.0b1", "1.0.dev1"}
|
|
_, err := LatestStable(versions)
|
|
if err == nil {
|
|
t.Fatal("LatestStable() should fail when no stable versions exist")
|
|
}
|
|
}
|
|
|
|
func TestLatestStableSkipsInvalid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
versions := []string{"not-a-version", "also-bad", "1.0", "???"}
|
|
got, err := LatestStable(versions)
|
|
if err != nil {
|
|
t.Fatalf("LatestStable() error: %v", err)
|
|
}
|
|
if got.String() != "1.0" {
|
|
t.Errorf("LatestStable() = %s, want 1.0", got.String())
|
|
}
|
|
}
|
|
|
|
func intSliceEqual(a, b []int) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|