Skip to content
Merged
41 changes: 41 additions & 0 deletions pkg/testing/goldengen/classify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package goldengen

import (
"sort"
"strings"
)

// Regime is a group of swept versions sharing an identical firing-set.
type Regime struct {
// Representative is the first version in supplied order within the group.
Representative string
// Versions are all versions in this regime, in supplied order.
Versions []string
// Firing is the shared firing-set, sorted.
Firing []string
}

// ClassifyRegimes groups versions (in supplied order) by identical firing-set.
// Two versions belong to the same regime when their firing-sets are equal as sets,
// independent of order. Regimes are returned in order of first appearance; the
// representative of each is the first version in supplied order belonging to it.
func ClassifyRegimes(versions []string, firing map[string][]string) []Regime {
index := make(map[string]int) // signature -> regime index
regimes := make([]Regime, 0)
for _, v := range versions {
sorted := append([]string(nil), firing[v]...)
sort.Strings(sorted)
sig := strings.Join(sorted, "\x00")
if i, ok := index[sig]; ok {
regimes[i].Versions = append(regimes[i].Versions, v)
continue
}
index[sig] = len(regimes)
regimes = append(regimes, Regime{
Representative: v,
Versions: []string{v},
Firing: sorted,
})
}
return regimes
}
44 changes: 44 additions & 0 deletions pkg/testing/goldengen/classify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package goldengen_test

import (
"testing"

"github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestClassifyRegimes(t *testing.T) {
versions := []string{"1.0.0", "1.1.0", "2.0.0"}
firing := map[string][]string{
"1.0.0": {"A"},
"1.1.0": {"A"}, // same regime as 1.0.0
"2.0.0": {"A", "B"}, // new regime
}
regimes := goldengen.ClassifyRegimes(versions, firing)
require.Len(t, regimes, 2)
assert.Equal(t, "1.0.0", regimes[0].Representative)
assert.Equal(t, []string{"1.0.0", "1.1.0"}, regimes[0].Versions)
assert.Equal(t, []string{"A"}, regimes[0].Firing)
assert.Equal(t, "2.0.0", regimes[1].Representative)
assert.Equal(t, []string{"2.0.0"}, regimes[1].Versions)
assert.Equal(t, []string{"A", "B"}, regimes[1].Firing)
}

func TestClassifyOrderIndependentSignature(t *testing.T) {
// firing-set order must not split a regime.
versions := []string{"1.0.0", "2.0.0"}
firing := map[string][]string{"1.0.0": {"A", "B"}, "2.0.0": {"B", "A"}}
regimes := goldengen.ClassifyRegimes(versions, firing)
require.Len(t, regimes, 1)
assert.Equal(t, []string{"A", "B"}, regimes[0].Firing)
}

func TestClassifyEmptyFiringSet(t *testing.T) {
versions := []string{"1.0.0", "2.0.0"}
firing := map[string][]string{"1.0.0": {}, "2.0.0": nil}
regimes := goldengen.ClassifyRegimes(versions, firing)
require.Len(t, regimes, 1)
assert.Equal(t, "1.0.0", regimes[0].Representative)
assert.Empty(t, regimes[0].Firing)
}
94 changes: 94 additions & 0 deletions pkg/testing/goldengen/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package goldengen

import (
"fmt"

"k8s.io/apimachinery/pkg/runtime"
)

// Expect is an assertion that a named mutation fires (Requires) or does not fire
// (Forbids) over a fixture's version sweep. For is optional; when set it must be a
// concrete version drawn from Config.Versions and pins the assertion to that
// version instead of quantifying over the whole sweep.
type Expect struct {
// Name is the registered mutation Name the expectation applies to.
Name string
// For optionally pins the expectation to a single version from Config.Versions.
For string
}

// Fixture is one spec and the gating expectations the author asserts for it.
type Fixture[T any] struct {
// Name identifies the fixture and names its golden subdirectory.
Name string
// Spec is the input passed to Config.Build for every version.
Spec T
// Requires lists mutations that must fire, existentially or pinned via For.
Requires []Expect
// Forbids lists mutations that must not fire, existentially or pinned via For.
Forbids []Expect
}

