package catalogue import ( "encoding/json" "strings" "testing" ) // A preference has a default in the definition; the mesh's layer, then the node's, override it; a key // with a default is not stray; and a value that is the operator's still has none (novox/hq ADR 0262). func notifier() Manifest { return Manifest{Module: "notifier", Settings: map[string]SettingDeclaration{ "font-size": {Kind: KindPreference, Default: float64(10), Why: "readable at a scale of one"}, "width": {Kind: KindPreference, Default: float64(250), Why: "fits a title of forty characters"}, }, Resources: []map[string]any{{"id": "configuration", "type": "file", "path": "/x/notifierrc", "content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}}, } } func parsed(t *testing.T, m Manifest) error { t.Helper() raw, err := json.Marshal(m) if err != nil { t.Fatal(err) } _, err = decodeForTest(raw) return err } func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) { t.Run("the node over the mesh over the default", theNodeOverTheMeshOverTheDefault) m := notifier() for _, layers := range [][]Layer{nil, {{From: MeshWideLayer, Values: map[string]any{}}}} { file := map[string]any{} for k, v := range m.Resources[0] { file[k] = v } if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil { t.Fatal(err) } if file["content"] != "font = Inter 10\nwidth = 250\n" { t.Fatalf("filled as %q", file["content"]) } } if err := JudgeSettings(m, nil, false); err != nil { t.Fatalf("a module whose every key has a default does not compose with no layer: %v", err) } } func theNodeOverTheMeshOverTheDefault(t *testing.T) { m := notifier() layers := []Layer{ {From: MeshWideLayer, Values: map[string]any{"width": float64(300)}}, {From: "laptop", Values: map[string]any{"font-size": float64(13), "width": float64(340)}}, } file := map[string]any{"type": "file", "content": m.Resources[0]["content"]} if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil { t.Fatal(err) } if file["content"] != "font = Inter 13\nwidth = 340\n" { t.Fatalf("filled as %q", file["content"]) } file = map[string]any{"type": "file", "content": m.Resources[0]["content"]} if err := settingInto(file, WithDefaults(m, layers[:1]), m.Module); err != nil { t.Fatal(err) } if file["content"] != "font = Inter 10\nwidth = 300\n" { t.Fatalf("the mesh's layer over the default filled as %q", file["content"]) } } // Composed for a machine, the way a push writes it: the default reaches the file, and the node's // layer overrides it. func TestAComposedMachineGetsTheDefaultAndTheNodesValue(t *testing.T) { r := anAdoptedAnchor() r.Modules = append(r.Modules, notifier()) with := anchorRendering(false) composed, err := r.Compose(with) if err != nil { t.Fatal(err) } if why, left := composed.LeftOut["notifier"]; left { t.Fatalf("the notifier was left out: %s", why) } if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 10\nwidth = 250\n" { t.Fatalf("composed with no layer as %q", c) } with.Settings["notifier"] = []Layer{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}} if composed, err = r.Compose(with); err != nil { t.Fatal(err) } if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 13\nwidth = 250\n" { t.Fatalf("composed with the node's font size as %q", c) } } // Setting a key the module gives a default is not refused as reaching nothing: it overrides the // default, which is how a running module gains a setting with no gap between. func TestAKeyWithADefaultIsNotStray(t *testing.T) { m := notifier() m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n" stray := UnusedSettings(m, []Layer{{From: "laptop", Values: map[string]any{"font-size": float64(13), "colour": "red"}}}) joined := strings.Join(stray, "; ") if strings.Contains(joined, `"font-size"`) || !strings.Contains(joined, `"colour"`) { t.Fatalf("stray: %s", joined) } } // A default fills ${setting:…} only: it never becomes a key of a mergeable file (issue 168). func TestADefaultIsNotMergedIntoAJSONFile(t *testing.T) { m := notifier() m.Resources = append(m.Resources, map[string]any{"id": "other", "type": "file", "path": "/x/other.json", "merge": MergeJSON, "content": `{"keep": 1}`}) out, err := ApplySettings(m.Resources[1], WithDefaults(m, nil)) if err != nil { t.Fatal(err) } if strings.Contains(out["content"].(string), "font-size") { t.Fatalf("a default reached a mergeable file: %s", out["content"]) } } // A value that is the operator's has no default: no layer setting it is refused by name, as before. func TestAnOperatorsValueWithoutADefaultIsStillRefused(t *testing.T) { m := notifier() m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\nfrom = ${setting:domain}\n" err := JudgeSettings(m, nil, false) if err == nil || !strings.Contains(err.Error(), "${setting:domain}") { t.Fatalf("judged %v", err) } if strings.Contains(err.Error(), "font-size") { t.Fatalf("a default was named as set today: %v", err) } } func TestEveryValueSaysWhereItCameFrom(t *testing.T) { m := notifier() m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:position}\n" got := Effective(m, []Layer{ {From: MeshWideLayer, Values: map[string]any{"width": float64(300), "position": "top-right"}}, {From: "laptop", Values: map[string]any{"font-size": float64(13)}}, }) want := map[string]string{"font-size": "laptop", "position": MeshWideLayer, "width": MeshWideLayer} if len(got) != 3 { t.Fatalf("effective: %+v", got) } for _, s := range got { if s.From != want[s.Key] { t.Errorf("%s from %q, want %q", s.Key, s.From, want[s.Key]) } } if got[0].Key != "font-size" || got[0].Value != float64(13) || got[0].Default != float64(10) { t.Fatalf("font-size: %+v", got[0]) } only := Effective(m, nil) if only[0].From != DefaultLayer || only[0].Value != float64(10) { t.Fatalf("with no layer: %+v", only) } } // A node may be called `default`. Its layer is a node's like any other: it overrides the module's // default, it merges into a mergeable file, and it is named among what is set. func TestANodeCalledDefaultIsANodesLayer(t *testing.T) { m := notifier() node := []Layer{{From: DefaultLayer, Values: map[string]any{"font-size": float64(13)}}} file := map[string]any{"type": "file", "content": m.Resources[0]["content"]} if err := settingInto(file, WithDefaults(m, node), m.Module); err != nil { t.Fatal(err) } if file["content"] != "font = Inter 13\nwidth = 250\n" { t.Fatalf("the node called default was dropped: %q", file["content"]) } out, err := ApplySettings(map[string]any{"id": "j", "type": "file", "merge": MergeJSON, "content": `{}`}, node) if err != nil { t.Fatal(err) } if !strings.Contains(out["content"].(string), `"font-size": 13`) { t.Fatalf("the node called default did not merge: %s", out["content"]) } if got := Effective(m, node); got[0].FromDefault || got[0].Value != float64(13) { t.Fatalf("the node's value read as the default: %+v", got[0]) } } // A default fills ${setting:…} in what a module contributes and serves, and never replaces a value a // contribution states itself. func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) { m := notifier() m.Settings["site-title"] = SettingDeclaration{Kind: KindPreference, Default: "Notes", Why: "x"} contribution := map[string]any{"title": "${setting:site-title}", "width": "fixed", "port": float64(8080)} got, err := overridden(contribution, WithDefaults(m, nil), "a contribution") if err != nil { t.Fatal(err) } if got["title"] != "Notes" || got["width"] != "fixed" { t.Fatalf("contribution: %v", got) } got, err = overridden(contribution, WithDefaults(m, []Layer{{From: "laptop", Values: map[string]any{"width": "wide"}}}), "a contribution") if err != nil || got["width"] != "wide" { t.Fatalf("a node's value did not override a contribution's own key: %v %v", got, err) } served, err := Settle(map[string]any{"name": "${setting:site-title}"}, WithDefaults(m, nil)) if err != nil || served["name"] != "Notes" { t.Fatalf("served: %v %v", served, err) } if _, err := Settle(map[string]any{"name": "${setting:site-title}"}, nil); err == nil { t.Fatal("a served fact without the defaults was filled") } } // A module left out for a key this controller does not know, inside an entry, is read past that key // alone: it still provides what it provides, its other entries are whole, and a key of the same name // that another entry knows is kept (novox/hq ADR 0262). func aModuleWithAFieldSetAsideStillProvidesWhatItProvides(t *testing.T) { var m Manifest raw := `{"module": "later", "version": "2", "provides": [{"name": "db", "scope": "mesh"}, {"name": "cache", "a-field-from-later": 1}], "state": [{"name": "s", "history": 3}], "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "d", "class": "later"}}]}, "resources": [{"id": "d", "type": "directory", "mode": "0700"}]}` if err := json.Unmarshal([]byte(raw), &m); err != nil { t.Fatal(err) } if len(m.Provides) != 2 || m.Provides[0].Name != "db" || m.Provides[1].Name != "cache" { t.Fatalf("provides: %+v", m.Provides) } if len(m.State) != 1 || m.State[0].History != 3 { t.Fatalf("state: %+v", m.State) } if m.Data == nil || len(m.Data.Own) != 1 || m.Data.Own[0].Class != "valuable" { t.Fatalf("data: the item's own class was taken for the backup's unknown one: %+v", m.Data) } for _, want := range []string{"provides[1].a-field-from-later", "data.own[0].backup.class"} { if !strings.Contains(m.UnknownField(), want) { t.Errorf("unknown %q does not say it read without %s", m.UnknownField(), want) } } if why := UnknownFieldReason(m); why != "" { t.Fatal(why) } } // A module with a field set aside is composed whole: its files are declared and its data backed up (novox/hq ADR 0578 // §6; before, ADR 0262 left it out and backed up its data alone). func aModuleWithAFieldSetAsideIsComposedWhole(t *testing.T) { var later Manifest if err := json.Unmarshal([]byte(`{"module": "later", "version": "2", "a-field-from-later": 1, "resources": [{"id": "d", "type": "directory", "mode": "0700"}, {"id": "rc", "type": "file", "path": "/etc/later.conf", "content": "x\n"}], "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable"}]}}`), &later); err != nil { t.Fatal(err) } holder := Manifest{Module: "backups", Version: "1", Claims: []Claim{{Name: BackupSeat, Scope: ScopeNode}}, Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644", "content": "${contribution:" + BackupSeat + ":backup}"}}} r := anAdoptedAnchor() r.Modules = append(r.Modules, holder, later) composed, err := r.Compose(anchorRendering(false)) if err != nil { t.Fatal(err) } if _, left := composed.LeftOut["later"]; left { t.Fatalf("left out: %v", composed.LeftOut) } got := byID(composed.Resources) if _, declared := got["later.rc"]; !declared { t.Fatal("the module's file is not declared") } list, _ := got["backups.list"]["content"].(string) if !strings.Contains(list, "# later") || !strings.Contains(list, "path /var/lib/later/d") { t.Fatalf("the left-out module's data is no longer backed up:\n%s", list) } } // A left-out module's backup lines are best effort: a line that cannot be placed — an access nobody // placed, a placement setting that does not read — is said among what could not be placed, and the // machine is declared (novox/hq ADR 0262). func TestALeftOutModulesUnplaceableBackupLineCostsOnlyThatLine(t *testing.T) { holder := Manifest{Module: "backups", Version: "1", Claims: []Claim{{Name: BackupSeat, Scope: ScopeNode}}, Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644", "content": "${contribution:" + BackupSeat + ":backup}"}}} media := Manifest{Module: "media", Version: "1", Accesses: []Access{{ID: "library", Mode: "read"}}, Data: &Data{Own: []DataItem{{ID: "library", Path: "${access:library}", Class: "valuable"}}}} placedBadly := Manifest{Module: "notes", Version: "1", Resources: []map[string]any{{"id": "d", "type": "directory", "path": "/srv/notes", "mode": "0700"}}, Data: &Data{Own: []DataItem{{ID: "d", Path: "${dir:d}", Class: "valuable"}}}} r := anAdoptedAnchor() r.Modules = append(r.Modules, holder, media, placedBadly) with := anchorRendering(false) with.Settings["notes"] = []Layer{LayerOf("anchor", map[string]any{PlacesSetting: "not a map"})} composed, err := r.Compose(with) if err != nil { t.Fatalf("an unplaceable line of a left-out module failed the machine: %v", err) } for _, m := range []string{"media", "notes"} { if _, left := composed.LeftOut[m]; !left { t.Errorf("%s is not left out: %v", m, composed.LeftOut) } } unplaced := strings.Join(composed.Unplaced, "\n") for _, want := range []string{"media's backup for node-backup", "notes's backup for node-backup", "placement setting does not read"} { if !strings.Contains(unplaced, want) { t.Errorf("not said among what could not be placed: %q in\n%s", want, unplaced) } } list, _ := byID(composed.Resources)["backups.list"]["content"].(string) if strings.Contains(list, "/srv/notes") || strings.Contains(list, "${") { t.Fatalf("a line was placed from the definition's default or unfilled:\n%s", list) } }