Ctrl K
ComunidadGuías…

pkg/autoscalerstatus/parse_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package autoscalerstatus

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func loadFixture(t *testing.T, name string) string {
	t.Helper()
	b, err := os.ReadFile(filepath.Join("testdata", name))
	if err != nil {
		t.Fatalf("read fixture %s: %v", name, err)
	}
	return string(b)
}

func mustInt(t *testing.T, p *int, label string) int {
	t.Helper()
	if p == nil {
		t.Fatalf("%s: expected non-nil int, got nil", label)
	}
	return *p
}

func TestParseZonalSinglePool(t *testing.T) {
	st, err := Parse(loadFixture(t, "gke-zonal-single-pool.yaml"))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if st.Format != FormatStructured {
		t.Errorf("Format = %q, want %q", st.Format, FormatStructured)
	}
	if st.AutoscalerState != "Running" {
		t.Errorf("AutoscalerState = %q, want %q", st.AutoscalerState, "Running")
	}
	if st.Time == nil {
		t.Errorf("Time = nil, want non-nil parsed timestamp")
	}
	if len(st.NodeGroups) != 1 {
		t.Fatalf("len(NodeGroups) = %d, want 1", len(st.NodeGroups))
	}

	ng := st.NodeGroups[0]
	if got := mustInt(t, ng.MinSize, "MinSize"); got != 5 {
		t.Errorf("MinSize = %d, want 5", got)
	}
	if got := mustInt(t, ng.MaxSize, "MaxSize"); got != 11 {
		t.Errorf("MaxSize = %d, want 11", got)
	}
	if got := mustInt(t, ng.Target, "Target"); got != 9 {
		t.Errorf("Target = %d, want 9", got)
	}
	if got := mustInt(t, ng.Health.Ready, "Health.Ready"); got != 9 {
		t.Errorf("Health.Ready = %d, want 9", got)
	}
	if got := mustInt(t, ng.Health.Registered, "Health.Registered"); got != 9 {
		t.Errorf("Health.Registered = %d, want 9", got)
	}
	if ng.Health.Status != "Healthy" {
		t.Errorf("Health.Status = %q, want %q", ng.Health.Status, "Healthy")
	}
	if ng.ScaleUp.Status != "NoActivity" {
		t.Errorf("ScaleUp.Status = %q, want %q", ng.ScaleUp.Status, "NoActivity")
	}
	if ng.ScaleUp.Backoff != nil {
		t.Errorf("ScaleUp.Backoff = %+v, want nil for empty backoffInfo {}", ng.ScaleUp.Backoff)
	}
	if !strings.HasSuffix(ng.Basename, "-grp") {
		t.Errorf("Basename = %q, want suffix %q", ng.Basename, "-grp")
	}
	if ng.Basename != "gke-demo-cluster--default-pool-16275362-grp" {
		t.Errorf("Basename = %q, want %q", ng.Basename, "gke-demo-cluster--default-pool-16275362-grp")
	}
}

func TestParseRegionalMultiMigScaleToZero(t *testing.T) {
	st, err := Parse(loadFixture(t, "gke-regional-multi-mig-scale-to-zero.yaml"))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if st.Format != FormatStructured {
		t.Errorf("Format = %q, want %q", st.Format, FormatStructured)
	}
	if len(st.NodeGroups) != 13 {
		t.Fatalf("len(NodeGroups) = %d, want 13", len(st.NodeGroups))
	}

	zeroTargets := 0
	for _, ng := range st.NodeGroups {
		if got := mustInt(t, ng.MinSize, "MinSize"); got != 0 {
			t.Errorf("group %s: MinSize = %d, want 0", ng.Basename, got)
		}
		if got := mustInt(t, ng.MaxSize, "MaxSize"); got != 1000 {
			t.Errorf("group %s: MaxSize = %d, want 1000", ng.Basename, got)
		}
		if ng.Target != nil && *ng.Target == 0 {
			zeroTargets++
			// Scale-to-zero groups publish an explicit `lastProbeTime: null`;
			// that must survive as nil, never as a zero time.
			if ng.Health.LastProbeTime != nil {
				t.Errorf("group %s: target 0 should keep Health.LastProbeTime nil, got %v",
					ng.Basename, ng.Health.LastProbeTime)
			}
		}
	}
	if zeroTargets != 10 {
		t.Errorf("groups with Target==0 = %d, want 10", zeroTargets)
	}
}