// Config declares the whole version matrix: the versions to sweep, the fixtures to
// build, the scheme to serialize with, and the Build function that materializes a
// Unit from a fixture spec at a version.
type Config[T any] struct {
// Dir is the root directory for generated goldens and the manifest.
Dir string
// Versions is the version universe to sweep, in supplied order. Representative
// selection uses this order.
Versions []string
// Exclude lists registered mutation Names deliberately left unaccounted by the
// completeness check. It does not affect gating or golden generation.
Exclude []string
// Scheme resolves TypeMeta when serializing rendered objects.
Scheme *runtime.Scheme
// Fixtures are the specs to build and assert across the version sweep.
Fixtures []Fixture[T]
// Build materializes a Unit from a fixture spec at a version.
Build func(version string, spec T) (Unit, error)
}

// Validate checks the static invariants of the config before any sweep runs:
// non-empty Versions, a non-nil Build, a non-empty Dir, unique non-empty fixture
// names, non-empty expectation names, and every Expect.For being a member of
// Versions.
func (c Config[T]) Validate() error {
if len(c.Versions) == 0 {
return fmt.Errorf("goldengen: Versions must not be empty")
}
if c.Build == nil {
return fmt.Errorf("goldengen: Build must not be nil")
}
if c.Dir == "" {
return fmt.Errorf("goldengen: Dir must not be empty")
}

known := make(map[string]struct{}, len(c.Versions))
for _, v := range c.Versions {
known[v] = struct{}{}
}

seenFixture := make(map[string]struct{}, len(c.Fixtures))
for _, f := range c.Fixtures {
if f.Name == "" {
return fmt.Errorf("goldengen: fixture name must not be empty")
}
if _, dup := seenFixture[f.Name]; dup {
return fmt.Errorf("goldengen: duplicate fixture name %q", f.Name)
}
seenFixture[f.Name] = struct{}{}

for _, e := range append(append([]Expect{}, f.Requires...), f.Forbids...) {
if e.Name == "" {
return fmt.Errorf("goldengen: fixture %q has an expectation with an empty Name", f.Name)
}
if e.For != "" {
if _, ok := known[e.For]; !ok {
return fmt.Errorf("goldengen: fixture %q expectation %q has For %q not in Versions", f.Name, e.Name, e.For)
}
}
}
}
return nil
}
75 changes: 75 additions & 0 deletions pkg/testing/goldengen/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package goldengen_test

import (
"testing"

"github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
)

func TestConfigValidate(t *testing.T) {
valid := goldengen.Config[*corev1.ConfigMap]{
Dir: "td",
Versions: []string{"1.0.0", "2.0.0"},
Scheme: testScheme(),
Fixtures: []goldengen.Fixture[*corev1.ConfigMap]{{
Name: "default", Spec: &corev1.ConfigMap{},
Requires: []goldengen.Expect{{Name: "A"}, {Name: "B", For: "2.0.0"}},
}},
Build: func(string, *corev1.ConfigMap) (goldengen.Unit, error) { return nil, nil },
}
require.NoError(t, valid.Validate())

t.Run("for not in versions", func(t *testing.T) {
bad := valid
bad.Fixtures = []goldengen.Fixture[*corev1.ConfigMap]{{
Name: "default", Spec: &corev1.ConfigMap{},
Requires: []goldengen.Expect{{Name: "B", For: "9.9.9"}},
}}
err := bad.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "9.9.9")
})

t.Run("empty versions", func(t *testing.T) {
bad := valid
bad.Versions = nil
require.Error(t, bad.Validate())
})

t.Run("nil build", func(t *testing.T) {
bad := valid
bad.Build = nil
require.Error(t, bad.Validate())
})

t.Run("empty dir", func(t *testing.T) {
bad := valid
bad.Dir = ""
require.Error(t, bad.Validate())
})

t.Run("duplicate fixture name", func(t *testing.T) {
bad := valid
bad.Fixtures = append([]goldengen.Fixture[*corev1.ConfigMap]{}, valid.Fixtures...)
bad.Fixtures = append(bad.Fixtures, bad.Fixtures[0])
require.Error(t, bad.Validate())
})

t.Run("empty fixture name", func(t *testing.T) {
bad := valid
bad.Fixtures = []goldengen.Fixture[*corev1.ConfigMap]{{Name: "", Spec: &corev1.ConfigMap{}}}
require.Error(t, bad.Validate())
})

