Files
felhom-agent/internal/reconcile/normalize_test.go
T
admin 05c450147c v0.4.0-rc1: slice 4 Phase A — reconcile engine (structural, runs live unfed)
New internal/reconcile package: the agent-side control core's structural half.

- Per-guest serializer Queue (doc 03 §10): the single choke point all mutation
  sources funnel through; same-vmid serial in submit order, different vmids
  parallel (cond-var FIFO lanes).
- Desired-state model + DesiredProvider seam; EmptyProvider is the only live
  source at slice 4 (no hub serving until slice 10) so the live engine computes
  an empty action set and performs zero mutations.
- Normalization layer (FieldNormalizers): normalized desired-vs-actual so
  Proxmox round-trip quirks don't read as drift. normDesc promoted out of
  main.go to reconcile.NormDescription; selftest uses the shared helper.
- Plan (pure diff): minimal benign action set (Start/Stop/SetConfig) for guests
  in both desired and actual; provision/destroy out of scope here.
- Engine: dispatches onto the shared queue; honors the dual-mode SetConfig
  contract (UPID -> WaitTask; empty UPID -> synchronous success).
- Durable op journal + idempotency store (mirrors authz.FileNonceStore):
  in-flight task ids for crash detection + AlreadyApplied dedupe across restart.
- Wired into runDaemon alongside the hub loop, sharing the queue; runs cleanly
  with no desired state and no signers.

Full module race-clean and vet-clean on the Linux build server.

CHECKPOINT: Phase A only. Awaiting validation before Phase B (the reversibility
gate + signed-op consuming layer, landing v0.4.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:21:55 +02:00

88 lines
2.8 KiB
Go

package reconcile
import (
"sort"
"strings"
"testing"
)
func TestNormDescription_TrimsTrailingNewlines(t *testing.T) {
cases := map[string]string{
"felhom-selftest 2026": "felhom-selftest 2026",
"felhom-selftest 2026\n": "felhom-selftest 2026", // PVE's single trailing \n
"x\n\n": "x", // defensive: multiple
"": "",
"\n": "",
"keep trailing spaces ": "keep trailing spaces ", // only newlines stripped
}
for in, want := range cases {
if got := NormDescription(in); got != want {
t.Errorf("NormDescription(%q) = %q, want %q", in, got, want)
}
}
// Idempotent.
if NormDescription(NormDescription("a\n")) != NormDescription("a\n") {
t.Error("NormDescription not idempotent")
}
}
func TestFieldNormalizers_DescriptionRoundTrip(t *testing.T) {
n := DefaultNormalizers()
// A value the agent wrote ("...Z") and read back with PVE's newline ("...Z\n")
// must compare EQUAL — the whole point of the layer.
if !n.Equal("description", "felhom-op", "felhom-op\n") {
t.Error("description round-trip should normalize equal")
}
if n.Equal("description", "felhom-op", "different") {
t.Error("genuinely different descriptions must not be equal")
}
}
func TestFieldNormalizers_UnknownFieldIsIdentity(t *testing.T) {
n := DefaultNormalizers()
if n.Norm("cores", "2\n") != "2\n" {
t.Error("a field with no normalizer must compare verbatim")
}
if n.Equal("cores", "2", "2\n") {
t.Error("unknown field must not normalize away differences")
}
}
// TestFieldNormalizers_ExtensibilitySeam proves the structure accepts new quirks
// (boolean coercion, list ordering) the way it will as they're discovered — the task's
// "structure it so other normalizers slot in." These are synthetic, not production.
func TestFieldNormalizers_ExtensibilitySeam(t *testing.T) {
booleanCoerce := func(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "on", "yes":
return "1"
default:
return "0"
}
}
sortCSV := func(s string) string {
parts := strings.Split(s, ",")
sort.Strings(parts)
return strings.Join(parts, ",")
}
n := FieldNormalizers{
"description": NormDescription,
"onboot": booleanCoerce,
"tags": sortCSV,
}
if !n.Equal("onboot", "true", "1") || !n.Equal("onboot", "on", "1") {
t.Error("boolean coercion normalizer should equate truthy forms")
}
if n.Equal("onboot", "true", "0") {
t.Error("boolean coercion must still distinguish true from false")
}
if !n.Equal("tags", "b,a,c", "a,b,c") {
t.Error("list-ordering normalizer should equate reordered lists")
}
// The built-in still works alongside the synthetic ones.
if !n.Equal("description", "d", "d\n") {
t.Error("description normalizer should coexist with added ones")
}
}