func TestParseTruncatedNames(t *testing.T) {
	st, err := Parse(loadFixture(t, "gke-truncated-names.yaml"))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if len(st.NodeGroups) != 2 {
		t.Fatalf("len(NodeGroups) = %d, want 2", len(st.NodeGroups))
	}

	g0 := st.NodeGroups[0]
	wantName0 := "https://www.googleapis.com/compute/v1/projects/example-project/zones/me-west1-a/instanceGroups/gke-longname-clust-longname-clust-4712e1a8-grp"
	if g0.Name != wantName0 {
		t.Errorf("group[0].Name = %q, want verbatim %q", g0.Name, wantName0)
	}
	if g0.Basename != "gke-longname-clust-longname-clust-4712e1a8-grp" {
		t.Errorf("group[0].Basename = %q, want %q", g0.Basename, "gke-longname-clust-longname-clust-4712e1a8-grp")
	}
	if !strings.Contains(g0.Basename, "longname-clust") {
		t.Errorf("group[0].Basename = %q, want it to contain %q", g0.Basename, "longname-clust")
	}
	if got := mustInt(t, g0.MinSize, "group[0].MinSize"); got != 15 {
		t.Errorf("group[0].MinSize = %d, want 15", got)
	}
	if got := mustInt(t, g0.MaxSize, "group[0].MaxSize"); got != 20 {
		t.Errorf("group[0].MaxSize = %d, want 20", got)
	}

	g1 := st.NodeGroups[1]
	if g1.Basename != "gke-longname-clust-second-pool-trun-fb123523-grp" {
		t.Errorf("group[1].Basename = %q, want %q", g1.Basename, "gke-longname-clust-second-pool-trun-fb123523-grp")
	}
	if got := mustInt(t, g1.MinSize, "group[1].MinSize"); got != 3 {
		t.Errorf("group[1].MinSize = %d, want 3", got)
	}
	if got := mustInt(t, g1.MaxSize, "group[1].MaxSize"); got != 5 {
		t.Errorf("group[1].MaxSize = %d, want 5", got)
	}
}

func TestParseEKSManagedNodeGroups(t *testing.T) {
	raw, err := os.ReadFile("testdata/eks-managed-node-groups.yaml")
	if err != nil {
		t.Fatalf("read fixture: %v", err)
	}
	status, err := Parse(string(raw))
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if status.Format != FormatStructured || status.AutoscalerState != "Running" {
		t.Fatalf("status = %q %q", status.Format, status.AutoscalerState)
	}
	if len(status.NodeGroups) != 3 {
		t.Fatalf("node groups = %d, want 3", len(status.NodeGroups))
	}
	byName := map[string]NodeGroup{}
	for _, group := range status.NodeGroups {
		byName[group.Basename] = group
	}
	system, found := byName["eks-system-pool-28cf09b0-ae43-f32a-94c0-27fea5fc8bdc"]
	if !found || system.MinSize == nil || *system.MinSize != 2 || system.MaxSize == nil || *system.MaxSize != 4 {
		t.Fatalf("system pool group = %+v", system)
	}
	if system.Health.Ready == nil || *system.Health.Ready != 2 {
		t.Fatalf("system pool ready = %+v", system.Health)
	}
	// Zero-node EKS groups omit nodeCounts entirely (GKE publishes explicit
	// nulls instead) — both must land as nil, never zero.
	loadtest := byName["eks-loadtest-od-26cf0a34-4af9-ec18-066e-a8b0142e7d03"]
	if loadtest.Target == nil || *loadtest.Target != 0 || loadtest.Health.Ready != nil {
		t.Fatalf("scale-to-zero group = %+v", loadtest)
	}
}

