Issue 1585, S19: the strict parser, every manifest problem function and the judge's tables (192 declarations, about 5,200 lines) and their 244 tests leave; composition keeps its own refusals and the helpers it uses. The catalogue's tests decode the manifests they build. ParseManifest, ParseManifestWith, ParseManifestForInputs, CatalogueProblems and the rest are gone; mesh-delivery-indexer/judge holds them. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
293 lines
11 KiB
Go
293 lines
11 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660):
|
|
// one registration refusal per rule of design 57's K1, the old names read as aliases, and a module rewritten
|
|
// from settings to config composing byte-identically.
|
|
|
|
// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or
|
|
// required, placed in a file or read at runtime, and its mergeable file naming its keys.
|
|
func inConfigForm() map[string]any {
|
|
return map[string]any{
|
|
"module": "notifier",
|
|
"version": "1",
|
|
"config": map[string]any{
|
|
"font-size": map[string]any{"type": "number", "description": "the notification's font size, in points",
|
|
"minimum": 6, "maximum": 40, "default": 10},
|
|
"width": map[string]any{"type": "number", "description": "the notification's width, in pixels",
|
|
"default": 250},
|
|
"position": map[string]any{"type": "string", "description": "the corner it shows in, a word",
|
|
"enum": []any{"top-right", "top-left"}, "default": "top-right"},
|
|
"theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name",
|
|
"pattern": "[a-z]+", "default": "dark"},
|
|
"history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count",
|
|
"default": 20, "runtime": true},
|
|
"sender": map[string]any{"type": "string", "description": "the address it mails a digest from",
|
|
"required": true},
|
|
},
|
|
"resources": []any{
|
|
map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc",
|
|
"content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"},
|
|
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json",
|
|
"takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"},
|
|
map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service",
|
|
"reload-on": []any{"configuration"}, "restart-on": []any{"rules"}},
|
|
},
|
|
}
|
|
}
|
|
|
|
// declaration is one value of inConfigForm's config, to change in place.
|
|
func declaration(m map[string]any, key string) map[string]any {
|
|
return m["config"].(map[string]any)[key].(map[string]any)
|
|
}
|
|
|
|
func resourceOf(m map[string]any, id string) map[string]any {
|
|
for _, r := range m["resources"].([]any) {
|
|
if r.(map[string]any)["id"] == id {
|
|
return r.(map[string]any)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseRaw(t *testing.T, m map[string]any) (Manifest, error) {
|
|
t.Helper()
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return decodeForTest(raw)
|
|
}
|
|
|
|
// refused is that the manifest is refused with every one of these words.
|
|
func refused(t *testing.T, m map[string]any, words ...string) {
|
|
t.Helper()
|
|
_, err := parseRaw(t, m)
|
|
if err == nil {
|
|
t.Fatalf("registered; wanted a refusal saying %q", words)
|
|
}
|
|
for _, w := range words {
|
|
if !strings.Contains(err.Error(), w) {
|
|
t.Fatalf("refused without %q:\n%v", w, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAModuleInTheConfigFormRegisters(t *testing.T) {
|
|
m, err := parseRaw(t, inConfigForm())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !m.DeclaresConfig() {
|
|
t.Fatal("a module declaring config is not read as in the config form")
|
|
}
|
|
d := m.Settings["font-size"]
|
|
if d.Type != "number" || d.Description == "" || d.Minimum == nil || *d.Minimum != 6 || d.Default != float64(10) {
|
|
t.Fatalf("font-size read as %+v", d)
|
|
}
|
|
if s := m.Settings["sender"]; !s.Required || s.Default != nil {
|
|
t.Fatalf("sender read as %+v", s)
|
|
}
|
|
}
|
|
|
|
// withPredefined is the list of predefined keys the mesh published, for the length of a test.
|
|
func withPredefined(t *testing.T, keys PredefinedList) {
|
|
t.Helper()
|
|
was := ReplacePublished(keys)
|
|
t.Cleanup(func() { ReplacePublished(was) })
|
|
}
|
|
|
|
// What the mesh holds is what was written: a module in the config form is written back in it, and read again
|
|
// the same.
|
|
func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
|
|
m, err := parseRaw(t, inConfigForm())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, said := range []string{`"config":`, `${config:font-size}`, `"takes-config":`, `"required":true`} {
|
|
if !strings.Contains(string(raw), said) {
|
|
t.Fatalf("written back without %s: %s", said, raw)
|
|
}
|
|
}
|
|
for _, unsaid := range []string{`"settings":`, `${setting:`, `"takes-settings"`, `"kind"`, `"why"`, `"default":null`} {
|
|
if strings.Contains(string(raw), unsaid) {
|
|
t.Fatalf("written back with %s: %s", unsaid, raw)
|
|
}
|
|
}
|
|
again, err := decodeForTest(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
twice, _ := json.Marshal(again)
|
|
if !bytes.Equal(raw, twice) {
|
|
t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw)
|
|
}
|
|
}
|
|
|
|
// A module in the settings form is written back byte for byte as before.
|
|
func TestASettingsManifestIsWrittenBackAsBefore(t *testing.T) {
|
|
d := SettingDeclaration{Kind: KindPreference, Default: float64(10), Why: "readable"}
|
|
raw, err := json.Marshal(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(raw) != `{"kind":"preference","default":10,"why":"readable"}` {
|
|
t.Fatalf("written as %s", raw)
|
|
}
|
|
}
|
|
|
|
// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to
|
|
// config.
|
|
const settingsDunst = `{"module": "dunst", "version": "1",
|
|
"settings": {
|
|
"font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"},
|
|
"width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"}
|
|
},
|
|
"resources": [
|
|
{"id": "package", "type": "package", "package": "dunst"},
|
|
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
|
|
"content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"},
|
|
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
|
|
"takes-settings": [], "content": "{\"urgency\": \"low\"}\n"},
|
|
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
|
|
]}`
|
|
|
|
const configDunst = `{"module": "dunst", "version": "1",
|
|
"config": {
|
|
"font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6,
|
|
"maximum": 40, "default": 10},
|
|
"width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100,
|
|
"default": 250}
|
|
},
|
|
"resources": [
|
|
{"id": "package", "type": "package", "package": "dunst"},
|
|
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
|
|
"content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"},
|
|
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
|
|
"takes-config": [], "content": "{\"urgency\": \"low\"}\n"},
|
|
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
|
|
]}`
|
|
|
|
// ${config:<key>} fills exactly as ${setting:<key>} does: the module rewritten from settings to config composes
|
|
// to the same bytes, with no layer, with the node's, and with the mesh's under the node's.
|
|
func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
|
|
composed := func(manifest string, layers []Layer) []byte {
|
|
t.Helper()
|
|
m, err := decodeForTest([]byte(manifest))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := anAdoptedAnchor()
|
|
r.Modules = append(r.Modules, m)
|
|
with := anchorRendering(false)
|
|
if layers != nil {
|
|
with.Settings["dunst"] = layers
|
|
}
|
|
c, err := r.Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if why, left := c.LeftOut["dunst"]; left {
|
|
t.Fatalf("dunst was left out: %s", why)
|
|
}
|
|
raw, err := json.Marshal(c)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return raw
|
|
}
|
|
for _, layers := range [][]Layer{
|
|
nil,
|
|
{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}},
|
|
{{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}},
|
|
{From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}},
|
|
} {
|
|
was, is := composed(settingsDunst, layers), composed(configDunst, layers)
|
|
if !bytes.Equal(was, is) {
|
|
t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is)
|
|
}
|
|
if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") {
|
|
t.Fatalf("not filled: %s", is)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A required value nothing sets is refused at composition in the config form's words: ${config:<key>} and the
|
|
// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged.
|
|
func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
|
|
m, err := parseRaw(t, inConfigForm())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := anAdoptedAnchor()
|
|
r.Modules = append(r.Modules, m)
|
|
composed, err := r.Compose(anchorRendering(false))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
why, left := composed.LeftOut["notifier"]
|
|
if !left {
|
|
t.Fatal("a module whose required value nothing sets was not left out")
|
|
}
|
|
for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} {
|
|
if !strings.Contains(why, said) {
|
|
t.Fatalf("left out without %q: %s", said, why)
|
|
}
|
|
}
|
|
for _, unsaid := range []string{"${setting:", "settings set"} {
|
|
if strings.Contains(why, unsaid) {
|
|
t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why)
|
|
}
|
|
}
|
|
var unset *UnsetSettingError
|
|
if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil,
|
|
"notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" {
|
|
t.Fatalf("not typed as a value nobody gave: %v", err)
|
|
}
|
|
|
|
// A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}).
|
|
withRoute := inConfigForm()
|
|
withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}}
|
|
routed, err := parseRaw(t, withRoute)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = Resolution{}.composed(routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route")
|
|
if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(),
|
|
`notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") {
|
|
t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err)
|
|
}
|
|
|
|
settingsForm := notifier()
|
|
settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n"
|
|
err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm)
|
|
if err == nil || !strings.Contains(err.Error(), "`settings set notifier <file>`") {
|
|
t.Fatalf("the settings form's refusal changed: %v", err)
|
|
}
|
|
}
|
|
|
|
// 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed.
|
|
func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) {
|
|
m := map[string]any{
|
|
"module": "m", "version": "1",
|
|
"config": map[string]any{
|
|
"k": map[string]any{"type": "string", "description": "a word the item says", "default": "x"},
|
|
},
|
|
"items": []any{map[string]any{"collection": "a", "content": "k = ${config:k}\n"}},
|
|
}
|
|
if _, err := parseRaw(t, m); err != nil {
|
|
t.Fatalf("a value placed only in an item's text is refused: %v", err)
|
|
}
|
|
}
|