Modules talk over the bus, and the power draw reached the bar through a file. A block may now show state its contributor keeps, the contributor only its own; the holder on the same machine is granted the read without naming the module, and the template sees which machine it renders for.
396 lines
17 KiB
Go
396 lines
17 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}); len(got) != 1 || got[0] != "power.draw" {
|
||
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}); len(got) != 0 {
|
||
t.Fatalf("a contributor is granted %v", got)
|
||
}
|
||
if got := ReadsGranted(barHolder(), []Manifest{barHolder()}); 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)
|
||
}
|
||
}
|