554 lines
22 KiB
Go
554 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log,
|
|
// the events it raised and the hand-acts it recorded.
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
states map[string]map[string][]byte
|
|
logs map[string][]LogEntry
|
|
events []Change
|
|
handActs []string
|
|
// failHandAct makes the hand-act log refuse.
|
|
failHandAct bool
|
|
}
|
|
|
|
func newStore() *memStore {
|
|
return &memStore{states: map[string]map[string][]byte{}, logs: map[string][]LogEntry{}}
|
|
}
|
|
|
|
func (m *memStore) Get(state, key string) (*Entry, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
v, ok := m.states[state][key]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &Entry{Key: key, Value: append([]byte(nil), v...)}, nil
|
|
}
|
|
|
|
func (m *memStore) Put(state, key string, value any) error {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.states[state] == nil {
|
|
m.states[state] = map[string][]byte{}
|
|
}
|
|
m.states[state][key] = raw
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Delete(state, key string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.states[state], key)
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Keys(state string) ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var keys []string
|
|
for k := range m.states[state] {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys, nil
|
|
}
|
|
|
|
func (m *memStore) Append(log, key string, value any) error {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.logs[log] = append(m.logs[log], LogEntry{Key: key, Value: raw, At: time.Now()})
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Read(log string) ([]LogEntry, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return append([]LogEntry(nil), m.logs[log]...), nil
|
|
}
|
|
|
|
func (m *memStore) Emit(event string, body any) error {
|
|
if event != ConfigChanged {
|
|
return fmt.Errorf("mesh-config emits no %s", event)
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.events = append(m.events, body.(Change))
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) HandAct(what, why, cause string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.failHandAct {
|
|
return errors.New("the controller did not answer")
|
|
}
|
|
m.handActs = append(m.handActs, what+" | "+why+" | "+cause)
|
|
return nil
|
|
}
|
|
|
|
// mirror writes what the controller mirrors, in the shapes of its mirrors.
|
|
func (m *memStore) mirror(t *testing.T, read, key, raw string) {
|
|
t.Helper()
|
|
if !json.Valid([]byte(raw)) {
|
|
t.Fatalf("not JSON: %s", raw)
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.states[read] == nil {
|
|
m.states[read] = map[string][]byte{}
|
|
}
|
|
m.states[read][key] = []byte(raw)
|
|
}
|
|
|
|
func (m *memStore) assign(t *testing.T, node, module string) {
|
|
m.mirror(t, AssignmentsRead, node+"."+module,
|
|
fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module))
|
|
}
|
|
|
|
func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved {
|
|
t.Helper()
|
|
e, _ := m.Get(ResolvedState, ResolvedKey(node, module, key))
|
|
if e == nil {
|
|
t.Fatalf("%s holds no resolved value for %s on %s", key, module, node)
|
|
}
|
|
var r Resolved
|
|
if err := json.Unmarshal(e.Value, &r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// dunstDeclared is dunst's declarations as the declared-config mirror on the feature branch writes them: kind, why
|
|
// and default always written, so a config-form key without a default comes as kind "", why "" and default null.
|
|
const dunstDeclared = `{"module":"dunst","settings":{
|
|
"font-size":{"kind":"","default":10,"why":"","type":"number","description":"text size, in points","minimum":6,"maximum":72,"predefined":"font-size"},
|
|
"width":{"kind":"","default":300,"why":"","type":"number","description":"the width of a notification, in pixels","minimum":100},
|
|
"position":{"kind":"","default":"top-right","why":"","type":"string","description":"where notifications show","enum":["top-right","top-left"]},
|
|
"origin":{"kind":"","default":null,"why":"","type":"string","description":"the operator's own origin","required":true,"pattern":"[a-z]+"}}}`
|
|
|
|
// theList is the controller's predefined mirror, as it writes it.
|
|
const theList = `{"keys":{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
|
|
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}},
|
|
"repository":"mesh-catalog","commit":"aaaaaaaaaaaa","generation":5,"build":"b5"}`
|
|
|
|
func aMesh(t *testing.T) (*Config, *memStore) {
|
|
t.Helper()
|
|
m := newStore()
|
|
m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared)
|
|
m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{
|
|
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
|
|
m.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
|
|
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
|
|
for _, node := range []string{"g14", "novox"} {
|
|
m.assign(t, node, "dunst")
|
|
}
|
|
m.assign(t, "g14", "claude-code")
|
|
m.assign(t, "g14", "old")
|
|
clock := time.Date(2026, 10, 14, 19, 0, 0, 0, time.UTC)
|
|
c := &Config{Store: m, Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Logf: t.Logf}
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.events = nil // the first resolution's
|
|
return c, m
|
|
}
|
|
|
|
func set(t *testing.T, c *Config, call Call) *SetAnswer {
|
|
t.Helper()
|
|
if call.Action == "" {
|
|
call.Action = "set"
|
|
}
|
|
if call.Caller == "" {
|
|
call.Caller = "g14.node-tools"
|
|
}
|
|
if call.Why == "" {
|
|
call.Why = "the operator asked for it"
|
|
}
|
|
a, err := c.Change(call)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", call.Action, call.Key, err)
|
|
}
|
|
return a
|
|
}
|
|
|
|
func expect(t *testing.T, m *memStore, node string, value, source string) {
|
|
t.Helper()
|
|
r := m.resolved(t, node, "dunst", "font-size")
|
|
if string(r.Value) != value || r.Source != source {
|
|
t.Errorf("dunst's font-size on %s is %s from %s, want %s from %s", node, r.Value, r.Source, value, source)
|
|
}
|
|
}
|
|
|
|
// **The four sources, each in turn** (ADR 0348 §2, How it is checked): the manifest's default; mesh config for the
|
|
// predefined key it falls back to; node config for that key on the node; the module's own value on that node. Each
|
|
// cleared falls back to the one below.
|
|
func TestEachOfTheFourSourcesResolvesInTurnAndEachClearedFallsBack(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
expect(t, m, "g14", "10", SourceDefault)
|
|
expect(t, m, "novox", "10", SourceDefault)
|
|
|
|
a := set(t, c, Call{Key: "font-size", Value: 12.0})
|
|
expect(t, m, "g14", "12", SourceMesh)
|
|
expect(t, m, "novox", "12", SourceMesh)
|
|
if len(a.Moved) != 2 || a.Level.Kind != LevelMesh {
|
|
t.Errorf("a mesh value moved %+v", a.Moved)
|
|
}
|
|
|
|
a = set(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"})
|
|
expect(t, m, "g14", "14", SourceNode)
|
|
expect(t, m, "novox", "12", SourceMesh)
|
|
if len(a.Moved) != 1 || a.Moved[0].Node != "g14" || string(a.Moved[0].From) != "12" || string(a.Moved[0].To) != "14" {
|
|
t.Errorf("g14's node value moved %+v", a.Moved)
|
|
}
|
|
|
|
a = set(t, c, Call{Key: "font-size", Value: 16.0, Node: "g14", Module: "dunst"})
|
|
expect(t, m, "g14", "16", SourceModule)
|
|
expect(t, m, "novox", "12", SourceMesh)
|
|
if len(a.Moved) != 1 || a.Moved[0].Source != SourceModule {
|
|
t.Errorf("dunst's own value moved %+v", a.Moved)
|
|
}
|
|
|
|
// A value set below the module's own moves nothing it overrides.
|
|
if a = set(t, c, Call{Key: "font-size", Value: 18.0, Node: "g14"}); len(a.Moved) != 0 {
|
|
t.Errorf("a node value under a module's own moved %+v", a.Moved)
|
|
}
|
|
|
|
set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14", Module: "dunst"})
|
|
expect(t, m, "g14", "18", SourceNode)
|
|
set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14"})
|
|
expect(t, m, "g14", "12", SourceMesh)
|
|
set(t, c, Call{Action: "clear", Key: "font-size"})
|
|
expect(t, m, "g14", "10", SourceDefault)
|
|
expect(t, m, "novox", "10", SourceDefault)
|
|
}
|
|
|
|
// **A value naming no predefined key ignores mesh and node config** (ADR 0348 §2): it resolves from its default and
|
|
// the module's own value only.
|
|
func TestAValueNamingNoPredefinedKeyResolvesFromItsDefaultAndItsOwnValueOnly(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
set(t, c, Call{Key: "font-size", Value: 12.0})
|
|
set(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"})
|
|
if r := m.resolved(t, "g14", "dunst", "width"); string(r.Value) != "300" || r.Source != SourceDefault {
|
|
t.Errorf("width is %s from %s, want its default", r.Value, r.Source)
|
|
}
|
|
set(t, c, Call{Key: "width", Value: 400.0, Node: "g14", Module: "dunst"})
|
|
if r := m.resolved(t, "g14", "dunst", "width"); string(r.Value) != "400" || r.Source != SourceModule {
|
|
t.Errorf("width is %s from %s, want dunst's own value", r.Value, r.Source)
|
|
}
|
|
// A module not yet moved, in the settings form: its default and its own value.
|
|
if r := m.resolved(t, "g14", "old", "workers"); string(r.Value) != "4" || r.Source != SourceDefault {
|
|
t.Errorf("old's workers is %s from %s", r.Value, r.Source)
|
|
}
|
|
set(t, c, Call{Key: "workers", Value: 8.0, Node: "g14", Module: "old"})
|
|
if r := m.resolved(t, "g14", "old", "workers"); string(r.Value) != "8" || r.Source != SourceModule {
|
|
t.Errorf("old's workers is %s from %s", r.Value, r.Source)
|
|
}
|
|
}
|
|
|
|
// **A required value nothing sets is answered unset** (ADR 0348 §4), and once set, it is the value.
|
|
func TestARequiredValueNothingSetsIsAnsweredUnset(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
r := m.resolved(t, "g14", "dunst", "origin")
|
|
if r.Source != SourceUnset || !r.Required || len(r.Value) != 0 {
|
|
t.Errorf("an unset required value resolves as %+v", r)
|
|
}
|
|
set(t, c, Call{Key: "origin", Value: "home", Node: "g14", Module: "dunst"})
|
|
if r = m.resolved(t, "g14", "dunst", "origin"); r.Source != SourceModule || string(r.Value) != `"home"` {
|
|
t.Errorf("a set required value resolves as %+v", r)
|
|
}
|
|
if r = m.resolved(t, "novox", "dunst", "origin"); r.Source != SourceUnset {
|
|
t.Errorf("another node's required value resolves as %+v", r)
|
|
}
|
|
}
|
|
|
|
// **The verb refuses** (ADR 0348 §3, §8, §10, How it is checked): a key not declared, naming the declared keys; a
|
|
// module value without a node; a value failing validation; a trusted value without the terminal; a change without
|
|
// why or a proven caller.
|
|
func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
call Call
|
|
want string
|
|
}{
|
|
{"a key the module does not declare", Call{Key: "colour", Value: "red", Node: "g14", Module: "dunst"},
|
|
"it declares: font-size, origin, position, width"},
|
|
{"a key not on the predefined list, at the mesh", Call{Key: "width", Value: 400.0},
|
|
"the predefined keys are: agent-managed-settings, font-size"},
|
|
{"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"},
|
|
{"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"},
|
|
"no module value for the whole mesh"},
|
|
{"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
|
|
"it is assigned to: g14, novox"},
|
|
{"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"},
|
|
{"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`},
|
|
{"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"},
|
|
{"a value above its maximum", Call{Key: "font-size", Value: 99.0, Node: "g14", Module: "dunst"}, "above the maximum 72"},
|
|
{"a value not in its enum", Call{Key: "position", Value: "middle", Node: "g14", Module: "dunst"}, "is not one of"},
|
|
{"a value not matching its pattern", Call{Key: "origin", Value: "Home 1", Node: "g14", Module: "dunst"},
|
|
"does not match the pattern"},
|
|
{"a trusted value at the mesh, not from the terminal", Call{Key: "agent-managed-settings",
|
|
Value: map[string]any{"a": 1.0}}, "set only at the controller's terminal"},
|
|
{"a trusted module value, not from the terminal", Call{Key: "managed", Value: map[string]any{},
|
|
Node: "g14", Module: "claude-code"}, "set only at the controller's terminal"},
|
|
{"a change without why", Call{Key: "font-size", Value: 12.0, Why: " "}, "say why"},
|
|
{"an unproven caller", Call{Key: "font-size", Value: 12.0, Caller: Unproven}, "no proven caller"},
|
|
{"by from anyone but the controller", Call{Key: "font-size", Value: 12.0, By: "jochen"}, "only the controller says"},
|
|
{"a clear of nothing set", Call{Action: "clear", Key: "font-size", Node: "g14"}, "nothing is set"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
call := tc.call
|
|
if call.Action == "" {
|
|
call.Action = "set"
|
|
}
|
|
if call.Caller == "" {
|
|
call.Caller = "g14.node-tools"
|
|
}
|
|
if call.Why == "" {
|
|
call.Why = "a test"
|
|
}
|
|
_, err := c.Change(call)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("refused with %v, want %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
if keys, _ := m.Keys(ValuesState); len(keys) != 0 {
|
|
t.Errorf("a refused call wrote %v", keys)
|
|
}
|
|
if len(m.events) != 0 || len(m.handActs) != 0 {
|
|
t.Errorf("a refused call raised %d events and recorded %d hand-acts", len(m.events), len(m.handActs))
|
|
}
|
|
}
|
|
|
|
// **A trusted value from the controller's terminal is set**, with who gave it there (ADR 0348 §8).
|
|
func TestATrustedValueFromTheTerminalIsSetWithWhoGaveIt(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
a := set(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"b": 2.0, "a": 1.0},
|
|
Caller: ControllerCaller, By: "jochen"})
|
|
if string(a.Value) != `{"a":1,"b":2}` {
|
|
t.Errorf("the value is written as %s", a.Value)
|
|
}
|
|
r := m.resolved(t, "g14", "claude-code", "managed")
|
|
if r.Source != SourceMesh || !r.Trusted || string(r.Value) != `{"a":1,"b":2}` {
|
|
t.Errorf("claude-code's managed settings resolve as %+v", r)
|
|
}
|
|
e, _ := m.Get(ValuesState, "mesh.agent-managed-settings")
|
|
var v Value
|
|
_ = json.Unmarshal(e.Value, &v)
|
|
if v.By != "jochen" || v.Via != ControllerCaller {
|
|
t.Errorf("the value says it was set by %s via %s", v.By, v.Via)
|
|
}
|
|
}
|
|
|
|
// **Every set and clear is in the history and the hand-act log with the proven caller and why, and raises
|
|
// config-changed naming what moved** (ADR 0348 §9, §10).
|
|
func TestEveryChangeIsKeptWithWhoAndWhyAndRaisesConfigChanged(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
m.events = nil
|
|
set(t, c, Call{Key: "font-size", Value: 12.0, Caller: "novox.node-tools", Why: "bigger on every desk"})
|
|
set(t, c, Call{Action: "clear", Key: "font-size", Caller: "g14.node-tools", Why: "back to the default"})
|
|
|
|
history, err := c.History(Call{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var changes []HistoryEntry
|
|
for _, h := range history {
|
|
if h.Action == "set" || h.Action == "clear" {
|
|
changes = append(changes, h)
|
|
}
|
|
}
|
|
if len(changes) != 2 || changes[0].By != "novox.node-tools" || changes[0].Why != "bigger on every desk" ||
|
|
changes[1].Action != "clear" || changes[1].By != "g14.node-tools" || string(changes[1].Was) != "12" {
|
|
t.Fatalf("the history holds %+v", changes)
|
|
}
|
|
if len(m.handActs) != 2 || !strings.Contains(m.handActs[0], "config set font-size at mesh config to 12, by novox.node-tools | bigger on every desk") {
|
|
t.Errorf("the hand-act log holds %q", m.handActs)
|
|
}
|
|
if len(m.events) != 2 || len(m.events[0].Moved) != 2 || m.events[0].Key != "font-size" || m.events[1].Action != "clear" {
|
|
t.Errorf("config-changed was raised as %+v", m.events)
|
|
}
|
|
// Narrowed: g14's dunst sees both, through what they moved; a node that holds no dunst sees neither.
|
|
if h, _ := c.History(Call{Module: "dunst", Node: "g14"}); len(h) < 2 {
|
|
t.Errorf("dunst on g14's history holds %d changes", len(h))
|
|
}
|
|
if h, _ := c.History(Call{Node: "ace"}); len(h) != 0 {
|
|
t.Errorf("ace's history holds %+v", h)
|
|
}
|
|
}
|
|
|
|
// **A hand-act the log refuses is said in the answer, never dropped**, and the value is set.
|
|
func TestAChangeTheHandActLogRefusesIsSaid(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
m.failHandAct = true
|
|
a := set(t, c, Call{Key: "font-size", Value: 12.0})
|
|
if len(a.Unsaid) != 1 || !strings.Contains(a.Unsaid[0], "hand-act log does not hold it") {
|
|
t.Errorf("the answer says %q", a.Unsaid)
|
|
}
|
|
expect(t, m, "g14", "12", SourceMesh)
|
|
}
|
|
|
|
// **The newest list of predefined keys is published, and an older one heard later is not** (ADR 0348 §7).
|
|
func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
published := func() map[string]Published {
|
|
out := map[string]Published{}
|
|
keys, _ := m.Keys(PredefinedState)
|
|
for _, k := range keys {
|
|
e, _ := m.Get(PredefinedState, k)
|
|
var p Published
|
|
_ = json.Unmarshal(e.Value, &p)
|
|
out[k] = p
|
|
}
|
|
return out
|
|
}
|
|
if p := published(); len(p) != 2 || p["font-size"].Generation != 5 || !p["agent-managed-settings"].Trusted {
|
|
t.Fatalf("published %+v", p)
|
|
}
|
|
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"font-size":{"type":"number","description":"x"},
|
|
"theme":{"type":"string","description":"the theme"}},"commit":"bbbb","generation":7}`)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := published(); len(p) != 2 || p["theme"].Generation != 7 || p["agent-managed-settings"].Type != "" {
|
|
t.Fatalf("a newer list published %+v", p)
|
|
}
|
|
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) // generation 5, heard later
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := published(); p["theme"].Generation != 7 {
|
|
t.Fatalf("an older list replaced the newer one: %+v", p)
|
|
}
|
|
}
|
|
|
|
// **A list dropping a key that still holds a value is withheld** (ADR 0348 §7): the list published stands, and the
|
|
// listing says why until the value is cleared.
|
|
func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"})
|
|
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"agent-managed-settings":{"type":"object",
|
|
"description":"managed settings","trusted":true}},"commit":"cccccccccc","generation":9}`)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listed, err := c.List(Call{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, kept := listed.Predefined["font-size"]; !kept ||
|
|
!strings.Contains(listed.Withheld, "drops font-size (still set as node config on novox)") {
|
|
t.Fatalf("the list was published over a value it drops: withheld %q, published %v", listed.Withheld,
|
|
joinKeys(listed.Predefined))
|
|
}
|
|
set(t, c, Call{Action: "clear", Key: "font-size", Node: "novox"})
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listed, _ = c.List(Call{})
|
|
if _, kept := listed.Predefined["font-size"]; kept || listed.Withheld != "" {
|
|
t.Errorf("once cleared, the list is still withheld: %q, %v", listed.Withheld, joinKeys(listed.Predefined))
|
|
}
|
|
}
|
|
|
|
// **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and
|
|
// said as config-changed; a pass over unchanged sources writes nothing.
|
|
func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
r, err := c.Reconcile()
|
|
if err != nil || len(r.Moved) != 0 {
|
|
t.Fatalf("a pass over unchanged sources moved %+v (%v)", r.Moved, err)
|
|
}
|
|
m.events = nil
|
|
m.assign(t, "ace", "dunst")
|
|
if r, _ = c.Reconcile(); len(r.Moved) != 4 {
|
|
t.Errorf("an assignment moved %+v", r.Moved)
|
|
}
|
|
expect(t, m, "ace", "10", SourceDefault)
|
|
if len(m.events) != 1 || m.events[0].Action != "resolve" {
|
|
t.Errorf("an assignment raised %+v", m.events)
|
|
}
|
|
_ = m.Delete(AssignmentsRead, "ace.dunst")
|
|
if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
|
t.Errorf("an unassignment moved %+v", r.Moved)
|
|
}
|
|
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil {
|
|
t.Error("an unassigned module keeps its resolved values")
|
|
}
|
|
}
|
|
|
|
// **The declared-config mirror's two shapes read the same** (the release lead's note on issue 652): kind, why and a
|
|
// null default written for a config-form key are not given, and a mirror that leaves them out reads alike.
|
|
func TestTheDeclaredConfigMirrorsTwoShapesReadTheSame(t *testing.T) {
|
|
with, err := ParseModuleDeclarations([]byte(`{"module":"x","settings":{"k":{"kind":"","default":null,"why":"","type":"string","description":"d","required":true}}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
without, err := ParseModuleDeclarations([]byte(`{"module":"x","settings":{"k":{"type":"string","description":"d","required":true}}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, _ := json.Marshal(with)
|
|
b, _ := json.Marshal(without)
|
|
if string(a) != string(b) || with.Config["k"].HasDefault() {
|
|
t.Errorf("the two shapes read as %s and %s", a, b)
|
|
}
|
|
if r := resolveOne(Sources{}, "n", "x", "k", with.Config["k"]); r.Source != SourceUnset {
|
|
t.Errorf("a null default resolved as %+v", r)
|
|
}
|
|
}
|
|
|
|
// **A listing narrows by module and node**, and says the mesh and node values its modules fall back to.
|
|
func TestAListingNarrowsByModuleAndNode(t *testing.T) {
|
|
c, _ := aMesh(t)
|
|
set(t, c, Call{Key: "font-size", Value: 12.0})
|
|
set(t, c, Call{Key: "font-size", Value: 14.0, Node: "novox"})
|
|
l, err := c.List(Call{Module: "dunst", Node: "g14"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(l.Resolved) != 4 || len(l.Set) != 1 || l.Set[0].Level.Kind != LevelMesh || l.Predefined != nil {
|
|
t.Errorf("dunst on g14 lists %d resolved and %+v", len(l.Resolved), l.Set)
|
|
}
|
|
l, _ = c.List(Call{Node: "novox"})
|
|
if len(l.Resolved) != 4 || len(l.Set) != 2 {
|
|
t.Errorf("novox lists %d resolved and %+v", len(l.Resolved), l.Set)
|
|
}
|
|
}
|
|
|
|
// **The verb's arguments are read strictly**: one it does not know is refused, naming what it takes.
|
|
func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
|
|
if _, err := callOf(map[string]any{"action": "set", "level": "mesh"}); err == nil ||
|
|
!strings.Contains(err.Error(), "config takes no level") {
|
|
t.Errorf("an unknown argument: %v", err)
|
|
}
|
|
if _, err := callOf(map[string]any{"key": 3.0}); err == nil {
|
|
t.Error("a key that is not a string was read")
|
|
}
|
|
call, err := callOf(map[string]any{"action": "set", "key": "font-size", "value": 12.0, "why": " w "})
|
|
if err != nil || call.Value != 12.0 || call.Why != "w" {
|
|
t.Errorf("read %+v (%v)", call, err)
|
|
}
|
|
}
|