package view import ( "encoding/json" "errors" "fmt" "strconv" "strings" "testing" "time" ) // usageNow is the fixture's clock. var usageNow = time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC) // at is the fixture's time hh:mm on its day. func at(hm string) time.Time { t, _ := time.Parse("2006-01-02 15:04", "2026-10-26 "+hm) return t.UTC() } // stepID is a step id made at t, as buildstep.NewID makes it, with suffix as its six hex digits. func stepID(t time.Time, suffix string) string { return strconv.FormatInt(t.UnixMilli(), 36) + suffix } func states(m map[string]string) StateRead { return StateRead{ Keys: func() ([]string, error) { var k []string for key := range m { k = append(k, key) } return k, nil }, Get: func(key string) (json.RawMessage, error) { if v, ok := m[key]; ok { return json.RawMessage(v), nil } return nil, nil }, } } func step(node, id, tool, state string, started, ended time.Time) string { e := "" if !ended.IsZero() { e = `,"ended":"` + ended.Format(time.RFC3339) + `"` } return fmt.Sprintf(`{"v":1,"id":%q,"tool":%q,"node":%q,"state":%q,"started":%q%s}`, id, tool, node, state, started.Format(time.RFC3339), e) } // usageFixture is four nodes: shanks builds Go at capacity 2 (two asks running, one left running, a try's ask, a check, // a recheck of the morning and a hand step ended); ace holds the Go seat at capacity 2 and is paused; g14 holds it at // capacity 0; the Node seat on shanks has no resolved capacity and takes the modules' default. func usageFixture() UsageReader { running := `{"v":1,"phase":"running","health":"ok"}` tryAsk := stepID(at("11:30"), "a00003") goSteps := map[string]string{} add := func(node, id, tool, state string, started, ended time.Time) { goSteps[node+"."+id] = step(node, id, tool, state, started, ended) } add("shanks", stepID(at("11:50"), "a00001"), "delivery ask", "running", at("11:50"), time.Time{}) add("shanks", stepID(at("11:55"), "a00002"), "check ask", "running", at("11:55"), time.Time{}) add("shanks", tryAsk, "check ask", "passed", at("11:30"), at("11:40")) add("shanks", stepID(at("11:20"), "a00004"), "check ask", "failed", at("11:20"), at("11:26")) add("shanks", stepID(at("06:00"), "a00005"), "recheck ask", "passed", at("06:00"), at("06:10")) add("shanks", stepID(at("10:00"), "a00006"), "check ask", "running", at("10:00"), time.Time{}) add("shanks", stepID(at("11:45"), "a00007"), "build_go_test", "passed", at("11:45"), at("11:46")) goSteps["shanks.notastepkey"] = `{}` // a key that is no step id is passed over return UsageReader{ Now: func() time.Time { return usageNow }, Haves: states(map[string]string{"shanks.mesh-build-go": running, "shanks.mesh-build-node": running, "ace.mesh-build-go": running, "g14.mesh-build-go": running, "shanks.node": `{}`, "shanks.mesh-delivery-view": running}), Resolved: states(map[string]string{ "shanks.mesh-build-go.capacity": `{"node":"shanks","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0","source":"mesh"}`, "ace.mesh-build-go.capacity": `{"node":"ace","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0","source":"mesh"}`, "g14.mesh-build-go.capacity": `{"node":"g14","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0","source":"mesh"}`, "shanks.mesh-config.other": `{"value":"x"}`, }), Steps: map[string]StateRead{"go": states(goSteps), "node": states(map[string]string{}), "image": states(map[string]string{})}, Pauses: map[string]StateRead{"go": states(map[string]string{"paused.ace": `{"v":1,"paused":true,"by":"lead@shanks","at":"2026-10-26T09:00:00Z"}`}), "node": states(nil), "image": states(nil)}, Tries: states(map[string]string{ "try-" + strconv.FormatInt(at("11:29").UnixNano(), 10): `{"id":"try-1","repository":"novox/mesh-sdk","head":"h","trunk":"t","state":"pass","asks":["` + tryAsk + `"]}`, "try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","started":"2026-10-26T11:58:00Z"}`, }), Backlog: func() ([]BacklogRow, error) { return []BacklogRow{{Language: "go", Kind: "check", Waiting: 3, Held: 2, OldestSecs: 240}}, nil }, } } func seatOf(t *testing.T, a UsageAnswer, node, seat string) SeatUsage { t.Helper() for _, n := range a.Nodes { for _, s := range n.Seats { if n.Node == node && s.Seat == seat { return s } } } t.Fatalf("no seat %s on %s in %+v", seat, node, a.Nodes) return SeatUsage{} } // build_usage answers, from the states alone, what the operator asked in six calls: per node its seats, capacity, // eligibility, the asks running by kind and the ones left running, the last hour and day, and mesh-wide the backlog. func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { a := usageFixture().Usage() if testing.Verbose() { out, _ := json.MarshalIndent(a, "", " ") t.Logf("build_usage answers:\n%s", out) } sg := seatOf(t, a, "shanks", "node-build-go") eligible := map[string]bool{} for _, n := range a.Nodes { eligible[n.Node] = n.Eligible } for _, c := range []struct { what string got, want any }{ {"nodes", len(a.Nodes), 3}, {"eligible", eligible, map[string]bool{"shanks": true, "ace": false, "g14": false}}, {"ace why not", seatOf(t, a, "ace", "node-build-go").WhyNot, "paused (node-build-go.resume takes asks again)"}, {"g14 why not", seatOf(t, a, "g14", "node-build-go").WhyNot, "its capacity on g14 is 0"}, {"shanks capacity", sg.Capacity, 2}, {"shanks node seat takes the default", seatOf(t, a, "shanks", "node-build-node").Capacity, 3}, {"running", sg.Running.Total, 2}, {"running by kind", sg.Running.ByKind, map[string]int{"delivery": 1, "check": 1}}, {"running steps, the hand step's ended", len(sg.Steps), 2}, {"left running", len(sg.LeftRunning), 1}, {"hour started", sg.LastHour.Started, 5}, // delivery, check, try, check, hand {"hour finished", sg.LastHour.Finished, 3}, // try 600 s, check 360 s, hand 60 s {"hour failed", sg.LastHour.Failed, 1}, {"hour total", sg.LastHour.TotalSecs, int64(1020)}, {"hour median", sg.LastHour.MedianSecs, int64(360)}, {"hour try", sg.LastHour.ByKind["try"], KindWindow{Finished: 1, TotalSecs: 600, MedianSecs: 600}}, {"hour hand", sg.LastHour.ByKind["hand"], KindWindow{Finished: 1, TotalSecs: 60, MedianSecs: 60}}, {"hour at capacity", sg.LastHour.AtCapacitySecs, int64(300)}, // 11:55-12:00, two asks of two {"hour at capacity share", sg.LastHour.AtCapacityShare, 0.083}, {"day finished", sg.LastDay.Finished, 4}, {"day recheck", sg.LastDay.ByKind["recheck"].Finished, 1}, {"day at capacity", sg.LastDay.AtCapacitySecs, int64(300)}, {"backlog", a.Backlog, []BacklogRow{{Language: "go", Kind: "check", Waiting: 3, Held: 2, OldestSecs: 240}}}, {"tries running", len(a.TriesRunning), 1}, {"said", a.Nodes[0].Said, []string{"node-build-go: not eligible, paused (node-build-go.resume takes asks again); 0 of capacity 2 running; last hour 0 finished, median 0 s, at capacity 0% of it; last day 0 finished, median 0 s, at capacity 0.0% of it"}}, {"said shanks", a.Nodes[2].Said[0], "node-build-go: eligible; 2 of capacity 2 running (check 1, delivery 1), 1 left running; last hour 3 finished, median 360 s, at capacity 8% of it; last day 4 finished, median 360 s, at capacity 0.3% of it"}, {"cpu says where it is", strings.Contains(a.Nodes[0].CPU, "/disk-load.cpu_top"), true}, } { if fmt.Sprint(c.got) != fmt.Sprint(c.want) { t.Errorf("%s = %v, want %v", c.what, c.got, c.want) } } if !strings.Contains(sg.LeftRunning[0].Why, "past the step bound") { t.Errorf("left running says %q", sg.LeftRunning[0].Why) } // Nothing readable: each state is named in a note, the backlog's refusal too, and nothing is said held. none := UsageReader{Now: func() time.Time { return usageNow }, Backlog: func() ([]BacklogRow, error) { return nil, errors.New("no socket") }}.Usage() notes := strings.Join(none.Notes, " | ") for _, want := range []string{"have is not readable", "resolved values are not readable", "steps of mesh-build-go are not readable", "tries are not readable", "did not answer: no socket"} { if !strings.Contains(notes, want) { t.Errorf("the notes %q do not say %q", notes, want) } } if len(none.Nodes) != 0 || none.Backlog == nil { t.Errorf("nothing readable answered nodes %v, backlog %v", none.Nodes, none.Backlog) } }