diff --git a/README.md b/README.md
index 37f7e669..08dfd271 100644
--- a/README.md
+++ b/README.md
@@ -401,6 +401,7 @@ See the [Custom Resource Implementation Guide](docs/custom-resource.md) for a co
| [Custom Resources](docs/custom-resource.md) | Implementing custom resource wrappers using the generic building blocks |
| [Guidelines](docs/guidelines.md) | Recommended patterns for structuring operators built with the framework |
| [Compatibility](docs/compatibility.md) | Supported Kubernetes and controller-runtime versions, version policy |
+| [Testing](docs/testing.md) | Golden snapshots and version-matrix golden generation |
## Contributing
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 00000000..22f2eb64
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,307 @@
+# Testing
+
+The framework ships two test-only packages for asserting the desired state your resources and components produce:
+
+- `pkg/testing/golden` snapshots a single build to a YAML golden file and compares against it on every run.
+- `pkg/testing/goldengen` sweeps a version universe over one or more fixtures, classifies the swept versions into gating
+ regimes, generates the minimal set of goldens covering them, asserts which mutations fire at which version, and proves
+ every registered mutation is accounted for.
+
+Reach for `golden` when you want to pin the output of one build. Reach for `goldengen` when a resource carries
+version-gated mutations and you want one golden per behavior rather than one per version, with the gating asserted
+explicitly.
+
+Both packages are opt-in and import nothing into the reconcile path. A consumer that does not import them pays nothing.
+
+## Golden snapshots
+
+`golden` renders a built primitive or component to canonical YAML and compares it against a checked-in file. The
+serialization resolves `TypeMeta` (from the object or a supplied scheme) and strips zero-value noise fields, so the
+golden reflects only the meaningful desired state.
+
+### Assert a single resource
+
+`AssertYAML` previews a built primitive, serializes it, and fails the test on any difference from the golden file. Wire
+a `-update` flag to regenerate the golden when the desired state legitimately changes.
+
+```go
+var update = flag.Bool("update", false, "update golden files")
+
+func TestDeploymentGolden(t *testing.T) {
+ res, err := deployment.NewBuilder(baseDeployment()).
+ WithMutation(features.DebugLogging(true)).
+ Build()
+ require.NoError(t, err)
+
+ golden.AssertYAML(t, "testdata/deployment.yaml", res,
+ golden.WithScheme(scheme), golden.Update(*update))
+}
+```
+
+`golden.WithScheme(scheme)` resolves `apiVersion` and `kind` for objects that do not populate `TypeMeta`. Without it,
+serialization of such an object fails. `golden.Update(*update)` overwrites the golden file (creating intermediate
+directories) instead of comparing, so `go test ./... -update` refreshes every golden in one pass.
+
+### Assert a component
+
+`AssertComponentYAML` previews every resource a component would apply and serializes them into one multi-document YAML
+stream (`---` separated, in apply order).
+
+```go
+func TestComponentGolden(t *testing.T) {
+ c, err := buildComponent(owner)
+ require.NoError(t, err)
+
+ golden.AssertComponentYAML(t, "testdata/component.yaml", c,
+ golden.WithScheme(scheme), golden.Update(*update))
+}
+```
+
+Both helpers have non-`testing.T` variants, `CompareYAML` and `CompareComponentYAML`, that return a `*MismatchError`
+(carrying a unified diff) instead of failing a test, for use outside a test body.
+
+### Serialize out of band
+
+When you need the canonical YAML bytes directly, for example to feed a custom comparison or to generate goldens from a
+tool, call the serializers the assertions use:
+
+```go
+data, err := golden.Serialize(obj, scheme) // one object
+stream, err := golden.SerializeComponent(objs, scheme) // multi-document stream
+```
+
+`goldengen` is built on exactly these two functions.
+
+## Version matrix generation
+
+A resource with version-gated mutations behaves differently across versions, but not at every version: it changes only
+where a gate flips. Asserting one golden per version is wasteful and obscures where behavior actually changes.
+`goldengen` sweeps the versions you supply, groups them by which mutations fire, and writes one golden per distinct
+group.
+
+The worked example lives at [`examples/version-matrix`](../examples/version-matrix). The walkthrough below follows it.
+
+### Declare the matrix
+
+A `Config[T]` declares the whole matrix. `T` is your fixture spec type (a custom resource, or any value your build
+function accepts).
+
+```go
+var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{
+ Dir: "testdata/version_matrix",
+ Versions: []string{"8.7.0", "8.8.2", "8.9.0"},
+ Scheme: scheme,
+ Fixtures: []goldengen.Fixture[*app.ExampleApp]{{
+ Name: "default",
+ Spec: defaultCluster(),
+ Requires: []goldengen.Expect{
+ {Name: "ContainerImage"},
+ {Name: "ClusterEnv/Pre89", For: "8.8.2"},
+ {Name: "ClusterEnv/Unified89", For: "8.9.0"},
+ },
+ Forbids: []goldengen.Expect{
+ {Name: "ClusterEnv/Unified89", For: "8.8.2"},
+ {Name: "ClusterEnv/Pre89", For: "8.9.0"},
+ },
+ }},
+ Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) {
+ c := spec.DeepCopyObject().(*app.ExampleApp)
+ c.Spec.Version = version
+ res, err := resources.NewStatefulSetResource(c)
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Resource(res, scheme), nil
+ },
+})
+```
+
+The fields:
+
+- **`Dir`** roots the generated goldens and the manifest.
+- **`Versions`** is the version universe to sweep, in the order you supply (see [version ordering](#version-ordering)).
+- **`Scheme`** resolves `TypeMeta` when serializing, exactly as `golden.WithScheme` does.
+- **`Fixtures`** are the specs to build and assert. Each names its own golden subdirectory.
+- **`Exclude`** (omitted above) lists registered mutation names you deliberately leave unasserted, so they do not fail
+ the [completeness check](#completeness-accounting). It does not affect gating or golden generation.
+- **`Build`** materializes a `Unit` from a fixture spec at a version. It must apply the version to the spec so the gates
+ evaluate against it. Copy the spec before mutating it, since `Build` is called once per version for the same fixture.
+
+`Build` returns a `Unit`, the introspectable-and-renderable handle the generator works with. Adapt a built primitive
+with `goldengen.Resource(res, scheme)` or a built component with `goldengen.Component(comp, scheme)`. Both delegate
+rendering to `golden.Serialize` / `golden.SerializeComponent`.
+
+### Run the sweep
+
+Wire a `-update` flag through `WithUpdate` and call `Run` from a normal test:
+
+```go
+var update = flag.Bool("update", false, "update golden files")
+
+func TestVersionMatrix(t *testing.T) {
+ gen.WithUpdate(*update)
+ gen.Run(t)
+}
+```
+
+`Run` validates the config, builds every fixture at every version, asserts the gating, then writes (under `-update`) or
+compares one golden per regime plus the manifest. Generate the goldens once, inspect them, then commit:
+
+```bash
+go test ./examples/version-matrix/ -run TestVersionMatrix -update # generate
+go test ./examples/version-matrix/ # verify
+```
+
+### Firing-set classification
+
+The firing set at a version is the set of registered mutations whose gate is enabled there (a mutation with no gate
+fires unconditionally). A **regime** is a maximal group of swept versions sharing an identical firing set. `goldengen`
+writes one golden per regime, named after the regime's representative, instead of one golden per version.
+
+In the example, the universe `8.7.0`, `8.8.2`, `8.9.0` collapses to two regimes:
+
+```mermaid
+flowchart LR
+ v1["8.7.0"] --> r1
+ v2["8.8.2"] --> r1
+ v3["8.9.0"] --> r2
+ r1["regime: ContainerImage + ClusterEnv/Pre89
golden: default/8.7.0.yaml"]
+ r2["regime: ContainerImage + ClusterEnv/Unified89
golden: default/8.9.0.yaml"]
+```
+
+`8.7.0` and `8.8.2` fire the same set, so they share one golden; `8.9.0` crosses the `ClusterEnv` boundary into its own
+regime. Two goldens cover three versions, and adding more versions inside an existing regime adds no goldens.
+
+### Version ordering
+
+The representative of a regime is the first version in supplied order that belongs to it. Listing `Versions` ascending
+therefore puts each representative on the **lower inclusive boundary** of its gating range, so the golden's filename
+marks exactly where the regime begins. In the example, `default/8.9.0.yaml` is named for the first version at which the
+unified-discovery regime takes effect. List versions ascending unless you have a specific reason not to.
+
+### The four assertions
+
+Per fixture you assert gating with `Requires` and `Forbids`, each a list of `Expect{Name, For}`. `For` is optional; when
+set it must be a version drawn from `Versions`.
+
+| Assertion | `For` set | Meaning |
+| --------------------- | --------- | ---------------------------------------------- |
+| `Requires{Name}` | no | the mutation fires at **some** swept version |
+| `Requires{Name, For}` | yes | the mutation fires **at that version** |
+| `Forbids{Name}` | no | the mutation fires at **no** swept version |
+| `Forbids{Name, For}` | yes | the mutation **does not** fire at that version |
+
+Pin both sides of a boundary to assert it precisely: in the example `ClusterEnv/Unified89` is required at `8.9.0` and
+forbidden at `8.8.2`, which locks the gate to exactly the `8.9.0` boundary rather than merely "fires somewhere".
+
+### Completeness accounting
+
+`AssertComplete` proves no registered mutation slips through unasserted. Call it from `TestMain`, passing the result of
+`m.Run()`:
+
+```go
+func TestMain(m *testing.M) {
+ os.Exit(gen.AssertComplete(m.Run()))
+}
+```
+
+Accounting holds when the universe of registered mutation names across all fixtures equals
+`union(Requires names) ∪ Exclude`. `AssertComplete` returns the incoming code unchanged when the tests already failed (a
+nonzero code) or when accounting holds; otherwise it prints the violations to stderr and returns a nonzero code. The
+violations are:
+
+- a registered mutation that is neither required by a fixture nor listed in `Exclude` (an unasserted mutation),
+- a name in `Requires` or `Exclude` that no fixture actually registers (a stale assertion), and
+- a registered mutation with an empty name.
+
+The effect: registering a new version-gated mutation fails the suite until you either assert it with a `Requires` or
+deliberately set it aside with `Exclude`.
+
+### The manifest
+
+Alongside the goldens, `Run` writes `
/manifest.yaml`, a reviewable coverage map: per fixture, each regime with its
+representative version, the versions it covers, and the shared firing set.
+
+```yaml
+fixtures:
+ - name: default
+ regimes:
+ - representative: 8.7.0
+ versions:
+ - 8.7.0
+ - 8.8.2
+ firing:
+ - ClusterEnv/Pre89
+ - ContainerImage
+ - representative: 8.9.0
+ versions:
+ - 8.9.0
+ firing:
+ - ClusterEnv/Unified89
+ - ContainerImage
+```
+
+Reviewing the manifest diff in a pull request shows at a glance how the gating coverage changed: a new regime, a moved
+boundary, or a mutation that started or stopped firing.
+
+## YAML matrix loader
+
+The matrix can be declared in YAML instead of Go, keeping the version universe and fixtures as data while the build and
+scheme stay in code. `LoadMatrix` reads the file and returns a ready-to-run `Config[T]`:
+
+```go
+func LoadMatrix[T any](
+ path string,
+ newSpec func() T,
+ build func(version string, spec T) (Unit, error),
+ scheme *runtime.Scheme,
+) (Config[T], error)
+```
+
+`newSpec` returns a fresh, empty spec to unmarshal each fixture into; `build` and `scheme` are the same callbacks you
+would set on a Go `Config`. The returned config is validated before it is returned.
+
+A matrix file mirrors `Config` minus the Go-only `build` and `scheme`. Each fixture supplies its spec either inline
+under `spec:` or from an external file under `specFile:` (resolved relative to the matrix file), exactly one of the two:
+
+```yaml
+dir: testdata/version_matrix
+versions:
+ - "8.7.0"
+ - "8.8.2"
+ - "8.9.0"
+exclude: []
+fixtures:
+ - name: default
+ spec: # inline custom resource
+ apiVersion: apps.example.io/v1
+ kind: ExampleApp
+ metadata:
+ name: demo
+ namespace: default
+ spec:
+ version: 8.7.0
+ requires:
+ - { name: ContainerImage }
+ - { name: ClusterEnv/Pre89, for: "8.8.2" }
+ - { name: ClusterEnv/Unified89, for: "8.9.0" }
+ forbids:
+ - { name: ClusterEnv/Unified89, for: "8.8.2" }
+ - name: tls
+ specFile: fixtures/tls.yaml # external custom resource
+ requires:
+ - { name: ContainerImage }
+```
+
+```go
+cfg, err := goldengen.LoadMatrix("testdata/matrix.yaml",
+ func() *app.ExampleApp { return &app.ExampleApp{} },
+ buildUnit, scheme)
+require.NoError(t, err)
+
+gen := goldengen.New(cfg).WithUpdate(*update)
+gen.Run(t)
+```
+
+`LoadMatrix` errors if a fixture sets both `spec` and `specFile` or neither, if a `for` value is not in `versions`, or
+if any spec fails to unmarshal into `T`.
diff --git a/examples/version-matrix/README.md b/examples/version-matrix/README.md
new file mode 100644
index 00000000..4dafb5f2
--- /dev/null
+++ b/examples/version-matrix/README.md
@@ -0,0 +1,50 @@
+# Version Matrix
+
+This example demonstrates the `pkg/testing/goldengen` helper: sweeping a version universe over a single fixture,
+classifying the swept versions into behaviorally-distinct gating regimes, generating one golden per regime, asserting
+the gating, and proving every registered mutation is accounted for.
+
+## What it shows
+
+- **One build, swept across versions**: `resources.NewStatefulSetResource` builds a StatefulSet with three mutations.
+ The owner's `Spec.Version` drives every gate, so wiring that build through `goldengen.Config.Build` and listing a
+ version universe produces a distinct golden per gating regime instead of one golden per version.
+- **Version-gated mutations**:
+ - `ContainerImage` has no gate, so it fires at every version and anchors the always-on part of the firing set.
+ - `ClusterEnv/Pre89` fires for versions `< 8.9.0` (legacy gossip discovery).
+ - `ClusterEnv/Unified89` fires for versions `>= 8.9.0` (unified raft discovery).
+- **Firing-set classification**: The version universe `8.7.0`, `8.8.2`, `8.9.0` collapses to two regimes:
+ `{ContainerImage, ClusterEnv/Pre89}` covering `8.7.0` and `8.8.2`, and `{ContainerImage, ClusterEnv/Unified89}`
+ covering `8.9.0`. Only two goldens are written, one per regime, named by the regime's representative version.
+- **Ascending version order**: Listing `Versions` ascending puts each regime's representative on the lower inclusive
+ boundary of its gating range, so the golden's filename marks exactly where the regime begins.
+- **Gating assertions**: `Requires`/`Forbids` pin which mutation fires (or does not) at which version. The boundary is
+ asserted from both sides: `ClusterEnv/Unified89` is required at `8.9.0` and forbidden at `8.8.2`.
+- **Completeness accounting**: `TestMain` calls `gen.AssertComplete(m.Run())`, which fails the package if any registered
+ mutation is neither required by a fixture nor listed in `Exclude`. Adding a fourth mutation without asserting it would
+ break this test.
+
+## Generated artifacts
+
+```
+testdata/version_matrix/
+ manifest.yaml # per-fixture regimes: representative, versions, firing-set
+ default/8.7.0.yaml # regime representative for { ContainerImage, ClusterEnv/Pre89 }
+ default/8.9.0.yaml # regime representative for { ContainerImage, ClusterEnv/Unified89 }
+```
+
+## Running
+
+Generate or refresh the goldens and the manifest:
+
+```bash
+go test ./examples/version-matrix/ -run TestVersionMatrix -update
+```
+
+Verify against the committed goldens:
+
+```bash
+go test ./examples/version-matrix/
+```
+
+See [docs/testing.md](../../docs/testing.md) for the full goldengen reference.
diff --git a/examples/version-matrix/app/owner.go b/examples/version-matrix/app/owner.go
new file mode 100644
index 00000000..dd6f1214
--- /dev/null
+++ b/examples/version-matrix/app/owner.go
@@ -0,0 +1,21 @@
+// Package app re-exports the shared ExampleApp CRD for the version-matrix example.
+package app
+
+import (
+ sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
+)
+
+// ExampleApp re-exports the shared CRD type so callers in this package need no import alias.
+type ExampleApp = sharedapp.ExampleApp
+
+// ExampleAppSpec re-exports the shared spec type.
+type ExampleAppSpec = sharedapp.ExampleAppSpec
+
+// ExampleAppStatus re-exports the shared status type.
+type ExampleAppStatus = sharedapp.ExampleAppStatus
+
+// ExampleAppList re-exports the shared list type.
+type ExampleAppList = sharedapp.ExampleAppList
+
+// AddToScheme registers the ExampleApp types with the given scheme.
+var AddToScheme = sharedapp.AddToScheme
diff --git a/examples/version-matrix/resources/statefulset.go b/examples/version-matrix/resources/statefulset.go
new file mode 100644
index 00000000..6aefdecf
--- /dev/null
+++ b/examples/version-matrix/resources/statefulset.go
@@ -0,0 +1,117 @@
+// Package resources builds the version-gated StatefulSet for the version-matrix example.
+package resources
+
+import (
+ "fmt"
+
+ "github.com/Masterminds/semver/v3"
+ "github.com/sourcehawk/operator-component-framework/examples/version-matrix/app"
+ "github.com/sourcehawk/operator-component-framework/pkg/feature"
+ "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors"
+ "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors"
+ "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// semverConstraint adapts a Masterminds/semver constraint to feature.VersionConstraint.
+type semverConstraint struct {
+ c *semver.Constraints
+}
+
+// mustConstraint parses a semver constraint expression or panics. It is only used
+// at package init with literal expressions, so a parse failure is a programming
+// error rather than a runtime condition.
+func mustConstraint(expr string) feature.VersionConstraint {
+ c, err := semver.NewConstraint(expr)
+ if err != nil {
+ panic(err)
+ }
+ return &semverConstraint{c: c}
+}
+
+// Enabled reports whether the constraint is satisfied for the given version.
+func (s *semverConstraint) Enabled(version string) (bool, error) {
+ v, err := semver.NewVersion(version)
+ if err != nil {
+ return false, fmt.Errorf("parse version %q: %w", version, err)
+ }
+ return s.c.Check(v), nil
+}
+
+// BaseStatefulSet returns the desired-state StatefulSet for the given owner before
+// any version-gated mutation is applied.
+func BaseStatefulSet(owner *app.ExampleApp) *appsv1.StatefulSet {
+ return &appsv1.StatefulSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: owner.Name + "-db",
+ Namespace: owner.Namespace,
+ Labels: map[string]string{"app": owner.Name},
+ },
+ Spec: appsv1.StatefulSetSpec{
+ ServiceName: owner.Name,
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": owner.Name}},
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": owner.Name}},
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{{Name: "db"}},
+ },
+ },
+ },
+ }
+}
+
+// ContainerImageMutation pins the container image to the owner's version. It has no
+// gate, so it fires at every version and anchors the always-on part of the firing
+// set.
+func ContainerImageMutation(owner *app.ExampleApp) statefulset.Mutation {
+ return statefulset.Mutation{
+ Name: "ContainerImage",
+ Mutate: func(m *statefulset.Mutator) error {
+ m.EditContainers(selectors.AllContainers(), func(c *editors.ContainerEditor) error {
+ c.Raw().Image = fmt.Sprintf("example/db:%s", owner.Spec.Version)
+ return nil
+ })
+ return nil
+ },
+ }
+}
+
+// ClusterEnvPre89Mutation sets the pre-8.9 cluster-coordination environment
+// variable. It fires only for versions below 8.9.0, where the unified protocol is
+// not yet available.
+func ClusterEnvPre89Mutation(version string) statefulset.Mutation {
+ return statefulset.Mutation{
+ Name: "ClusterEnv/Pre89",
+ Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint("< 8.9.0")}),
+ Mutate: func(m *statefulset.Mutator) error {
+ m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CLUSTER_DISCOVERY", Value: "legacy-gossip"})
+ return nil
+ },
+ }
+}
+
+// ClusterEnvUnified89Mutation sets the unified cluster-coordination environment
+// variable introduced in 8.9.0. It fires only for versions at or above 8.9.0.
+func ClusterEnvUnified89Mutation(version string) statefulset.Mutation {
+ return statefulset.Mutation{
+ Name: "ClusterEnv/Unified89",
+ Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint(">= 8.9.0")}),
+ Mutate: func(m *statefulset.Mutator) error {
+ m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CLUSTER_DISCOVERY", Value: "unified-raft"})
+ return nil
+ },
+ }
+}
+
+// NewStatefulSetResource builds the StatefulSet for the owner with its version-gated
+// mutations registered. The owner's Spec.Version drives every gate, so the same
+// build wired through goldengen produces a distinct golden per gating regime.
+func NewStatefulSetResource(owner *app.ExampleApp) (*statefulset.Resource, error) {
+ return statefulset.NewBuilder(BaseStatefulSet(owner)).
+ WithMutation(ContainerImageMutation(owner)).
+ WithMutation(ClusterEnvPre89Mutation(owner.Spec.Version)).
+ WithMutation(ClusterEnvUnified89Mutation(owner.Spec.Version)).
+ Build()
+}
diff --git a/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml b/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml
new file mode 100644
index 00000000..fee8dce3
--- /dev/null
+++ b/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml
@@ -0,0 +1,28 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ labels:
+ app: demo
+ name: demo-db
+ namespace: default
+spec:
+ selector:
+ matchLabels:
+ app: demo
+ serviceName: demo
+ template:
+ metadata:
+ labels:
+ app: demo
+ spec:
+ containers:
+ - env:
+ - name: CLUSTER_DISCOVERY
+ value: legacy-gossip
+ image: example/db:8.7.0
+ name: db
+ resources: {}
+ updateStrategy: {}
+status:
+ availableReplicas: 0
+ replicas: 0
diff --git a/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml b/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml
new file mode 100644
index 00000000..9c0aa59f
--- /dev/null
+++ b/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml
@@ -0,0 +1,28 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ labels:
+ app: demo
+ name: demo-db
+ namespace: default
+spec:
+ selector:
+ matchLabels:
+ app: demo
+ serviceName: demo
+ template:
+ metadata:
+ labels:
+ app: demo
+ spec:
+ containers:
+ - env:
+ - name: CLUSTER_DISCOVERY
+ value: unified-raft
+ image: example/db:8.9.0
+ name: db
+ resources: {}
+ updateStrategy: {}
+status:
+ availableReplicas: 0
+ replicas: 0
diff --git a/examples/version-matrix/testdata/version_matrix/manifest.yaml b/examples/version-matrix/testdata/version_matrix/manifest.yaml
new file mode 100644
index 00000000..24c3413f
--- /dev/null
+++ b/examples/version-matrix/testdata/version_matrix/manifest.yaml
@@ -0,0 +1,16 @@
+fixtures:
+- name: default
+ regimes:
+ - firing:
+ - ClusterEnv/Pre89
+ - ContainerImage
+ representative: 8.7.0
+ versions:
+ - 8.7.0
+ - 8.8.2
+ - firing:
+ - ClusterEnv/Unified89
+ - ContainerImage
+ representative: 8.9.0
+ versions:
+ - 8.9.0
diff --git a/examples/version-matrix/version_matrix_test.go b/examples/version-matrix/version_matrix_test.go
new file mode 100644
index 00000000..6fb4f669
--- /dev/null
+++ b/examples/version-matrix/version_matrix_test.go
@@ -0,0 +1,87 @@
+// Package versionmatrix is a worked example of goldengen: it sweeps a version
+// universe over a single StatefulSet fixture, classifies the versions into gating
+// regimes, generates one golden per regime, asserts the gating, and proves every
+// registered mutation is accounted for.
+package versionmatrix
+
+import (
+ "flag"
+ "os"
+ "testing"
+
+ "github.com/sourcehawk/operator-component-framework/examples/version-matrix/app"
+ "github.com/sourcehawk/operator-component-framework/examples/version-matrix/resources"
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+)
+
+// update is wired to the -update flag so the generator overwrites goldens and the
+// manifest when set: go test ./examples/version-matrix/ -run TestVersionMatrix -update.
+var update = flag.Bool("update", false, "update golden files")
+
+// scheme resolves TypeMeta for the rendered StatefulSet.
+var scheme = newScheme()
+
+// newScheme returns a scheme with the apps and core Kubernetes types plus the
+// example CRD registered.
+func newScheme() *runtime.Scheme {
+ s := clientgoscheme.Scheme
+ if err := app.AddToScheme(s); err != nil {
+ panic(err)
+ }
+ return s
+}
+
+// defaultCluster returns the fixture spec swept across versions. The Build function
+// overwrites Spec.Version per version, so the Version set here is just a placeholder.
+func defaultCluster() *app.ExampleApp {
+ return &app.ExampleApp{
+ ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "default"},
+ Spec: app.ExampleAppSpec{Version: "0.0.0"},
+ }
+}
+
+// gen declares the whole version matrix. The Versions are listed ascending so a
+// regime's representative lands on the lower inclusive boundary of its gating range.
+var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{
+ Dir: "testdata/version_matrix",
+ Versions: []string{"8.7.0", "8.8.2", "8.9.0"},
+ Scheme: scheme,
+ Fixtures: []goldengen.Fixture[*app.ExampleApp]{{
+ Name: "default",
+ Spec: defaultCluster(),
+ Requires: []goldengen.Expect{
+ {Name: "ContainerImage"}, // fires at every version
+ {Name: "ClusterEnv/Pre89", For: "8.8.2"}, // legacy discovery before 8.9
+ {Name: "ClusterEnv/Unified89", For: "8.9.0"}, // unified discovery from 8.9
+ },
+ Forbids: []goldengen.Expect{
+ {Name: "ClusterEnv/Unified89", For: "8.8.2"}, // not before the boundary
+ {Name: "ClusterEnv/Pre89", For: "8.9.0"}, // not after the boundary
+ },
+ }},
+ Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) {
+ c := spec.DeepCopyObject().(*app.ExampleApp)
+ c.Spec.Version = version
+ res, err := resources.NewStatefulSetResource(c)
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Resource(res, scheme), nil
+ },
+})
+
+// TestVersionMatrix runs the sweep: it asserts the gating expectations and writes or
+// compares one golden per regime plus the coverage manifest.
+func TestVersionMatrix(t *testing.T) {
+ gen.WithUpdate(*update)
+ gen.Run(t)
+}
+
+// TestMain runs the package tests, then proves every registered mutation is required
+// or excluded before reporting the final exit code.
+func TestMain(m *testing.M) {
+ os.Exit(gen.AssertComplete(m.Run()))
+}
diff --git a/pkg/testing/goldengen/accounting_test.go b/pkg/testing/goldengen/accounting_test.go
new file mode 100644
index 00000000..3bd0f466
--- /dev/null
+++ b/pkg/testing/goldengen/accounting_test.go
@@ -0,0 +1,87 @@
+package goldengen_test
+
+import (
+ "testing"
+
+ "github.com/sourcehawk/operator-component-framework/pkg/feature"
+ "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset"
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen"
+ "github.com/stretchr/testify/assert"
+)
+
+// configWithRegistered builds a Config whose Build registers a StatefulSet with the
+// given mutation names, attaching the given Requires to a single fixture and the
+// given Exclude to the config.
+func configWithRegistered(t *testing.T, registered, requires, exclude []string) goldengen.Config[*struct{}] {
+ t.Helper()
+ reqs := make([]goldengen.Expect, 0, len(requires))
+ for _, name := range requires {
+ reqs = append(reqs, goldengen.Expect{Name: name})
+ }
+ return goldengen.Config[*struct{}]{
+ Dir: "testdata/accounting",
+ Versions: []string{"1.0.0"},
+ Exclude: exclude,
+ Scheme: testScheme(),
+ Fixtures: []goldengen.Fixture[*struct{}]{{
+ Name: "default",
+ Spec: &struct{}{},
+ Requires: reqs,
+ }},
+ Build: func(_ string, _ *struct{}) (goldengen.Unit, error) {
+ muts := make([]statefulset.Mutation, 0, len(registered))
+ for _, name := range registered {
+ muts = append(muts, statefulset.Mutation(feature.Mutation[*statefulset.Mutator]{
+ Name: name,
+ Mutate: func(*statefulset.Mutator) error { return nil },
+ }))
+ }
+ res, err := statefulset.NewBuilder(baseStatefulSet()).
+ WithMutation(muts...).
+ Build()
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Resource(res, testScheme()), nil
+ },
+ }
+}
+
+func TestAssertComplete(t *testing.T) {
+ t.Run("complete passes", func(t *testing.T) {
+ gen := goldengen.New(configWithRegistered(t, []string{"A", "B"}, []string{"A"}, []string{"B"}))
+ assert.Equal(t, 0, gen.AssertComplete(0))
+ })
+
+ t.Run("unaccounted fails", func(t *testing.T) {
+ gen := goldengen.New(configWithRegistered(t, []string{"A", "B"}, []string{"A"}, nil))
+ assert.NotEqual(t, 0, gen.AssertComplete(0)) // B is neither required nor excluded
+ })
+
+ t.Run("stale exclude fails", func(t *testing.T) {
+ gen := goldengen.New(configWithRegistered(t, []string{"A"}, []string{"A"}, []string{"Ghost"}))
+ assert.NotEqual(t, 0, gen.AssertComplete(0)) // Ghost is excluded but never registered
+ })
+
+ t.Run("stale requires fails", func(t *testing.T) {
+ gen := goldengen.New(configWithRegistered(t, []string{"A"}, []string{"A", "Ghost"}, nil))
+ assert.NotEqual(t, 0, gen.AssertComplete(0)) // Ghost is required but never registered
+ })
+
+ t.Run("passes through nonzero code", func(t *testing.T) {
+ gen := goldengen.New(configWithRegistered(t, []string{"A"}, []string{"A"}, nil))
+ assert.Equal(t, 7, gen.AssertComplete(7)) // accounting holds, but tests already failed
+ })
+
+ t.Run("nonzero code wins over accounting failure", func(t *testing.T) {
+ gen := goldengen.New(configWithRegistered(t, []string{"A", "B"}, []string{"A"}, nil))
+ assert.Equal(t, 7, gen.AssertComplete(7)) // incoming nonzero is returned unchanged
+ })
+
+ t.Run("invalid config fails", func(t *testing.T) {
+ cfg := configWithRegistered(t, []string{"A"}, []string{"A"}, nil)
+ cfg.Versions = nil
+ gen := goldengen.New(cfg)
+ assert.NotEqual(t, 0, gen.AssertComplete(0))
+ })
+}
diff --git a/pkg/testing/goldengen/generator.go b/pkg/testing/goldengen/generator.go
index 2792be3e..e165b982 100644
--- a/pkg/testing/goldengen/generator.go
+++ b/pkg/testing/goldengen/generator.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"testing"
)
@@ -26,6 +27,79 @@ func (g *Generator[T]) WithUpdate(enabled bool) *Generator[T] {
return g
}
+// AssertComplete proves every registered mutation is accounted for and returns an
+// exit code for the consumer's TestMain to pass to os.Exit:
+//
+// func TestMain(m *testing.M) { os.Exit(gen.AssertComplete(m.Run())) }
+//
+// Accounting holds when the universe of registered mutation Names across all
+// fixtures equals union(Requires names across fixtures) ∪ Exclude, with no empty
+// names. The registered universe is gathered by building each fixture once at the
+// first version, since registration is version-independent.
+//
+// It returns code unchanged when that code is already nonzero (the tests already
+// failed, so accounting noise would only obscure the failure) or when accounting
+// holds. Otherwise it prints the violations to stderr and returns a nonzero code.
+// Violations are: a registered mutation neither required nor excluded; a stale
+// Exclude or Requires name not registered by any fixture; and an empty registered
+// mutation Name.
+func (g *Generator[T]) AssertComplete(code int) int {
+ if code != 0 {
+ return code
+ }
+ if err := g.cfg.Validate(); err != nil {
+ fmt.Fprintf(os.Stderr, "goldengen: invalid config: %v\n", err)
+ return 1
+ }
+
+ registered := map[string]struct{}{}
+ for _, f := range g.cfg.Fixtures {
+ unit, err := g.cfg.Build(g.cfg.Versions[0], f.Spec)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "goldengen: build fixture %q for accounting: %v\n", f.Name, err)
+ return 1
+ }
+ for _, name := range unit.RegisteredMutations() {
+ if name == "" {
+ fmt.Fprintf(os.Stderr, "goldengen: fixture %q registers a mutation with an empty Name\n", f.Name)
+ return 1
+ }
+ registered[name] = struct{}{}
+ }
+ }
+
+ accounted := map[string]struct{}{}
+ for _, f := range g.cfg.Fixtures {
+ for _, e := range f.Requires {
+ accounted[e.Name] = struct{}{}
+ }
+ }
+ for _, name := range g.cfg.Exclude {
+ accounted[name] = struct{}{}
+ }
+
+ var violations []string
+ for name := range registered {
+ if _, ok := accounted[name]; !ok {
+ violations = append(violations, fmt.Sprintf("unaccounted mutation %q (require it in a fixture or add it to Exclude)", name))
+ }
+ }
+ for name := range accounted {
+ if _, ok := registered[name]; !ok {
+ violations = append(violations, fmt.Sprintf("stale name %q is required or excluded but not registered by any fixture", name))
+ }
+ }
+
+ if len(violations) > 0 {
+ sort.Strings(violations)
+ for _, v := range violations {
+ fmt.Fprintf(os.Stderr, "goldengen: %s\n", v)
+ }
+ return 1
+ }
+ return code
+}
+
// fixtureSweep holds, per fixture, the per-version firing-sets and the built units
// used to render representatives.
type fixtureSweep struct {