t.Run("empty expectation name", func(t *testing.T) {
bad := valid
bad.Fixtures = []goldengen.Fixture[*corev1.ConfigMap]{{
Name: "default", Spec: &corev1.ConfigMap{},
Forbids: []goldengen.Expect{{Name: ""}},
}}
require.Error(t, bad.Validate())
})
}
58 changes: 58 additions & 0 deletions pkg/testing/goldengen/gating.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package goldengen

import "fmt"

// firesAt reports whether name is in the firing-set at version v.
func firesAt(firing map[string][]string, v, name string) bool {
for _, n := range firing[v] {
if n == name {
return true
}
}
return false
}

// firesSomewhere reports whether name is in the firing-set at any swept version.
func firesSomewhere(firing map[string][]string, versions []string, name string) bool {
for _, v := range versions {
if firesAt(firing, v, name) {
return true
}
}
return false
}

// CheckGating verifies a fixture's Requires/Forbids expectations against its
// per-version firing-sets. The lattice is:
//
// - Requires with empty For: the name fires at some swept version.
// - Requires with For=v: the name fires at v.
// - Forbids with empty For: the name fires at no swept version.
// - Forbids with For=v: the name does not fire at v.
//
// It returns a descriptive error on the first violation.
func CheckGating[T any](f Fixture[T], versions []string, firing map[string][]string) error {
for _, e := range f.Requires {
if e.For == "" {
if !firesSomewhere(firing, versions, e.Name) {
return fmt.Errorf("fixture %q: required mutation %q never fires across the version sweep", f.Name, e.Name)
}
continue
}
if !firesAt(firing, e.For, e.Name) {
return fmt.Errorf("fixture %q: required mutation %q does not fire at %s", f.Name, e.Name, e.For)
}
}
for _, e := range f.Forbids {
if e.For == "" {
if firesSomewhere(firing, versions, e.Name) {
return fmt.Errorf("fixture %q: forbidden mutation %q fires somewhere across the version sweep", f.Name, e.Name)
}
continue
}
if firesAt(firing, e.For, e.Name) {
return fmt.Errorf("fixture %q: forbidden mutation %q fires at %s", f.Name, e.Name, e.For)
}
}
return nil
}
53 changes: 53 additions & 0 deletions pkg/testing/goldengen/gating_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package goldengen_test

import (
"testing"

"github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCheckGating(t *testing.T) {
firing := map[string][]string{ // version -> firing-set
"8.8.0": {"Always", "Pre89"},
"8.9.0": {"Always", "Unified89"},
}
f := goldengen.Fixture[int]{
Name: "default",
Requires: []goldengen.Expect{
{Name: "Always"}, // existential: fires somewhere
{Name: "Unified89", For: "8.9.0"}, // pinned
{Name: "Pre89", For: "8.8.0"},
},
Forbids: []goldengen.Expect{
{Name: "Unified89", For: "8.8.0"}, // not before boundary
{Name: "Pre89", For: "8.9.0"}, // not after boundary
},
}
require.NoError(t, goldengen.CheckGating(f, []string{"8.8.0", "8.9.0"}, firing))
}

func TestCheckGatingFailures(t *testing.T) {
firing := map[string][]string{"8.9.0": {"Always"}}
versions := []string{"8.9.0"}

t.Run("required existential missing", func(t *testing.T) {
f := goldengen.Fixture[int]{Name: "f", Requires: []goldengen.Expect{{Name: "Ghost"}}}
err := goldengen.CheckGating(f, versions, firing)
require.Error(t, err)
assert.Contains(t, err.Error(), "Ghost")
})
t.Run("required pinned missing", func(t *testing.T) {
f := goldengen.Fixture[int]{Name: "f", Requires: []goldengen.Expect{{Name: "Ghost", For: "8.9.0"}}}
require.Error(t, goldengen.CheckGating(f, versions, firing))
})
t.Run("forbidden existential fires", func(t *testing.T) {
f := goldengen.Fixture[int]{Name: "f", Forbids: []goldengen.Expect{{Name: "Always"}}}
require.Error(t, goldengen.CheckGating(f, versions, firing))
})
t.Run("forbidden pinned fires", func(t *testing.T) {
f := goldengen.Fixture[int]{Name: "f", Forbids: []goldengen.Expect{{Name: "Always", For: "8.9.0"}}}
require.Error(t, goldengen.CheckGating(f, versions, firing))
})
}
Loading
Loading