diff --git a/pkg/testing/goldengen/classify.go b/pkg/testing/goldengen/classify.go new file mode 100644 index 00000000..40aabf5b --- /dev/null +++ b/pkg/testing/goldengen/classify.go @@ -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 +} diff --git a/pkg/testing/goldengen/classify_test.go b/pkg/testing/goldengen/classify_test.go new file mode 100644 index 00000000..f9295ee4 --- /dev/null +++ b/pkg/testing/goldengen/classify_test.go @@ -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) +} diff --git a/pkg/testing/goldengen/config.go b/pkg/testing/goldengen/config.go new file mode 100644 index 00000000..fdbfd085 --- /dev/null +++ b/pkg/testing/goldengen/config.go @@ -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 +} diff --git a/pkg/testing/goldengen/config_test.go b/pkg/testing/goldengen/config_test.go new file mode 100644 index 00000000..841ff032 --- /dev/null +++ b/pkg/testing/goldengen/config_test.go @@ -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()) + }) +} diff --git a/pkg/testing/goldengen/gating.go b/pkg/testing/goldengen/gating.go new file mode 100644 index 00000000..2d8a94ec --- /dev/null +++ b/pkg/testing/goldengen/gating.go @@ -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 +} diff --git a/pkg/testing/goldengen/gating_test.go b/pkg/testing/goldengen/gating_test.go new file mode 100644 index 00000000..85578692 --- /dev/null +++ b/pkg/testing/goldengen/gating_test.go @@ -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)) + }) +} diff --git a/pkg/testing/goldengen/generator.go b/pkg/testing/goldengen/generator.go new file mode 100644 index 00000000..2792be3e --- /dev/null +++ b/pkg/testing/goldengen/generator.go @@ -0,0 +1,126 @@ +package goldengen + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// Generator runs a version matrix: per-fixture gating assertions, minimal goldens, +// and a coverage manifest, all derived from one Config. +type Generator[T any] struct { + cfg Config[T] + update bool +} + +// New creates a Generator from a Config. +func New[T any](cfg Config[T]) *Generator[T] { + return &Generator[T]{cfg: cfg} +} + +// WithUpdate controls whether Run overwrites goldens and the manifest. Wire it to +// a -update test flag: gen.WithUpdate(*update). +func (g *Generator[T]) WithUpdate(enabled bool) *Generator[T] { + g.update = enabled + return g +} + +// fixtureSweep holds, per fixture, the per-version firing-sets and the built units +// used to render representatives. +type fixtureSweep struct { + firing map[string][]string // version -> firing-set + units map[string]Unit // version -> built unit +} + +// sweepFixture builds the fixture at every version, collecting its per-version +// firing-set and the built unit. +func (g *Generator[T]) sweepFixture(f Fixture[T]) (fixtureSweep, error) { + sw := fixtureSweep{firing: map[string][]string{}, units: map[string]Unit{}} + for _, v := range g.cfg.Versions { + unit, err := g.cfg.Build(v, f.Spec) + if err != nil { + return sw, fmt.Errorf("build fixture %q at %s: %w", f.Name, v, err) + } + firing, err := unit.FiringSet() + if err != nil { + return sw, fmt.Errorf("firing set for fixture %q at %s: %w", f.Name, v, err) + } + sw.firing[v] = firing + sw.units[v] = unit + } + return sw, nil +} + +// Run validates the config, asserts per-fixture gating, and writes (or compares) +// one golden per regime at