This commit is contained in:
Martino Ferrari
2026-06-24 01:39:15 +02:00
parent 11120bedca
commit c0f7e662be
76 changed files with 4368 additions and 210 deletions
+75
View File
@@ -0,0 +1,75 @@
package confmgr
import (
"reflect"
"testing"
)
// TestCoerceSnapshot covers the alternate and error branches of coerceSnapshot
// that the happy-path Snapshot tests do not reach.
func TestCoerceSnapshot(t *testing.T) {
enum := Parameter{Type: TypeEnum, EnumValues: []string{"off", "low", "high"}}
cases := []struct {
name string
p Parameter
raw any
want any
wantErr bool
}{
{"float err", Parameter{Type: TypeFloat}, struct{}{}, nil, true},
{"int round", Parameter{Type: TypeInt}, 2.6, int64(3), false},
{"int err", Parameter{Type: TypeInt}, struct{}{}, nil, true},
{"bool from string", Parameter{Type: TypeBool}, "true", true, false},
{"bool bad string", Parameter{Type: TypeBool}, "maybe", nil, true},
{"bool from numeric", Parameter{Type: TypeBool}, 0.0, false, false},
{"bool unconvertible", Parameter{Type: TypeBool}, struct{}{}, nil, true},
{"string passthrough", Parameter{Type: TypeString}, "x", "x", false},
{"string from numeric", Parameter{Type: TypeString}, 42.0, "42", false},
{"enum string in range", enum, "low", "low", false},
{"enum string out of range", enum, "nope", nil, true},
{"enum index", enum, int64(2), "high", false},
{"enum index out of range", enum, 9.0, nil, true},
{"enum non-numeric", enum, struct{}{}, nil, true},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, []float64{1, 2}, false},
{"array err", Parameter{Type: TypeFloatArray}, 1.0, nil, true},
{"default passthrough", Parameter{Type: ParamType("weird")}, "asis", "asis", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.p.coerceSnapshot(tc.raw)
if tc.wantErr {
if err == nil {
t.Errorf("coerceSnapshot(%v): want error", tc.raw)
}
return
}
if err != nil {
t.Fatalf("coerceSnapshot(%v): %v", tc.raw, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("coerceSnapshot(%v) = %v (%T), want %v (%T)", tc.raw, got, got, tc.want, tc.want)
}
})
}
}
// TestSnapshotCoerceFailureRecorded ensures a coercion failure (vs read failure)
// is counted in res.Failed and kept out of Values.
func TestSnapshotCoerceFailureRecorded(t *testing.T) {
set := ConfigSet{
ID: "s",
Name: "s",
Parameters: []Parameter{
{Key: "n", DS: "d", Signal: "N", Type: TypeInt},
},
}
res := Snapshot(set, func(_, _ string) (any, error) {
return "not-a-number", nil // reads fine, fails coercion
})
if res.Captured != 0 || res.Failed != 1 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if _, ok := res.Values["n"]; ok {
t.Error("coercion-failed parameter must not appear in values")
}
}
+4 -4
View File
@@ -50,10 +50,10 @@ type RuleViolation struct {
// violation. Transformed holds the parameter values the rule(s) derived or
// overrode (keys are parameter keys; values are the new concrete values).
type RuleResult struct {
OK bool `json:"ok"`
Violations []RuleViolation `json:"violations,omitempty"`
Transformed map[string]any `json:"transformed,omitempty"`
CompileError string `json:"compileError,omitempty"`
OK bool `json:"ok"`
Violations []RuleViolation `json:"violations,omitempty"`
Transformed map[string]any `json:"transformed,omitempty"`
CompileError string `json:"compileError,omitempty"`
}
// RuleError wraps a failing RuleResult so it can flow through the store's
+9 -9
View File
@@ -36,18 +36,18 @@ func (t ParamType) valid() bool {
// signal (DS + Signal), a value type, an optional default, and validation
// metadata. Parameters may be grouped for presentation via Group/Subgroup.
type Parameter struct {
Key string `json:"key"` // unique within the set
Label string `json:"label,omitempty"` // human-friendly name
Group string `json:"group,omitempty"` // top-level grouping
Key string `json:"key"` // unique within the set
Label string `json:"label,omitempty"` // human-friendly name
Group string `json:"group,omitempty"` // top-level grouping
Subgroup string `json:"subgroup,omitempty"`
DS string `json:"ds"` // target data source
Signal string `json:"signal"` // target signal name
Type ParamType `json:"type"` // value kind
DS string `json:"ds"` // target data source
Signal string `json:"signal"` // target signal name
Type ParamType `json:"type"` // value kind
Default any `json:"default,omitempty"`
Mandatory bool `json:"mandatory,omitempty"`
Min *float64 `json:"min,omitempty"` // numeric lower bound
Max *float64 `json:"max,omitempty"` // numeric upper bound
EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum
Min *float64 `json:"min,omitempty"` // numeric lower bound
Max *float64 `json:"max,omitempty"` // numeric upper bound
EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
}
+134
View File
@@ -0,0 +1,134 @@
package confmgr
import (
"testing"
)
// TestParamTypeValid covers the valid/invalid branches of ParamType.valid.
func TestParamTypeValid(t *testing.T) {
for _, ok := range []ParamType{TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray} {
if !ok.valid() {
t.Errorf("%q should be valid", ok)
}
}
if ParamType("bogus").valid() {
t.Error("bogus type should be invalid")
}
}
// TestCheckValue exercises every type branch of Parameter.checkValue, including
// the type-mismatch and range-violation error paths.
func TestCheckValue(t *testing.T) {
cases := []struct {
name string
p Parameter
v any
wantErr bool
}{
{"float ok", Parameter{Type: TypeFloat}, 1.5, false},
{"float from string", Parameter{Type: TypeFloat}, "2.5", false},
{"float not numeric", Parameter{Type: TypeFloat}, true, true},
{"float below min", Parameter{Type: TypeFloat, Min: fptr(0)}, -1.0, true},
{"float above max", Parameter{Type: TypeFloat, Max: fptr(10)}, 11.0, true},
{"int ok", Parameter{Type: TypeInt}, 4.0, false},
{"int non-integer", Parameter{Type: TypeInt}, 4.5, true},
{"bool ok", Parameter{Type: TypeBool}, true, false},
{"bool wrong type", Parameter{Type: TypeBool}, 1.0, true},
{"string ok", Parameter{Type: TypeString}, "hi", false},
{"string wrong type", Parameter{Type: TypeString}, 1.0, true},
{"enum ok", Parameter{Type: TypeEnum, EnumValues: []string{"a", "b"}}, "b", false},
{"enum not string", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, 1.0, true},
{"enum not allowed", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, "z", true},
{"array ok", Parameter{Type: TypeFloatArray}, []any{1.0, 2.0}, false},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, false},
{"array not array", Parameter{Type: TypeFloatArray}, 1.0, true},
{"array elem below min", Parameter{Type: TypeFloatArray, Min: fptr(0)}, []any{-1.0}, true},
{"array elem above max", Parameter{Type: TypeFloatArray, Max: fptr(5)}, []any{9.0}, true},
{"array bad elem", Parameter{Type: TypeFloatArray}, []any{"nope"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.p.checkValue(tc.v)
if tc.wantErr && err == nil {
t.Errorf("checkValue(%v): want error", tc.v)
}
if !tc.wantErr && err != nil {
t.Errorf("checkValue(%v): unexpected error %v", tc.v, err)
}
})
}
}
// TestValidateInvalidType covers the invalid-type branch of ConfigSet.Validate.
func TestValidateInvalidType(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: ParamType("weird")},
}}
if err := set.Validate(); err == nil {
t.Error("invalid parameter type: want error")
}
}
// TestValidateEnumRequiresValues covers the enum-without-values branch.
func TestValidateEnumRequiresValues(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeEnum},
}}
if err := set.Validate(); err == nil {
t.Error("enum without values: want error")
}
}
// TestValidateMinGreaterThanMax covers the min>max branch.
func TestValidateMinGreaterThanMax(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Min: fptr(10), Max: fptr(1)},
}}
if err := set.Validate(); err == nil {
t.Error("min>max: want error")
}
}
// TestValidateAgainstMandatoryNoDefault covers the mandatory-without-default
// branch of ValidateAgainst.
func TestValidateAgainstMandatoryNoDefault(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "req", DS: "d", Signal: "s", Type: TypeFloat, Mandatory: true},
}}
inst := ConfigInstance{SetID: "x", Values: map[string]any{}}
if err := inst.ValidateAgainst(set); err == nil {
t.Error("missing mandatory value: want error")
}
// Providing the value clears the error.
inst.Values["req"] = 1.0
if err := inst.ValidateAgainst(set); err != nil {
t.Errorf("with value: unexpected error %v", err)
}
}
// TestResolveAndNormalize covers Resolve fallbacks and array normalization.
func TestResolveAndNormalize(t *testing.T) {
p := Parameter{Key: "v", Type: TypeFloat, Default: 7.0}
inst := ConfigInstance{Values: map[string]any{}}
if got, ok := inst.Resolve(p); !ok || got != 7.0 {
t.Errorf("Resolve default: got %v,%v want 7,true", got, ok)
}
inst.Values["v"] = 3.0
if got, ok := inst.Resolve(p); !ok || got != 3.0 {
t.Errorf("Resolve value: got %v,%v want 3,true", got, ok)
}
// Optional param without default → no value.
if _, ok := inst.Resolve(Parameter{Key: "none", Type: TypeFloat}); ok {
t.Error("Resolve no-default: want ok=false")
}
arr := Parameter{Type: TypeFloatArray}
out := arr.normalize([]any{1.0, 2.0, 3.0})
if got, ok := out.([]float64); !ok || len(got) != 3 {
t.Errorf("normalize array: got %T %v", out, out)
}
// Non-array passthrough.
if got := arr.normalize("scalar"); got != "scalar" {
t.Errorf("normalize passthrough: got %v", got)
}
}
+4 -4
View File
@@ -26,11 +26,11 @@ type SnapshotEntry struct {
// SnapshotResult summarises a snapshot run. Values holds the captured,
// type-coerced values ready to populate a new ConfigInstance.
type SnapshotResult struct {
SetID string `json:"setId"`
SetID string `json:"setId"`
Entries []SnapshotEntry `json:"entries"`
Captured int `json:"captured"`
Failed int `json:"failed"`
Values map[string]any `json:"-"`
Captured int `json:"captured"`
Failed int `json:"failed"`
Values map[string]any `json:"-"`
}
// Snapshot reads the current value of every parameter's target signal via read
+3 -3
View File
@@ -20,9 +20,9 @@ func TestSnapshotCapturesAndCoerces(t *testing.T) {
}
live := map[string]any{
"PSU:V": 24.5,
"PSU:N": 3.0, // float reading → coerced to int64
"PSU:EN": int64(1), // numeric → bool true
"PSU:MODE": int64(2), // enum index → "high"
"PSU:N": 3.0, // float reading → coerced to int64
"PSU:EN": int64(1), // numeric → bool true
"PSU:MODE": int64(2), // enum index → "high"
"PSU:WF": []float64{1, 2, 3},
"PSU:LBL": "ready",
}
+3 -3
View File
@@ -89,9 +89,9 @@ func New(storageDir string) (*Store, error) {
// header parses the metadata fields shared by sets and instances.
type header struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
Tag string `json:"tag"`
SetID string `json:"setId"`
Enabled *bool `json:"enabled"`
+118
View File
@@ -0,0 +1,118 @@
package confmgr
import (
"testing"
)
// TestValidateIDRejectsBadChars covers the invalid-character branch of
// validateID, surfaced through the typed getters as ErrNotFound.
func TestValidateIDRejectsBadChars(t *testing.T) {
s, _ := New(t.TempDir())
for _, bad := range []string{"", "bad/slash", "has space", "dot.dot"} {
if _, err := s.GetSet(bad); err == nil {
t.Errorf("GetSet(%q): want error", bad)
}
}
}
// TestNotFoundPaths covers the ErrNotFound branches across the revision API.
func TestNotFoundPaths(t *testing.T) {
s, _ := New(t.TempDir())
if _, err := s.GetSet("missing"); err != ErrNotFound {
t.Errorf("GetSet missing: want ErrNotFound, got %v", err)
}
if _, err := s.GetSetVersion("missing", 1); err != ErrNotFound {
t.Errorf("GetSetVersion missing: want ErrNotFound, got %v", err)
}
if _, err := s.Versions(KindSet, "missing"); err != ErrNotFound {
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
}
if err := s.Promote(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Promote missing: want ErrNotFound, got %v", err)
}
if _, err := s.Fork(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
}
if _, err := s.UpdateSet("missing", sampleSet(), ""); err != ErrNotFound {
t.Errorf("UpdateSet missing: want ErrNotFound, got %v", err)
}
// A valid id that exists but a version that was never written.
created, _ := s.CreateSet(sampleSet(), "")
if _, err := s.GetSetVersion(created.ID, 99); err != ErrNotFound {
t.Errorf("GetSetVersion bogus version: want ErrNotFound, got %v", err)
}
}
// TestInstancePinnedSetVersion covers the SetVersion>0 branch of setForInstance:
// the instance is validated against a specific (pinned) revision of its set.
func TestInstancePinnedSetVersion(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
// Bump the set to v2 so a distinct earlier revision exists.
if _, err := s.UpdateSet(set.ID, sampleSet(), "v2"); err != nil {
t.Fatal(err)
}
inst := ConfigInstance{
Name: "pinned",
SetID: set.ID,
SetVersion: 1, // pin to the original schema revision
Values: map[string]any{"voltage": 24.0},
}
out, err := s.CreateInstance(inst, "")
if err != nil {
t.Fatalf("CreateInstance pinned: %v", err)
}
if out.SetVersion != 1 {
t.Errorf("SetVersion: want 1, got %d", out.SetVersion)
}
// Updating the pinned instance also exercises the pinned UpdateInstance path.
out.Values["voltage"] = 30.0
v2, err := s.UpdateInstance(out.ID, out, "bump")
if err != nil {
t.Fatalf("UpdateInstance pinned: %v", err)
}
if v2.Version != 2 {
t.Errorf("instance version: want 2, got %d", v2.Version)
}
}
// TestUpdateInstanceMissingSet covers the load-set error branch of
// UpdateInstance (set referenced by the instance does not exist).
func TestUpdateInstanceMissingSet(t *testing.T) {
s, _ := New(t.TempDir())
inst := ConfigInstance{Name: "x", SetID: "nope", Values: map[string]any{}}
if _, err := s.UpdateInstance("anything", inst, ""); err == nil {
t.Error("UpdateInstance with missing set: want error")
}
}
// TestListSkipsVersionedFiles checks List returns only current revisions and
// reflects metadata after updates.
func TestListSkipsVersionedFiles(t *testing.T) {
s, _ := New(t.TempDir())
a, _ := s.CreateSet(sampleSet(), "")
b, _ := s.CreateSet(sampleSet(), "")
// Create backups for a so the dir holds versioned files too.
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
metas, err := s.List(KindSet)
if err != nil {
t.Fatal(err)
}
if len(metas) != 2 {
t.Fatalf("List: want 2 current sets, got %d", len(metas))
}
ids := map[string]bool{}
for _, m := range metas {
ids[m.ID] = true
}
if !ids[a.ID] || !ids[b.ID] {
t.Errorf("List missing expected ids: %v", ids)
}
}