func TestParseLegacyText(t *testing.T) {
	st, err := Parse(loadFixture(t, "legacy-text.txt"))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if st.Format != FormatLegacyText {
		t.Errorf("Format = %q, want %q", st.Format, FormatLegacyText)
	}
	if st.Time == nil {
		t.Errorf("Time = nil, want non-nil parsed header timestamp")
	}
	if len(st.NodeGroups) != 2 {
		t.Fatalf("len(NodeGroups) = %d, want 2", len(st.NodeGroups))
	}

	g0 := st.NodeGroups[0]
	if g0.Basename != "gke-legacy-cluster-default-pool-a1b2c3d4-grp" {
		t.Errorf("group[0].Basename = %q, want %q", g0.Basename, "gke-legacy-cluster-default-pool-a1b2c3d4-grp")
	}
	if g0.Health.Status != "Healthy" {
		t.Errorf("group[0].Health.Status = %q, want %q", g0.Health.Status, "Healthy")
	}
	if got := mustInt(t, g0.Target, "group[0].Target"); got != 3 {
		t.Errorf("group[0].Target = %d, want 3", got)
	}
	if got := mustInt(t, g0.MinSize, "group[0].MinSize"); got != 1 {
		t.Errorf("group[0].MinSize = %d, want 1", got)
	}
	if got := mustInt(t, g0.MaxSize, "group[0].MaxSize"); got != 5 {
		t.Errorf("group[0].MaxSize = %d, want 5", got)
	}
	if got := mustInt(t, g0.Health.Ready, "group[0].Health.Ready"); got != 3 {
		t.Errorf("group[0].Health.Ready = %d, want 3", got)
	}
	if got := mustInt(t, g0.Health.Registered, "group[0].Health.Registered"); got != 3 {
		t.Errorf("group[0].Health.Registered = %d, want 3", got)
	}
	if g0.ScaleUp.Status != "NoActivity" {
		t.Errorf("group[0].ScaleUp.Status = %q, want %q", g0.ScaleUp.Status, "NoActivity")
	}
	if g0.ScaleDown.Status != "NoCandidates" {
		t.Errorf("group[0].ScaleDown.Status = %q, want %q", g0.ScaleDown.Status, "NoCandidates")
	}
	if g0.Health.LastProbeTime == nil {
		t.Errorf("group[0].Health.LastProbeTime = nil, want non-nil")
	}
	if g0.Health.LastTransition == nil {
		t.Errorf("group[0].Health.LastTransition = nil, want non-nil")
	}

	g1 := st.NodeGroups[1]
	if got := mustInt(t, g1.Target, "group[1].Target"); got != 2 {
		t.Errorf("group[1].Target = %d, want 2", got)
	}
	if got := mustInt(t, g1.MinSize, "group[1].MinSize"); got != 0 {
		t.Errorf("group[1].MinSize = %d, want 0", got)
	}
	if got := mustInt(t, g1.MaxSize, "group[1].MaxSize"); got != 10 {
		t.Errorf("group[1].MaxSize = %d, want 10", got)
	}
	if got := mustInt(t, g1.Health.Ready, "group[1].Health.Ready"); got != 2 {
		t.Errorf("group[1].Health.Ready = %d, want 2", got)
	}
}

