A derived read reached the whole bucket, and the power module could write every machine's draw. A read granted for a block now reaches that block's key alone, by the direct get of its subject and a consumer filtered to it, and state declared per-machine is written and read at the machine's own key only.
408 lines
18 KiB
Go
408 lines
18 KiB
Go
package catalogue
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// Defends novox/hq ADR 0255: a seat may receive a kind as data in its own shape, which the holder
|
||
// renders with its template; the holder places every combination of the shape's placing fields once;
|
||
// a contribution may be placed only where the machine reports a capability; and an offered kind makes
|
||
// no dependency on its seat.
|
||
|
||
const barTemplate = `{{if eq .shows "battery"}}[[block]]
|
||
block = "battery"
|
||
{{with .options}}{{with .device}}device = {{quote .}}
|
||
{{end}}{{end}}{{else if eq .shows "command"}}[[block]]
|
||
block = "custom"
|
||
command = {{quote .options.command}}
|
||
interval = {{.options.interval}}
|
||
{{else if eq .shows "state"}}[[block]]
|
||
block = "custom"
|
||
command = {{quote (printf "show %s %s" .options.state (or .options.key .machine))}}
|
||
{{end}}`
|
||
|
||
func barHolder() Manifest {
|
||
place := func(bar string) string {
|
||
return "own-first\n${contribution:node-bar:block:bar=" + bar + ",place=resources}" +
|
||
"own-middle\n${contribution:node-bar:block:bar=" + bar + ",place=status}own-last\n"
|
||
}
|
||
return Manifest{Module: "a-bar", Claims: []Claim{{Name: BarSeat, Renders: map[string]string{BarKindBlock: barTemplate}}},
|
||
Resources: []map[string]any{
|
||
{"id": "bottom", "type": "file", "path": "/b", "content": place("bottom")},
|
||
{"id": "top", "type": "file", "path": "/t", "content": place("top")},
|
||
}}
|
||
}
|
||
|
||
func block(data string) SeatContribution {
|
||
var d map[string]any
|
||
if err := json.Unmarshal([]byte(data), &d); err != nil {
|
||
panic(err)
|
||
}
|
||
return SeatContribution{Seat: BarSeat, Kind: BarKindBlock, Data: d}
|
||
}
|
||
|
||
func composedFile(t *testing.T, r Resolution, id string) string {
|
||
t.Helper()
|
||
out, err := r.Declaration(Rendering{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, res := range out {
|
||
if res["id"] == id {
|
||
return res["content"].(string)
|
||
}
|
||
}
|
||
t.Fatalf("%s was not composed", id)
|
||
return ""
|
||
}
|
||
|
||
func TestABlockIsRenderedByTheHolderInItsPlaceAndOrderNamedByModule(t *testing.T) {
|
||
power := Manifest{Module: "power", Contributions: []SeatContribution{
|
||
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
|
||
block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"draw \"now\"","interval":5}}`),
|
||
}}
|
||
other := Manifest{Module: "another", Contributions: []SeatContribution{
|
||
block(`{"bar":"bottom","place":"status","order":70,"shows":"battery","options":{"device":"BAT1"}}`),
|
||
block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":60}}`),
|
||
}}
|
||
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power, other}}
|
||
|
||
// Ordered by order (20, 50, 70), then module order; each piece under its module's name; the
|
||
// command quoted so its own quotes cannot end the line.
|
||
want := "own-first\nown-middle\n" +
|
||
"# power\n[[block]]\nblock = \"custom\"\ncommand = \"draw \\\"now\\\"\"\ninterval = 5\n" +
|
||
"# power\n[[block]]\nblock = \"battery\"\n" +
|
||
"# another\n[[block]]\nblock = \"battery\"\ndevice = \"BAT1\"\n" +
|
||
"own-last\n"
|
||
if got := composedFile(t, r, "a-bar.bottom"); got != want {
|
||
t.Fatalf("the bottom bar is\n%s\nnot\n%s", got, want)
|
||
}
|
||
want = "own-first\n# another\n[[block]]\nblock = \"custom\"\ncommand = \"up\"\ninterval = 60\nown-middle\nown-last\n"
|
||
if got := composedFile(t, r, "a-bar.top"); got != want {
|
||
t.Fatalf("the top bar is\n%s\nnot\n%s", got, want)
|
||
}
|
||
}
|
||
|
||
func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testing.T) {
|
||
c := block(`{"bar":"bottom","place":"status","shows":"battery"}`)
|
||
c.IfCapability = "battery"
|
||
power := Manifest{Module: "power", Contributions: []SeatContribution{c}}
|
||
for _, c := range []struct {
|
||
caps map[string]bool
|
||
has bool
|
||
}{{map[string]bool{"battery": true}, true}, {map[string]bool{"seat": true}, false}, {nil, false}} {
|
||
r := Resolution{Node: "n", Account: "op", Capabilities: c.caps, Modules: []Manifest{barHolder(), power}}
|
||
if bar := composedFile(t, r, "a-bar.bottom"); strings.Contains(bar, `block = "battery"`) != c.has {
|
||
t.Errorf("with %v: the battery placed is not %v:\n%s", c.caps, c.has, bar)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) {
|
||
cases := map[string]string{
|
||
// A name nothing detects would leave the piece out on every machine.
|
||
`{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports",
|
||
// A kind the holder depends on is never left out for want of something.
|
||
`{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers",
|
||
`{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers",
|
||
}
|
||
for c, want := range cases {
|
||
raw := `{"module":"power","contributions":[` + c + `]}`
|
||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||
t.Errorf("%s: accepted, or refused without %q: %v", c, want, err)
|
||
}
|
||
}
|
||
for _, name := range []string{"battery", "power-meter", "seat"} {
|
||
if !oneOf(KnownCapabilities, name) {
|
||
t.Errorf("%s is not a known capability", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestResolveCarriesTheMachinesCapabilities(t *testing.T) {
|
||
node := workstation()
|
||
node.Capabilities["battery"] = true
|
||
r, err := Resolve(shelf(Manifest{Module: "a"}), []string{"a"}, node, World{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !r.Capabilities["battery"] || !r.Capabilities["seat"] {
|
||
t.Fatalf("the resolution carries %v, not the node's %v", r.Capabilities, node.Capabilities)
|
||
}
|
||
}
|
||
|
||
func TestAPieceTheHolderCannotRenderIsLeftOutAndNamedNotTheMachine(t *testing.T) {
|
||
// A shows the shape gained after the holder's template was written: built here, past the check.
|
||
power := Manifest{Module: "power", Contributions: []SeatContribution{
|
||
block(`{"bar":"bottom","place":"status","shows":"gpu"}`),
|
||
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
|
||
}}
|
||
r := Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), power}}
|
||
c, err := r.Compose(Rendering{})
|
||
if err != nil {
|
||
t.Fatalf("one piece failed the whole machine: %v", err)
|
||
}
|
||
if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "power's node-bar:block") ||
|
||
!strings.Contains(c.Unplaced[0], "shows=gpu") || !strings.Contains(c.Unplaced[0], "renders nothing") {
|
||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||
}
|
||
for _, res := range c.Resources {
|
||
if res["id"] == "a-bar.bottom" && !strings.Contains(res["content"].(string), `block = "battery"`) {
|
||
t.Fatalf("the piece beside it was lost too: %s", res["content"])
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestTheCataloguesBarRendersEveryExampleOfTheShape(t *testing.T) {
|
||
holder := catalogueManifest(t, "i3status-rust")
|
||
s, _ := SeatNamed(BarSeat)
|
||
if !placesKind(holder, s, BarKindBlock) {
|
||
t.Skip("the catalogue beside this checkout has a bar that places no blocks yet")
|
||
}
|
||
tmpl, err := holderTemplate(holder, s, BarKindBlock)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, ex := range s.Receives[0].Shape.Examples {
|
||
out, err := renderPiece(tmpl, "example", "a-machine", ex)
|
||
if err != nil || !strings.Contains(out, "[[block]]") {
|
||
t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err)
|
||
}
|
||
}
|
||
for shows := range barShows {
|
||
covered := false
|
||
for _, ex := range s.Receives[0].Shape.Examples {
|
||
covered = covered || ex["shows"] == shows
|
||
}
|
||
if !covered {
|
||
t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestQuoteIsATomlBasicStringOfTheSameText(t *testing.T) {
|
||
for _, in := range []string{`plain`, `say "hi" \ there`, "tab\tnew\nline", "del\x7fend", "nul\x00 esc\x1b", "<&> é ☃"} {
|
||
q, err := quote(in)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got, err := tomlBasicString(q)
|
||
if err != nil || got != in {
|
||
t.Errorf("quote(%q) = %s, which TOML reads as %q: %v", in, q, got, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// tomlBasicString reads one TOML basic string, as the TOML specification (1.0, "String") defines it:
|
||
// any character but a quote, a backslash and the control characters U+0000–U+0008, U+000A–U+001F
|
||
// and U+007F, which are written as escapes.
|
||
func tomlBasicString(s string) (string, error) {
|
||
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
|
||
return "", fmt.Errorf("not quoted")
|
||
}
|
||
var b strings.Builder
|
||
rs := []rune(s[1 : len(s)-1])
|
||
for i := 0; i < len(rs); i++ {
|
||
r := rs[i]
|
||
switch {
|
||
case r == '"':
|
||
return "", fmt.Errorf("a bare quote at %d", i)
|
||
case r == 0x7f || (r < 0x20 && r != '\t'):
|
||
return "", fmt.Errorf("a bare control character %U at %d", r, i)
|
||
case r != '\\':
|
||
b.WriteRune(r)
|
||
continue
|
||
}
|
||
i++
|
||
if i >= len(rs) {
|
||
return "", fmt.Errorf("an escape at the end")
|
||
}
|
||
switch rs[i] {
|
||
case 'b':
|
||
b.WriteRune('\b')
|
||
case 't':
|
||
b.WriteRune('\t')
|
||
case 'n':
|
||
b.WriteRune('\n')
|
||
case 'f':
|
||
b.WriteRune('\f')
|
||
case 'r':
|
||
b.WriteRune('\r')
|
||
case '"':
|
||
b.WriteRune('"')
|
||
case '\\':
|
||
b.WriteRune('\\')
|
||
case 'u', 'U':
|
||
n := 4
|
||
if rs[i] == 'U' {
|
||
n = 8
|
||
}
|
||
if i+n > len(rs)-1 {
|
||
return "", fmt.Errorf("a short \\u escape")
|
||
}
|
||
v, err := strconv.ParseUint(string(rs[i+1:i+1+n]), 16, 32)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
b.WriteRune(rune(v))
|
||
i += n
|
||
default:
|
||
return "", fmt.Errorf("the escape \\%c is not TOML's", rs[i])
|
||
}
|
||
}
|
||
return b.String(), nil
|
||
}
|
||
|
||
func TestABlockOutsideTheShapeIsRefused(t *testing.T) {
|
||
cases := map[string]string{
|
||
`{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data",
|
||
`{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content",
|
||
`{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text",
|
||
`{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`,
|
||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`,
|
||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command",
|
||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`,
|
||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99",
|
||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command",
|
||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`,
|
||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds",
|
||
`{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability",
|
||
}
|
||
for c, want := range cases {
|
||
raw := `{"module":"power","contributions":[` + c + `]}`
|
||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||
t.Errorf("%s: accepted, or refused without %q: %v", c, want, err)
|
||
}
|
||
}
|
||
ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` +
|
||
`"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}`
|
||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||
t.Errorf("a block in the shape was refused: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) {
|
||
holder := func(template string, contents ...string) string {
|
||
var res []string
|
||
for i, c := range contents {
|
||
b, _ := json.Marshal(c)
|
||
res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`)
|
||
}
|
||
tb, _ := json.Marshal(template)
|
||
renders := ""
|
||
if template != "" {
|
||
renders = `,"renders":{"block":` + string(tb) + `}`
|
||
}
|
||
return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}`
|
||
}
|
||
all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" +
|
||
"${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}"
|
||
cases := map[string]string{
|
||
holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources",
|
||
holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times",
|
||
holder("", all): "gives no template",
|
||
holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing",
|
||
holder(`{{.nothing}}`, all): "renders nothing",
|
||
holder(`{{if}`, all): "does not parse",
|
||
holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top",
|
||
holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place",
|
||
holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys",
|
||
}
|
||
for raw, want := range cases {
|
||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||
t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err)
|
||
}
|
||
}
|
||
for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"),
|
||
// A holder that places no block at all is older than the kind, and is not refused for it.
|
||
holder("", "no blocks here")} {
|
||
if _, err := ParseManifest([]byte(raw)); err != nil {
|
||
t.Errorf("%s: refused: %v", raw, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) {
|
||
power := Manifest{Module: "power", Contributions: []SeatContribution{
|
||
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
|
||
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||
}}
|
||
for _, seat := range DependsOn(power) {
|
||
if seat == BarSeat {
|
||
t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(power))
|
||
}
|
||
}
|
||
// And on a machine with no bar it is placed nowhere, and nothing fails.
|
||
r := Resolution{Node: "server", Account: "op", Capabilities: map[string]bool{"battery": true},
|
||
Modules: []Manifest{hotkeysHolder(), power}}
|
||
if _, err := r.Declaration(Rendering{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestTheBarReceivesBlocksAsData(t *testing.T) {
|
||
s, _ := SeatNamed(BarSeat)
|
||
if len(s.Receives) != 1 || s.Receives[0].Kind != BarKindBlock || s.Receives[0].Shape == nil || !s.Receives[0].Offered {
|
||
t.Fatalf("node-bar receives %+v", s.Receives)
|
||
}
|
||
for _, ex := range s.Receives[0].Shape.Examples {
|
||
if p := barBlockProblems(ex); len(p) > 0 {
|
||
t.Errorf("the shape's own example %v is refused: %v", ex, p)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *testing.T) {
|
||
power := Manifest{Module: "power", State: []StateDeclaration{{Name: "draw"}}, Contributions: []SeatContribution{
|
||
block(`{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`),
|
||
}}
|
||
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power}}
|
||
if bar := composedFile(t, r, "a-bar.bottom"); !strings.Contains(bar, `command = "show power.draw laptop"`) {
|
||
t.Fatalf("the block does not read this machine's key:\n%s", bar)
|
||
}
|
||
if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}, "laptop"); len(got) != 1 ||
|
||
got[0] != (StateRead{Read: "power.draw", Key: "laptop"}) {
|
||
t.Fatalf("the holder is granted %v", got)
|
||
}
|
||
// Only the holder, and only for what the modules on its machine offer.
|
||
if got := ReadsGranted(power, []Manifest{barHolder(), power}, "laptop"); len(got) != 0 {
|
||
t.Fatalf("a contributor is granted %v", got)
|
||
}
|
||
if got := ReadsGranted(barHolder(), []Manifest{barHolder()}, "laptop"); len(got) != 0 {
|
||
t.Fatalf("a holder alone is granted %v", got)
|
||
}
|
||
}
|
||
|
||
func TestAModuleOffersItsOwnStateOnly(t *testing.T) {
|
||
block := `{"seat":"node-bar","kind":"block","data":{"bar":"bottom","place":"status","shows":"state","options":{"state":"%s"}}}`
|
||
cases := map[string]string{
|
||
`{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "other.draw") + `]}`: "which power does not keep",
|
||
`{"module":"power","contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`: "which power does not keep",
|
||
`{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "draw") + `]}`: "does not name a module",
|
||
}
|
||
for raw, want := range cases {
|
||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||
t.Errorf("%s: accepted, or refused without %q: %v", raw, want, err)
|
||
}
|
||
}
|
||
ok := `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`
|
||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||
t.Errorf("its own state was refused: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPerMachineStateIsDeclaredAndKeptInItsLongForm(t *testing.T) {
|
||
m, err := ParseManifest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`))
|
||
if err != nil || len(m.State) != 1 || !m.State[0].PerMachine {
|
||
t.Fatalf("%+v %v", m.State, err)
|
||
}
|
||
raw, _ := json.Marshal(StateDeclaration{Name: "draw", PerMachine: true})
|
||
if string(raw) != `{"name":"draw","per-machine":true}` {
|
||
t.Fatalf("%s", raw)
|
||
}
|
||
}
|