Testing
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user