func TestParseAKSStructured(t *testing.T) {
	st, err := Parse(loadFixture(t, "aks-structured.yaml"))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if st.Format != FormatStructured {
		t.Errorf("Format = %q, want %q", st.Format, FormatStructured)
	}
	if st.AutoscalerState != "Running" {
		t.Errorf("AutoscalerState = %q, want %q", st.AutoscalerState, "Running")
	}
	if st.Time == nil {
		t.Errorf("Time = nil, want non-nil parsed timestamp")
	}
	if len(st.NodeGroups) != 3 {
		t.Fatalf("len(NodeGroups) = %d, want 3", len(st.NodeGroups))
	}

	// AKS reports each node group by its bare VMSS name (ScaleSet.Id() ==
	// ScaleSet.Name). There is no "-grp" suffix and no "/"-delimited path, so
	// Basename must equal the published name verbatim — never truncated, never
	// stripped. Node names are the VMSS name plus a base36 instance suffix
	// (aks-nodepool1-33037761-vmss000000), so HasPrefix(node, Basename) — the
	// rule the logical-group join relies on — must hold.
	byName := map[string]NodeGroup{}
	for _, ng := range st.NodeGroups {
		if ng.Basename != ng.Name {
			t.Errorf("group %q: Basename = %q, want it to equal Name verbatim (no path, no suffix to strip)", ng.Name, ng.Basename)
		}
		if strings.HasSuffix(ng.Basename, "-grp") {
			t.Errorf("group %q: Basename unexpectedly ends in -grp (that is a GKE MIG convention, not AKS)", ng.Name)
		}
		if trimmed := strings.TrimSuffix(ng.Basename, "-grp"); trimmed != ng.Basename {
			t.Errorf("group %q: TrimSuffix(-grp) mutated the AKS basename to %q; the join key must be the VMSS name unchanged", ng.Name, trimmed)
		}
		syntheticNode := ng.Basename + "000000"
		if !strings.HasPrefix(syntheticNode, ng.Basename) {
			t.Errorf("group %q: node %q does not start with Basename %q — the group join would orphan this child", ng.Name, syntheticNode, ng.Basename)
		}
		byName[ng.Name] = ng
	}

	sys, ok := byName["aks-nodepool1-33037761-vmss"]
	if !ok {
		t.Fatalf("missing system node group aks-nodepool1-33037761-vmss; got %v", byName)
	}
	if got := mustInt(t, sys.MinSize, "system.MinSize"); got != 1 {
		t.Errorf("system MinSize = %d, want 1", got)
	}
	if got := mustInt(t, sys.MaxSize, "system.MaxSize"); got != 3 {
		t.Errorf("system MaxSize = %d, want 3", got)
	}
	if got := mustInt(t, sys.Target, "system.Target"); got != 2 {
		t.Errorf("system Target = %d, want 2", got)
	}
	if got := mustInt(t, sys.Health.Ready, "system.Health.Ready"); got != 2 {
		t.Errorf("system Health.Ready = %d, want 2", got)
	}
	if sys.Health.Status != "Healthy" {
		t.Errorf("system Health.Status = %q, want %q", sys.Health.Status, "Healthy")
	}
	if sys.ScaleUp.Backoff != nil {
		t.Errorf("system ScaleUp.Backoff = %+v, want nil for empty backoffInfo {}", sys.ScaleUp.Backoff)
	}

	// Scale-to-zero user pool: target 0 and minSize 0 must survive as real zero
	// values, never confused with "not published".
	zero, ok := byName["aks-spotgpu-41529630-vmss"]
	if !ok {
		t.Fatalf("missing scale-to-zero node group aks-spotgpu-41529630-vmss")
	}
	if got := mustInt(t, zero.MinSize, "spotgpu.MinSize"); got != 0 {
		t.Errorf("spotgpu MinSize = %d, want 0", got)
	}
	if got := mustInt(t, zero.MaxSize, "spotgpu.MaxSize"); got != 4 {
		t.Errorf("spotgpu MaxSize = %d, want 4", got)
	}
	if got := mustInt(t, zero.Target, "spotgpu.Target"); got != 0 {
		t.Errorf("spotgpu Target = %d, want 0", got)
	}
	if got := mustInt(t, zero.Health.Ready, "spotgpu.Health.Ready"); got != 0 {
		t.Errorf("spotgpu Health.Ready = %d, want 0", got)
	}
}

func TestParseAKSLegacyText(t *testing.T) {
	st, err := Parse(loadFixture(t, "aks-legacy-text.txt"))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if st.Format != FormatLegacyText {
		t.Errorf("Format = %q, want %q", st.Format, FormatLegacyText)
	}
	if st.Time == nil {
		t.Errorf("Time = nil, want non-nil parsed header timestamp")
	}
	if len(st.NodeGroups) != 2 {
		t.Fatalf("len(NodeGroups) = %d, want 2", len(st.NodeGroups))
	}

	g0 := st.NodeGroups[0]
	if g0.Name != "aks-nodepool1-33037761-vmss" {
		t.Errorf("group[0].Name = %q, want %q", g0.Name, "aks-nodepool1-33037761-vmss")
	}
	if g0.Basename != "aks-nodepool1-33037761-vmss" {
		t.Errorf("group[0].Basename = %q, want it to equal the VMSS name verbatim", g0.Basename)
	}
	if strings.TrimSuffix(g0.Basename, "-grp") != g0.Basename {
		t.Errorf("group[0].Basename %q was mutated by TrimSuffix(-grp); AKS VMSS names carry no such suffix", g0.Basename)
	}
	if !strings.HasPrefix(g0.Basename+"000000", g0.Basename) {
		t.Errorf("group[0]: node name does not start with Basename %q", g0.Basename)
	}
	if g0.Health.Status != "Healthy" {
		t.Errorf("group[0].Health.Status = %q, want %q", g0.Health.Status, "Healthy")
	}
	if got := mustInt(t, g0.MinSize, "group[0].MinSize"); got != 1 {
		t.Errorf("group[0].MinSize = %d, want 1", got)
	}
	if got := mustInt(t, g0.MaxSize, "group[0].MaxSize"); got != 3 {
		t.Errorf("group[0].MaxSize = %d, want 3", got)
	}
	if got := mustInt(t, g0.Target, "group[0].Target"); got != 2 {
		t.Errorf("group[0].Target = %d, want 2", got)
	}
	if got := mustInt(t, g0.Health.Ready, "group[0].Health.Ready"); got != 2 {
		t.Errorf("group[0].Health.Ready = %d, want 2", got)
	}
	if g0.ScaleUp.Status != "NoActivity" {
		t.Errorf("group[0].ScaleUp.Status = %q, want %q", g0.ScaleUp.Status, "NoActivity")
	}

	// Second pool is a scale-to-zero user pool stuck in scale-up backoff (the
	// canonical AKS quota-exhaustion failure). Bounds still parse; the backoff is
	// surfaced as the ScaleUp status token.
	g1 := st.NodeGroups[1]
	if g1.Basename != "aks-spotgpu-41529630-vmss" {
		t.Errorf("group[1].Basename = %q, want %q", g1.Basename, "aks-spotgpu-41529630-vmss")
	}
	if got := mustInt(t, g1.MinSize, "group[1].MinSize"); got != 0 {
		t.Errorf("group[1].MinSize = %d, want 0", got)
	}
	if got := mustInt(t, g1.MaxSize, "group[1].MaxSize"); got != 6 {
		t.Errorf("group[1].MaxSize = %d, want 6", got)
	}
	if got := mustInt(t, g1.Target, "group[1].Target"); got != 1 {
		t.Errorf("group[1].Target = %d, want 1", got)
	}
	if g1.ScaleUp.Status != "Backoff" {
		t.Errorf("group[1].ScaleUp.Status = %q, want %q", g1.ScaleUp.Status, "Backoff")
	}
}

