mesh-issues keeps each design's milestone in the state milestones (history 5), says milestone-set and milestone-moved, and serves milestones, milestone-set and milestone-move. An issue's milestone is its own link of kind milestone, so at most one milestone per issue is checked inside the issue's own write; kind commit links a fixing commit. The triage links no milestone, pull request or commit. import with milestones=N seeds a design's table once. mesh-knowledge's progress reads issue-tracker.milestones and its table parse is gone. Squashed from four commits so the branch history does not carry a compiled bundle that was committed by mistake (6 MB); the path is now in .gitignore.
385 lines
14 KiB
Go
385 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The progress verb's tests (novox/hq issue 1947). The designs are hq's, captured at a commit; the tracker's rows and
|
|
// the delivery seat's answers were captured from the live mesh on 2026-10-26 (testdata/progress/README.md).
|
|
|
|
func progressRecords() dirRecords {
|
|
return dirRecords{dir: filepath.Join("testdata", "progress", "hq")}
|
|
}
|
|
|
|
// withDesigns is the fixture's records with the captured designs beside them, as the bus test's records module
|
|
// serves them: a folder or document the first does not hold is read from the second.
|
|
type withDesigns struct{ base, designs dirRecords }
|
|
|
|
func (w withDesigns) Standing() (string, error) { return w.base.Standing() }
|
|
|
|
func (w withDesigns) List(folder string) ([]string, []string, error) {
|
|
if f, d, err := w.base.List(folder); err == nil {
|
|
return f, d, nil
|
|
}
|
|
return w.designs.List(folder)
|
|
}
|
|
|
|
func (w withDesigns) Read(path string) (string, string, error) {
|
|
if c, commit, err := w.base.Read(path); err == nil {
|
|
return c, commit, nil
|
|
}
|
|
c, _, err := w.designs.Read(path)
|
|
commit, _ := w.base.Standing()
|
|
return c, commit, err
|
|
}
|
|
|
|
// seatsWorld plays the issue tracker and mesh-delivery from the captured answers, and counts what it is asked.
|
|
type seatsWorld struct {
|
|
mu sync.Mutex
|
|
rows map[int]json.RawMessage
|
|
linked map[string][]int
|
|
// milestones is the tracker's milestone records (issue 1781).
|
|
milestones json.RawMessage
|
|
asked map[string]int
|
|
files map[string]json.RawMessage
|
|
}
|
|
|
|
func newSeatsWorld(t *testing.T) *seatsWorld {
|
|
t.Helper()
|
|
s := &seatsWorld{rows: map[int]json.RawMessage{}, linked: map[string][]int{}, asked: map[string]int{}, files: map[string]json.RawMessage{}}
|
|
var rows []json.RawMessage
|
|
if err := json.Unmarshal(readFixture(t, "tracker-rows.json"), &rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range rows {
|
|
var n struct{ Number int }
|
|
_ = json.Unmarshal(r, &n)
|
|
s.rows[n.Number] = r
|
|
}
|
|
s.milestones = readFixture(t, "tracker-milestones-47.json")
|
|
for _, f := range []string{"deliveries.json", "groups.json", "show-car-hunter-2.json"} {
|
|
s.files[f] = readFixture(t, f)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func readFixture(t *testing.T, name string) json.RawMessage {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join("testdata", "progress", name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func (s *seatsWorld) ask(key string, body any) (json.RawMessage, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.asked[key]++
|
|
raw, _ := json.Marshal(body)
|
|
var a struct {
|
|
Numbers []int `json:"numbers"`
|
|
Linked string `json:"linked"`
|
|
Number int `json:"number"`
|
|
ID string `json:"id"`
|
|
Design int `json:"design"`
|
|
}
|
|
_ = json.Unmarshal(raw, &a)
|
|
switch key {
|
|
case MilestonesVerb:
|
|
if a.Design != 0 && a.Design != 47 {
|
|
return json.RawMessage(`{"milestones": []}`), nil
|
|
}
|
|
return s.milestones, nil
|
|
case "seat:issue-tracker.list":
|
|
numbers := a.Numbers
|
|
if a.Linked != "" {
|
|
numbers = s.linked[a.Linked]
|
|
}
|
|
var rows []json.RawMessage
|
|
for _, n := range numbers {
|
|
if r, ok := s.rows[n]; ok {
|
|
rows = append(rows, r)
|
|
}
|
|
}
|
|
return json.Marshal(map[string]any{"rows": rows, "matched": len(rows)})
|
|
case "seat:issue-tracker.get":
|
|
return json.Marshal(map[string]any{"number": a.Number, "notes": []map[string]any{{"at": "2026-10-26T15:12:15Z", "by": "shanks.node-tools", "text": "a note"}},
|
|
"notes-left-out": 96, "links": []map[string]any{{"kind": "depends-on", "target": "1943"}}})
|
|
case "seat:mesh-delivery.deliveries":
|
|
return s.files["deliveries.json"], nil
|
|
case "seat:mesh-delivery.groups":
|
|
return s.files["groups.json"], nil
|
|
case "seat:mesh-delivery.show":
|
|
if a.ID == "jschoubben/car-hunter@cc4c8ce12901" {
|
|
return s.files["show-car-hunter-2.json"], nil
|
|
}
|
|
return json.RawMessage(`{"delivery": {}}`), nil
|
|
}
|
|
return nil, os.ErrNotExist
|
|
}
|
|
|
|
// countingRecords is Records that counts the documents read and can be moved to another commit.
|
|
type countingRecords struct {
|
|
dirRecords
|
|
commit string
|
|
reads int
|
|
}
|
|
|
|
func (c *countingRecords) Standing() (string, error) { return c.commit, nil }
|
|
func (c *countingRecords) Read(p string) (string, string, error) {
|
|
c.reads++
|
|
content, _, err := c.dirRecords.Read(p)
|
|
return content, c.commit, err
|
|
}
|
|
|
|
// 1947/AC2, as issue 1781 changed it: a design's title, state and decisions are hq's; its milestones are the issue
|
|
// tracker's records, ordered as its table, an id found with spaces or hyphens, and a closed one holding no issue done.
|
|
func TestADesignsMilestonesAreTheTrackersRecords(t *testing.T) {
|
|
content, _, err := progressRecords().Read(DesignsFolder + "/47-delivery-from-commit-to-delivered.md")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := parseDesign(DesignsFolder+"/47-delivery-from-commit-to-delivered.md", content)
|
|
if d.Number != 47 || d.Title != "Delivery: from a commit to delivered" || d.Status != "in-progress" || !contains(d.Decisions, "ADR 0276") ||
|
|
len(d.Milestones) != 0 {
|
|
t.Fatalf("design %d %q %q %v, %d milestones read from hq", d.Number, d.Title, d.Status, d.Decisions, len(d.Milestones))
|
|
}
|
|
seats := newSeatsWorld(t)
|
|
k := &KB{Seats: seats.ask}
|
|
ds, err := k.withMilestones([]*Design{d}, 47)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ds[0].Milestones) != 29 || len(d.Milestones) != 0 || seats.asked[MilestonesVerb] != 1 {
|
|
t.Fatalf("%d milestones; the cached design holds %d", len(ds[0].Milestones), len(d.Milestones))
|
|
}
|
|
for _, c := range []struct {
|
|
id string
|
|
want []int
|
|
}{
|
|
{"1", []int{973, 525}},
|
|
{"R6", []int{1942, 1945, 1943, 1944, 1946, 1906, 1130}},
|
|
{"r4d", []int{}},
|
|
{"3 fix", []int{1082, 1049, 1047, 1083, 1079, 1084}},
|
|
} {
|
|
m := ds[0].milestone(c.id)
|
|
if m == nil {
|
|
t.Fatalf("no milestone %s", c.id)
|
|
}
|
|
if !reflect.DeepEqual(m.Issues, c.want) {
|
|
t.Errorf("milestone %s: %v, want %v", c.id, m.Issues, c.want)
|
|
}
|
|
}
|
|
closed := &Design{Milestones: []Milestone{{ID: "6", Status: "closed", Reason: "folded into release 4"}, {ID: "R4d", Status: "open"}}}
|
|
items, _ := milestoneItems(closed, nil, nil)
|
|
if items[0].State != StateDone || items[1].State != StateWaiting {
|
|
t.Fatalf("%+v", items)
|
|
}
|
|
}
|
|
|
|
func containsInt(xs []int, n int) bool {
|
|
for _, x := range xs {
|
|
if x == n {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 1947/AC6: the designs are read once per commit of hq, and again when it moves; the mesh level names those with
|
|
// milestones one by one and counts the rest.
|
|
func TestDesignsAreReadAgainOnlyWhenHqMoves(t *testing.T) {
|
|
rec := &countingRecords{dirRecords: progressRecords(), commit: "aaaa"}
|
|
seats := newSeatsWorld(t)
|
|
k := &KB{Records: rec, Seats: seats.ask, Now: func() time.Time { return time.Date(2026, 10, 26, 15, 30, 0, 0, time.UTC) }}
|
|
p, err := k.Progress(ProgressAsk{Of: "mesh"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.reads != 3 {
|
|
t.Fatalf("read %d documents for the mesh, want 3", rec.reads)
|
|
}
|
|
if !strings.Contains(p.Tree, "3 designs in progress, 1 with milestones") || !strings.Contains(p.Tree, "design 47 · Delivery: from a commit to delivered") ||
|
|
!strings.Contains(p.Tree, "2 designs in progress name no milestones (issue 845): 53, 57") {
|
|
t.Fatalf("mesh:\n%s", p.Tree)
|
|
}
|
|
for range 2 {
|
|
if _, err := k.Progress(ProgressAsk{Of: "design 47"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if rec.reads != 3 {
|
|
t.Fatalf("read %d documents with hq standing still, want 3", rec.reads)
|
|
}
|
|
rec.commit = "bbbb"
|
|
if _, err := k.Progress(ProgressAsk{Of: "design 47"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.reads != 4 {
|
|
t.Fatalf("read %d documents after hq moved, want 4", rec.reads)
|
|
}
|
|
if _, err := k.Progress(ProgressAsk{Of: "design 99"}); err == nil || !strings.Contains(err.Error(), "no design 99") {
|
|
t.Fatalf("an unknown design: %v", err)
|
|
}
|
|
}
|
|
|
|
// 1947/AC3: an issue's state from its tracker row, and a parent's from its children.
|
|
func TestIssueStatesAndTheirRollUp(t *testing.T) {
|
|
seats := newSeatsWorld(t)
|
|
row := func(n int) trackerRow {
|
|
var r trackerRow
|
|
if err := json.Unmarshal(seats.rows[n], &r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
for _, c := range []struct {
|
|
n int
|
|
state string
|
|
why string
|
|
counts bool
|
|
}{
|
|
{1942, StateDone, "resolved, delivered", true},
|
|
{1943, StateDone, "resolved, nothing to deliver", true},
|
|
{1946, StateMoving, "resolved · fix merged, not delivered: mesh-catalog#475", true},
|
|
{1906, StateStuck, "resolved · held: mesh-delivery#39", true},
|
|
{1945, StateMoving, "fixing since", true},
|
|
{1049, StateStuck, "worker fix-chain-cat@shanks stale since", true},
|
|
{1126, StateWaiting, "open, needs a decision", true},
|
|
{774, StateWaiting, "open, ready", true},
|
|
{1021, "", "", false},
|
|
} {
|
|
it, counts := issueItem(row(c.n))
|
|
if counts != c.counts || it.State != c.state || !strings.Contains(it.Why, c.why) {
|
|
t.Errorf("issue %d: %s %q (counts %v), want %s %q", c.n, it.State, it.Why, counts, c.state, c.why)
|
|
}
|
|
}
|
|
// The other delivery lines a resolved issue carries on the live tracker (981, 1290 and 1235 on 2026-10-26).
|
|
for line, state := range map[string]string{
|
|
"fix not delivered: mesh-catalog#357 stopped:": StateStuck,
|
|
"fix not delivered: mesh-catalog#389 failed:": StateStuck,
|
|
"nothing heard from the delivery of mesh-delivery#5": StateWaiting,
|
|
} {
|
|
if it, _ := issueItem(trackerRow{Number: 1, Status: "resolved", Delivery: line}); it.State != state {
|
|
t.Errorf("resolved with %q: %s, want %s", line, it.State, state)
|
|
}
|
|
}
|
|
at := func(h int) *time.Time { t := time.Date(2026, 10, 26, h, 0, 0, 0, time.UTC); return &t }
|
|
for _, c := range []struct {
|
|
name string
|
|
kids []Item
|
|
state string
|
|
done int
|
|
moved *time.Time
|
|
}{
|
|
{"all done", []Item{{State: StateDone, Moved: at(9)}, {State: StateDone, Moved: at(11)}}, StateDone, 2, at(11)},
|
|
{"one moves", []Item{{State: StateDone}, {State: StateMoving, Moved: at(12)}, {State: StateWaiting}}, StateMoving, 1, at(12)},
|
|
{"one stuck outranks moving", []Item{{State: StateMoving}, {State: StateStuck}}, StateStuck, 0, nil},
|
|
{"only waiting", []Item{{State: StateDone}, {State: StateWaiting}}, StateWaiting, 1, nil},
|
|
{"no issue yet", nil, StateWaiting, 0, nil},
|
|
} {
|
|
var p Item
|
|
rollUp(&p, c.kids)
|
|
if p.State != c.state || p.Done != c.done || !reflect.DeepEqual(p.Moved, c.moved) {
|
|
t.Errorf("%s: %s %d %v", c.name, p.State, p.Done, p.Moved)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1947/AC1 (its unit side): design 47 in its two forms, and one milestone.
|
|
func TestProgressOfADesignMinimalAndDetailed(t *testing.T) {
|
|
seats := newSeatsWorld(t)
|
|
k := &KB{Records: progressRecords(), Seats: seats.ask, Now: func() time.Time { return time.Date(2026, 10, 26, 15, 30, 0, 0, time.UTC) }}
|
|
p, err := k.Progress(ProgressAsk{Of: "design 47"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(p)
|
|
if len(raw) > DefaultBound || p.Detail != DetailMinimal || !strings.Contains(p.Source, "the issue tracker's milestone records of design 47") {
|
|
t.Fatalf("%d bytes, %s, %s", len(raw), p.Detail, p.Source)
|
|
}
|
|
if seats.asked["seat:issue-tracker.list"] != 1 || seats.asked["seat:mesh-delivery.deliveries"] != 0 || seats.asked["seat:issue-tracker.get"] != 0 {
|
|
t.Fatalf("minimal asked %v", seats.asked)
|
|
}
|
|
if w := strings.Join(p.Warnings, "\n"); !strings.Contains(w, "the tracker neither answered nor named missing") || !strings.Contains(w, "973, 525") {
|
|
t.Errorf("a row the tracker did not answer is not said: %v", p.Warnings)
|
|
}
|
|
lines := strings.Split(p.Tree, "\n")
|
|
if !strings.HasPrefix(lines[0], "✗ design 47 · Delivery: from a commit to delivered — ") {
|
|
t.Fatalf("first line %q", lines[0])
|
|
}
|
|
for _, want := range []string{"✗ R6 4/7 1945 fixing since", "✗ 3-fix 0/1 1049 fixing, worker fix-chain-cat@shanks stale since"} {
|
|
if !strings.Contains(p.Tree, want) {
|
|
t.Errorf("no %q in\n%s", want, p.Tree)
|
|
}
|
|
}
|
|
d, err := k.Progress(ProgressAsk{Of: "design 47", Detail: DetailDetailed, Bound: MostBound})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"├── ▶ 1946 ", "resolved · fix merged, not delivered: mesh-catalog#475",
|
|
"▶ 1945 The controller still plans", "jschoubben/car-hunter@cc4c8ce12901 ready since 10-26 17:05, waits for its merge",
|
|
"depends on 1943", "97 notes; last 10-26 17:12 by shanks.node-tools"} {
|
|
if !strings.Contains(d.Tree, want) {
|
|
t.Errorf("detailed: no %q in\n%s", want, d.Tree)
|
|
}
|
|
}
|
|
if len(d.Items) != 1 || d.Items[0].ID != "design 47" {
|
|
t.Fatalf("items %+v", d.Items)
|
|
}
|
|
m, err := k.Progress(ProgressAsk{Of: "milestone 47/r6", Detail: DetailDetailed})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(m.Tree, "✗ milestone 47/R6 · ") || !strings.Contains(m.Tree, "✓ 1130") {
|
|
t.Fatalf("milestone:\n%s", m.Tree)
|
|
}
|
|
small, _ := k.Progress(ProgressAsk{Of: "design 47", Detail: DetailDetailed, Bound: LeastBound})
|
|
if raw, _ := json.Marshal(small); len(raw) > LeastBound || !strings.Contains(small.Cut, "raise bound") {
|
|
t.Fatalf("%d bytes within %d; cut %q", len(raw), LeastBound, small.Cut)
|
|
}
|
|
for _, bad := range []ProgressAsk{{Of: "design"}, {Of: "milestone 47/R99"}, {Of: "design 47", Detail: "full"}, {Of: "design 57"}} {
|
|
if _, err := k.Progress(bad); err == nil {
|
|
t.Errorf("%+v was answered", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1947/AC5: an issue's own level, and a record's: the designs that name it and the issues linked to it.
|
|
func TestProgressOfAnIssueAndARecord(t *testing.T) {
|
|
w := imported(t, newMemStore())
|
|
seats := newSeatsWorld(t)
|
|
seats.linked["decision:0276"] = []int{1942, 1945}
|
|
w.k.Records, w.k.Seats = progressRecords(), seats.ask
|
|
p, err := w.k.Progress(ProgressAsk{Of: "issue 1945"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"▶ issue 1945 · The controller still plans", "97 notes", "depends on 1943",
|
|
"jschoubben/car-hunter@cc4c8ce12901 ready"} {
|
|
if !strings.Contains(p.Tree, want) {
|
|
t.Errorf("issue: no %q in\n%s", want, p.Tree)
|
|
}
|
|
}
|
|
r, err := w.k.Progress(ProgressAsk{Of: "ADR 0276", Detail: DetailDetailed})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"ADR 0276 · ", "named by design 47 (in-progress)", "▶ 1 of 2 linked issues done", "✓ 1942"} {
|
|
if !strings.Contains(r.Tree, want) {
|
|
t.Errorf("record: no %q in\n%s", want, r.Tree)
|
|
}
|
|
}
|
|
if _, err := w.k.Progress(ProgressAsk{Of: "issue 99999"}); err == nil {
|
|
t.Error("an issue the tracker does not hold was answered")
|
|
}
|
|
}
|