status: one machine that cannot be worked out no longer takes the answer from the rest
`status --json` emitted no JSON at all when a single node was unresolvable. A blocked node is not on the private network, and a mesh whose hub is that node has no hub — which came back through the reading as a refusal, so `status` printed nothing and `status --json` put multi-line prose on stderr and not one byte on stdout. A machine-readable interface that stops being machine-readable exactly when something is wrong is one nobody can build an alarm on. Why a machine cannot be worked out is read as data now, per machine, through the same whoResolves the private network is built from — so this and the network agree about who could not be resolved rather than deciding it twice. The private network failing to compute is kept as a note beside it instead of ending the read: it is almost always a consequence of those same refusals, and every question that does not depend on it is still answered. It reaches all three ways of saying it, from the one reading: the text form leads with it because a machine here is in none of the answers below, the JSON carries `unresolved` (always a list, never null) and `network`, and the page has a section of its own. That also closes a silent success. A machine that resolves to nothing has nothing computed for it, so there is nothing to compare it against and nothing it can be behind — it appeared in no answer at all, and `status` reported a mesh where nothing could be sent anywhere as "all doing what they were told". statusAsJSON takes the whole reading now rather than a growing argument list, which is what let an answer be added to the text form and forgotten here. The two are one function's output in two shapes and must not be able to differ about what was asked. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,8 +96,7 @@ func board() http.Handler {
|
|||||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := statusAsJSON(asked.wrong, asked.nodes, asked.quiet, asked.behind, asked.sources,
|
body, err := statusAsJSON(asked)
|
||||||
asked.waiting, asked.reported)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -129,7 +130,21 @@ type view struct {
|
|||||||
Quiet []quietMachine
|
Quiet []quietMachine
|
||||||
Behind []staleModule
|
Behind []staleModule
|
||||||
Waiting []waitingMachine
|
Waiting []waitingMachine
|
||||||
At string
|
// Unresolved is every machine that cannot be worked out at all. Shown above everything else,
|
||||||
|
// because a machine here is in none of the other lists: nothing was computed for it, so it is
|
||||||
|
// not broken, not quiet and not behind — and a page without this said "all well" about a mesh
|
||||||
|
// where nothing could be sent anywhere.
|
||||||
|
Unresolved []blockedMachine
|
||||||
|
// Network is why the private network could not be computed, when it could not.
|
||||||
|
Network string
|
||||||
|
At string
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockedMachine struct {
|
||||||
|
Node string
|
||||||
|
// Said is the mesh's own words, a line at a time. Every unmet requirement, not the first:
|
||||||
|
// a machine is usually blocked by more than one and fixing one of them changes nothing.
|
||||||
|
Said []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type waitingMachine struct {
|
type waitingMachine struct {
|
||||||
@@ -165,7 +180,20 @@ type staleModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func viewOf(asked answers) view {
|
func viewOf(asked answers) view {
|
||||||
out := view{Machines: len(asked.nodes), At: time.Now().Format("15:04:05")}
|
out := view{Machines: len(asked.nodes), At: time.Now().Format("15:04:05"),
|
||||||
|
Network: asked.network}
|
||||||
|
var blocked []string
|
||||||
|
for name := range asked.refused {
|
||||||
|
blocked = append(blocked, name)
|
||||||
|
}
|
||||||
|
sort.Strings(blocked)
|
||||||
|
for _, name := range blocked {
|
||||||
|
one := blockedMachine{Node: name}
|
||||||
|
for _, line := range strings.Split(strings.TrimRight(asked.refused[name], "\n"), "\n") {
|
||||||
|
one.Said = append(one.Said, strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
out.Unresolved = append(out.Unresolved, one)
|
||||||
|
}
|
||||||
for _, d := range asked.wrong {
|
for _, d := range asked.wrong {
|
||||||
one := brokenMachine{Node: d.Node, Outcome: d.Outcome,
|
one := brokenMachine{Node: d.Node, Outcome: d.Outcome,
|
||||||
When: d.At.Local().Format("2006-01-02 15:04")}
|
When: d.At.Local().Format("2006-01-02 15:04")}
|
||||||
@@ -223,6 +251,22 @@ find out.</p>
|
|||||||
{{else}}
|
{{else}}
|
||||||
<h1>{{.Machines}} machine{{if ne .Machines 1}}s{{end}}</h1>
|
<h1>{{.Machines}} machine{{if ne .Machines 1}}s{{end}}</h1>
|
||||||
|
|
||||||
|
{{if .Unresolved}}
|
||||||
|
<h2>Can everything be worked out?</h2>
|
||||||
|
<ul>
|
||||||
|
{{range .Unresolved}}
|
||||||
|
<li><span class="outcome failed">blocked</span> <strong>{{.Node}}</strong>
|
||||||
|
{{range .Said}}<div class="said">{{.}}</div>{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<p class="quiet">Nothing can be sent to a machine here, and it appears in none of the lists below:
|
||||||
|
nothing was computed for it, so there is nothing it can be behind.</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Network}}
|
||||||
|
<p class="failed">The private network could not be computed: {{.Network}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<h2>Is anything broken?</h2>
|
<h2>Is anything broken?</h2>
|
||||||
{{if .Broken}}
|
{{if .Broken}}
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -442,4 +442,13 @@ type answers struct {
|
|||||||
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
||||||
// recorded at send, not at apply).
|
// recorded at send, not at apply).
|
||||||
reported []inventory.Reported
|
reported []inventory.Reported
|
||||||
|
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
||||||
|
// other answer here: those are about a machine that was told something, and this is about one
|
||||||
|
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
|
||||||
|
// to compare against, so without this a wholly blocked mesh reads as a well one.
|
||||||
|
refused map[string]string
|
||||||
|
// network is why the private network could not be computed, when it could not. Almost always
|
||||||
|
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
||||||
|
// a mesh whose hub is that node has no hub.
|
||||||
|
network string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-control/internal/inventory"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The same answers, in a shape something other than a person can read.
|
// The same answers, in a shape something other than a person can read.
|
||||||
@@ -40,11 +39,30 @@ type meshStatus struct {
|
|||||||
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
||||||
// digest is recorded at send, not at apply.
|
// digest is recorded at send, not at apply.
|
||||||
Reported []machineReported `json:"reported"`
|
Reported []machineReported `json:"reported"`
|
||||||
|
// Unresolved is every machine that cannot be worked out at all, with what the mesh said when
|
||||||
|
// it tried. **A machine here is in none of the lists above**: nothing was computed for it, so
|
||||||
|
// there is nothing to compare it against and nothing it can be behind — which is why a
|
||||||
|
// document without this field described a wholly blocked mesh as a well one.
|
||||||
|
//
|
||||||
|
// Per machine, and data. One node failing must never take the document away from a reader
|
||||||
|
// asking about the others.
|
||||||
|
Unresolved []machineUnresolved `json:"unresolved"`
|
||||||
|
// Network is why the private network could not be computed, when it could not; absent when it
|
||||||
|
// could. Almost always a consequence of Unresolved: a node that does not resolve is not on the
|
||||||
|
// network, and a mesh whose hub is that node has no hub.
|
||||||
|
Network string `json:"network,omitempty"`
|
||||||
// Machines is how many the mesh knows about, so a reader can tell "none wrong" from
|
// Machines is how many the mesh knows about, so a reader can tell "none wrong" from
|
||||||
// "none at all".
|
// "none at all".
|
||||||
Machines int `json:"machines"`
|
Machines int `json:"machines"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type machineUnresolved struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
// Problem is the mesh's own words, whole — newlines and all. It lists every requirement that
|
||||||
|
// could not be met, and a first line alone would name one of them and hide the rest.
|
||||||
|
Problem string `json:"problem"`
|
||||||
|
}
|
||||||
|
|
||||||
type machineDoing struct {
|
type machineDoing struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
// Outcome is refused or failed. Kept distinct all the way out: they are fixed in different
|
// Outcome is refused or failed. Kept distinct all the way out: they are fixed in different
|
||||||
@@ -92,14 +110,32 @@ type moduleBehind struct {
|
|||||||
On []string `json:"on"`
|
On []string `json:"on"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusAsJSON answers the same three questions as the text form, from the same calls.
|
// statusAsJSON answers the same questions as the text form, from the same reading.
|
||||||
func statusAsJSON(wrong []inventory.Doing, nodes []inventory.Node, quiet []inventory.Node,
|
//
|
||||||
behind map[string][]string, sources map[string]inventory.Source,
|
// **It takes the whole reading rather than a growing argument list**, which is what let a new
|
||||||
waiting []inventory.Machine, reported []inventory.Reported) ([]byte, error) {
|
// answer be added to the text form and forgotten here — the two are one function's output in two
|
||||||
|
// shapes, and they must not be able to differ about what was asked.
|
||||||
|
//
|
||||||
|
// It never fails on account of the mesh. Every per-machine problem in here is a field, so one
|
||||||
|
// machine that cannot be worked out cannot stop a caller reading about the others: a
|
||||||
|
// machine-readable interface that stops being machine-readable exactly when something is wrong is
|
||||||
|
// one nobody can build an alarm on.
|
||||||
|
func statusAsJSON(asked answers) ([]byte, error) {
|
||||||
|
wrong, nodes, quiet := asked.wrong, asked.nodes, asked.quiet
|
||||||
|
behind, sources := asked.behind, asked.sources
|
||||||
|
waiting, reported := asked.waiting, asked.reported
|
||||||
|
|
||||||
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
||||||
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
||||||
Reported: []machineReported{}}
|
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
||||||
|
Network: asked.network}
|
||||||
|
for name := range asked.refused {
|
||||||
|
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||||
|
Node: name, Problem: asked.refused[name]})
|
||||||
|
}
|
||||||
|
sort.Slice(out.Unresolved, func(i, j int) bool {
|
||||||
|
return out.Unresolved[i].Node < out.Unresolved[j].Node
|
||||||
|
})
|
||||||
for _, r := range reported {
|
for _, r := range reported {
|
||||||
out.Reported = append(out.Reported, machineReported{
|
out.Reported = append(out.Reported, machineReported{
|
||||||
Node: r.Node, Outcome: r.Outcome, At: r.At, Sent: r.Sent, Current: r.Current})
|
Node: r.Node, Outcome: r.Outcome, At: r.At, Sent: r.Sent, Current: r.Current})
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import (
|
|||||||
func statusOf(t *testing.T, wrong []inventory.Doing, nodes, quiet []inventory.Node,
|
func statusOf(t *testing.T, wrong []inventory.Doing, nodes, quiet []inventory.Node,
|
||||||
behind map[string][]string, sources map[string]inventory.Source) map[string]any {
|
behind map[string][]string, sources map[string]inventory.Source) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, err := statusAsJSON(wrong, nodes, quiet, behind, sources, nil, nil)
|
body, err := statusAsJSON(answers{
|
||||||
|
wrong: wrong, nodes: nodes, quiet: quiet, behind: behind, sources: sources})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -123,10 +124,12 @@ func TestNoSecretIsInWhatABoardReads(t *testing.T) {
|
|||||||
// Everything here comes from the mesh's own records, which hold no readable secret — but a
|
// Everything here comes from the mesh's own records, which hold no readable secret — but a
|
||||||
// shape a page is built against is exactly where one would eventually be added for
|
// shape a page is built against is exactly where one would eventually be added for
|
||||||
// convenience, so this says it out loud.
|
// convenience, so this says it out loud.
|
||||||
body, err := statusAsJSON(
|
body, err := statusAsJSON(answers{
|
||||||
[]inventory.Doing{{Node: "a", Outcome: inventory.OutcomeRefused,
|
wrong: []inventory.Doing{{Node: "a", Outcome: inventory.OutcomeRefused,
|
||||||
Refused: "resource \"x\": a file needs a path"}},
|
Refused: "resource \"x\": a file needs a path"}},
|
||||||
[]inventory.Node{{Name: "a"}}, nil, nil, nil, nil, nil)
|
nodes: []inventory.Node{{Name: "a"}},
|
||||||
|
refused: map[string]string{"a": "nothing provides \"database\", wanted by web"},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-control/internal/inventory"
|
"github.com/novox/mesh-control/internal/inventory"
|
||||||
|
"github.com/novox/mesh-control/internal/overlay"
|
||||||
)
|
)
|
||||||
|
|
||||||
// is anything broken, is anything not answering, is anything out of date.
|
// is anything broken, is anything not answering, is anything out of date.
|
||||||
@@ -54,8 +55,7 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
behind, sources := asked.behind, asked.sources
|
behind, sources := asked.behind, asked.sources
|
||||||
|
|
||||||
if *asJSON {
|
if *asJSON {
|
||||||
body, err := statusAsJSON(wrong, nodes, quiet, behind, sources, asked.waiting,
|
body, err := statusAsJSON(asked)
|
||||||
asked.reported)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,30 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(asked.refused) > 0 {
|
||||||
|
// First, above everything else. A machine that cannot be worked out is not running an old
|
||||||
|
// declaration — it has no declaration, and nothing below this line is about it.
|
||||||
|
var names []string
|
||||||
|
for name := range asked.refused {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
fmt.Printf("%d machine(s) cannot be worked out at all, so nothing can be sent to them:\n\n",
|
||||||
|
len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
fmt.Printf(" %s\n", name)
|
||||||
|
for _, line := range strings.Split(strings.TrimRight(asked.refused[name], "\n"), "\n") {
|
||||||
|
fmt.Printf(" %s\n", strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if asked.network != "" {
|
||||||
|
fmt.Printf("the private network could not be computed:\n %s\n\n",
|
||||||
|
strings.ReplaceAll(strings.TrimRight(asked.network, "\n"), "\n", "\n "))
|
||||||
|
}
|
||||||
|
|
||||||
if len(wrong) > 0 {
|
if len(wrong) > 0 {
|
||||||
fmt.Printf("%d machine(s) are not doing what they were told:\n\n", len(wrong))
|
fmt.Printf("%d machine(s) are not doing what they were told:\n\n", len(wrong))
|
||||||
for _, d := range wrong {
|
for _, d := range wrong {
|
||||||
@@ -139,7 +163,8 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf("\n `push --behind` sends them\n\n")
|
fmt.Printf("\n `push --behind` sends them\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 {
|
if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 &&
|
||||||
|
len(asked.refused) == 0 && asked.network == "" {
|
||||||
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
|
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
|
||||||
// and getting here means every question was asked and answered.
|
// and getting here means every question was asked and answered.
|
||||||
fmt.Printf("%d machine(s), all doing what they were told, all heard from, running what "+
|
fmt.Printf("%d machine(s), all doing what they were told, all heard from, running what "+
|
||||||
@@ -197,12 +222,31 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
|
// And why any machine cannot be worked out at all, which is neither of the first two questions
|
||||||
|
// and is asked before them both in practice: a machine nothing can be computed for is not
|
||||||
|
// broken, not quiet and not behind, and every other answer here would call it well.
|
||||||
|
//
|
||||||
|
// Read through whoResolves, which is what the private network is built from, so this and the
|
||||||
|
// network agree about who could not be resolved rather than deciding it twice.
|
||||||
|
_, out.refused, err = whoResolves(ctx, open, overlay.Addressing)
|
||||||
|
if err != nil {
|
||||||
|
return answers{}, err
|
||||||
|
}
|
||||||
|
|
||||||
// And which machines are not running what the mesh would send them. The same question as a
|
// And which machines are not running what the mesh would send them. The same question as a
|
||||||
// module being behind its source, one level down: that one says the catalogue is out of date,
|
// module being behind its source, one level down: that one says the catalogue is out of date,
|
||||||
// this one says a machine is — and only the second has anybody's change waiting in it.
|
// this one says a machine is — and only the second has anybody's change waiting in it.
|
||||||
|
//
|
||||||
|
// **One machine that cannot be resolved must not take the answer away from every other**
|
||||||
|
// (novox/hq 04-ISSUES/017's sibling). This reaches the private network, and a mesh whose hub
|
||||||
|
// is the blocked machine has no hub — which used to come back here as a refusal, so `status`
|
||||||
|
// said nothing at all and `status --json` emitted prose to stderr and no JSON anywhere. The
|
||||||
|
// reason is kept and reported as data; every question that does not depend on it is still
|
||||||
|
// answered.
|
||||||
would, err := wouldSend(ctx, open, out.nodes)
|
would, err := wouldSend(ctx, open, out.nodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
out.network = err.Error()
|
||||||
|
would = map[string]string{}
|
||||||
}
|
}
|
||||||
out.waiting, err = inv.Waiting(ctx, would)
|
out.waiting, err = inv.Waiting(ctx, would)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **One machine's failure must never take the answer away from a reader asking about the others.**
|
||||||
|
//
|
||||||
|
// A blocked machine is not on the private network, and a mesh whose hub is that machine has no hub
|
||||||
|
// — which came back through the reading as a refusal, so `status` printed nothing at all and
|
||||||
|
// `status --json` emitted multi-line prose on stderr and not one byte of JSON. A machine-readable
|
||||||
|
// interface that stops being machine-readable exactly when something is wrong is one nobody can
|
||||||
|
// build an alarm on.
|
||||||
|
func TestOneBlockedMachineDoesNotDestroyTheWholeDocument(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// Break the hub, using nothing but the command a person has.
|
||||||
|
one, two := rivals()
|
||||||
|
register(t, open, one)
|
||||||
|
register(t, open, two)
|
||||||
|
for _, m := range []string{"rival-one", "rival-two"} {
|
||||||
|
if _, err := assign(ctx, open, "anchor", m); err != nil && m == "rival-one" {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("one blocked machine made the whole mesh unreadable: %v", err)
|
||||||
|
}
|
||||||
|
body, err := statusAsJSON(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
t.Fatalf("what a script would read is not JSON: %v\n%s", err, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failure is in the document, as data, on the machine it belongs to.
|
||||||
|
unresolved, _ := parsed["unresolved"].([]any)
|
||||||
|
if len(unresolved) == 0 {
|
||||||
|
t.Fatalf("a machine that cannot be worked out is absent from the document:\n%s", body)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, row := range unresolved {
|
||||||
|
entry, _ := row.(map[string]any)
|
||||||
|
if entry["node"] != "anchor" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
problem, _ := entry["problem"].(string)
|
||||||
|
if !strings.Contains(problem, "the-seat") {
|
||||||
|
t.Errorf("the machine's problem is not its own words: %q", problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the blocked machine is not the one named:\n%s", body)
|
||||||
|
}
|
||||||
|
// And the mesh is still counted, so a reader can tell "none blocked" from "none at all".
|
||||||
|
if parsed["machines"] != float64(2) {
|
||||||
|
t.Errorf("the rest of the document did not survive: %v", parsed["machines"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A well mesh carries the field as an empty list, not as nothing: a reader distinguishing "none
|
||||||
|
// blocked" from "this field is missing" would have to handle both, and null is the one that gets
|
||||||
|
// forgotten.
|
||||||
|
func TestAWellMeshCarriesAnEmptyUnresolvedList(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
asked, err := theThreeQuestions(t.Context(), open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := statusAsJSON(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, ok := parsed["unresolved"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("\"unresolved\" is %T, not a list:\n%s", parsed["unresolved"], body)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("a well mesh reports blocked machines: %v", list)
|
||||||
|
}
|
||||||
|
if _, said := parsed["network"]; said {
|
||||||
|
t.Errorf("a well mesh says the network could not be computed: %v", parsed["network"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the page says it too, from the same reading. A board that renders "all well" over a mesh
|
||||||
|
// where nothing can be sent anywhere is worse than a board that is down.
|
||||||
|
func TestThePageSaysWhichMachinesCannotBeWorkedOut(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
one, two := rivals()
|
||||||
|
register(t, open, one)
|
||||||
|
register(t, open, two)
|
||||||
|
for _, m := range []string{"rival-one", "rival-two"} {
|
||||||
|
if _, err := assign(ctx, open, "anchor", m); err != nil && m == "rival-one" {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
asked, err := theThreeQuestions(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rendered := render(t, viewOf(asked))
|
||||||
|
for _, want := range []string{"Can everything be worked out?", "anchor", "the-seat"} {
|
||||||
|
if !strings.Contains(rendered, want) {
|
||||||
|
t.Fatalf("the page does not say %q:\n%s", want, rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user