func TestParseUnrecognized(t *testing.T) {
	cases := map[string]string{
		"empty":          "",
		"whitespace":     "   \n  ",
		"prose":          "this is not a cluster-autoscaler status at all",
		"yaml-no-keys":   "{some: yaml, without: known keys}",
		"unrelated-yaml": "foo: bar\nbaz: 1\n",
	}
	for name, raw := range cases {
		t.Run(name, func(t *testing.T) {
			if _, err := Parse(raw); err == nil {
				t.Errorf("Parse(%q) err = nil, want error", raw)
			}
		})
	}
}

func TestParseLegacyTextReportsRunningAutoscalerState(t *testing.T) {
	// The legacy format has no autoscalerStatus field. Leaving the state blank
	// left every pre-1.30 cluster's manager rollup permanently "unknown", since
	// the rollup reads AutoscalerState to reach "healthy".
	for _, fixture := range []string{"legacy-text.txt", "aks-legacy-text.txt"} {
		t.Run(fixture, func(t *testing.T) {
			st, err := Parse(loadFixture(t, fixture))
			if err != nil {
				t.Fatalf("Parse: %v", err)
			}
			if st.AutoscalerState != "Running" {
				t.Errorf("AutoscalerState = %q, want %q — a payload with health sections is a running controller", st.AutoscalerState, "Running")
			}
		})
	}
}

func TestParseLegacyInitializingPayload(t *testing.T) {
	// Before its first health reading the legacy autoscaler writes the bare
	// word into data.status. Rejecting it reported a parse error for a
	// perfectly healthy starting controller.
	st, err := Parse("Initializing\n")
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if st.Format != FormatLegacyText {
		t.Errorf("Format = %q, want %q", st.Format, FormatLegacyText)
	}
	if st.AutoscalerState != "Initializing" {
		t.Errorf("AutoscalerState = %q, want %q", st.AutoscalerState, "Initializing")
	}
	if len(st.NodeGroups) != 0 {
		t.Errorf("NodeGroups = %+v, want none — nothing was published yet", st.NodeGroups)
	}
}

func TestParseLegacyBackoffCarriesStatusWithoutStructuredDetails(t *testing.T) {
	st, err := Parse(loadFixture(t, "aks-legacy-text.txt"))
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if st.ClusterWide.ScaleUp.Status != "Backoff" {
		t.Errorf("cluster-wide ScaleUp.Status = %q, want %q", st.ClusterWide.ScaleUp.Status, "Backoff")
	}
	if st.ClusterWide.ScaleUp.Backoff != nil {
		t.Errorf("legacy text carries no structured backoffInfo; Backoff must stay nil, got %+v", st.ClusterWide.ScaleUp.Backoff)
	}
	var spot *NodeGroup
	for i := range st.NodeGroups {
		if st.NodeGroups[i].Basename == "aks-spotgpu-41529630-vmss" {
			spot = &st.NodeGroups[i]
		}
	}
	if spot == nil {
		t.Fatalf("spotgpu group missing from %+v", st.NodeGroups)
	}
	if spot.ScaleUp.Status != "Backoff" {
		t.Errorf("spotgpu ScaleUp.Status = %q, want %q", spot.ScaleUp.Status, "Backoff")
	}
}

func TestParseScaleDownCandidates(t *testing.T) {
	for _, fixture := range []string{"gke-zonal-single-pool.yaml", "legacy-text.txt"} {
		for _, test := range []struct {
			name  string
			value string
			want  *int
		}{
			{"missing CandidatesPresent", "", nil},
			{"zero", "0", func() *int { n := 0; return &n }()},
			{"positive", "2", func() *int { n := 2; return &n }()},
		} {
			t.Run(fixture+"/"+test.name, func(t *testing.T) {
				raw := loadFixture(t, fixture)
				if strings.HasSuffix(fixture, ".yaml") {
					if test.value != "" && test.value != "0" {
						raw = strings.ReplaceAll(raw, "scaleDown:\n", "scaleDown:\n    candidates: "+test.value+"\n")
					}
				} else {
					replacement := ""
					if test.value != "" {
						replacement = " (candidates=" + test.value + ")"
					}
					raw = strings.ReplaceAll(raw, " (candidates=0)", replacement)
				}
				wantStatus := "NoCandidates"
				if test.name == "positive" || test.want == nil {
					wantStatus = "CandidatesPresent"
					raw = strings.ReplaceAll(raw, "NoCandidates", wantStatus)
				}
				st, err := Parse(raw)
				if err != nil {
					t.Fatal(err)
				}
				conditions := []Condition{st.ClusterWide.ScaleDown}
				for _, group := range st.NodeGroups {
					conditions = append(conditions, group.ScaleDown)
				}
				for _, condition := range conditions {
					if test.want == nil {
						if condition.Candidates != nil {
							t.Fatalf("unpublished candidates = %v", *condition.Candidates)
						}
					} else if got := mustInt(t, condition.Candidates, "candidates"); got != *test.want {
						t.Fatalf("candidates = %d, want %d", got, *test.want)
					}
					if condition.Status != wantStatus || condition.LastTransition == nil {
						t.Fatalf("scale-down status/transition lost: %+v", condition)
					}
				}
			})
		}
	}
}

func TestParseScaleDownNullCandidatesAndTransition(t *testing.T) {
	raw := "autoscalerStatus: Running\nnodeGroups:\n- name: idle\n  scaleDown:\n    candidates: null\n    lastTransitionTime: null\n    status: CandidatesPresent\n"
	st, err := Parse(raw)
	if err != nil {
		t.Fatal(err)
	}
	if st.NodeGroups[0].ScaleDown.Candidates != nil || st.NodeGroups[0].ScaleDown.LastTransition != nil {
		t.Fatalf("null fields must stay unpublished: %+v", st.NodeGroups[0].ScaleDown)
	}
}

func TestStructuredNoCandidatesOmitsZero(t *testing.T) {
	st, err := Parse(loadFixture(t, "gke-zonal-single-pool.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	conditions := []Condition{st.ClusterWide.ScaleDown}
	for _, group := range st.NodeGroups {
		conditions = append(conditions, group.ScaleDown)
	}
	for _, condition := range conditions {
		if condition.Status != "NoCandidates" || mustInt(t, condition.Candidates, "candidates") != 0 {
			t.Fatalf("omitted zero lost: %+v", condition)
		}
	}
}

func TestLegacyNoCandidatesWithoutCountStaysUnknown(t *testing.T) {
	raw := strings.ReplaceAll(loadFixture(t, "legacy-text.txt"), " (candidates=0)", "")
	st, err := Parse(raw)
	if err != nil {
		t.Fatal(err)
	}
	conditions := []Condition{st.ClusterWide.ScaleDown}
	for _, group := range st.NodeGroups {
		conditions = append(conditions, group.ScaleDown)
	}
	for _, condition := range conditions {
		if condition.Status != "NoCandidates" || condition.Candidates != nil {
			t.Fatalf("legacy status must not infer a count: %+v", condition)
		}
	}
}