Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/review_round_test.go
T
jschoubben c12f1a16db
mesh/delivery merged: source.main moved to novox/mesh-catalog@9c42423d5821
mesh/check checking c12f1a16db83 on 9c42423d5821
get shows a round only for a proposed version (issue 784, ADR 0665)
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-09 23:22:46 +01:00

192 lines
7.8 KiB
Go

package main
import (
"fmt"
"strings"
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
)
// ADR 0665: the review knows its round; from round 3 only a record that is wrong asks changes; at round 5 changes
// become an ask of the operator, no review runs before the answer, and a Decline restarts the count.
func changes(i int) string {
return fmt.Sprintf(`{"verdict":"changes","findings":["finding of review %d"]}`, i)
}
// review is the i'th review of record n: its author revises it first (but for the first), then every part is
// judged with answer. It answers the questions the judge was asked.
func (w *world) review(t *testing.T, n, i int, answer string) string {
t.Helper()
p := valid(n)
p.Sections[2].Text = fmt.Sprintf("Merges land sooner; revision %d.", i)
if _, err := w.k.Propose("author@g14", p); err != nil {
t.Fatal(err)
}
w.judge.review, w.judge.reviews = answer, nil
w.reviewAll(t)
return strings.Join(w.judge.reviews, "\n")
}
func TestTheReviewIsToldItsRoundAndRoundFiveIsTheOperators(t *testing.T) {
w := imported(t, newMemStore())
n := w.reserved(t, "author@g14")
key := Key{KindDecision, n}
for i := 1; i <= 4; i++ {
q := w.review(t, n, i, changes(i))
if !strings.Contains(q, fmt.Sprintf("This is round %d of its review", i)) || !strings.Contains(q, "At round 5 the operator decides") ||
strings.Contains(q, "From round 3 only") != (i >= 3) {
t.Fatalf("round %d's question: %.600s", i, q)
}
for j := 1; j < i; j++ { // every earlier round's findings, none of this round's
if !strings.Contains(q, fmt.Sprintf("Round %d (version", j)) || !strings.Contains(q, fmt.Sprintf("finding of review %d", j)) {
t.Errorf("round %d's question lacks the findings of round %d", i, j)
}
}
if strings.Contains(q, fmt.Sprintf("finding of review %d", i)) {
t.Errorf("round %d's question holds its own finding", i)
}
}
if e := mustGet(t, w.k.Store, key); e.Current.Status != StatusProposed || len(e.Current.Reviews) != 4 || e.Current.Round() != 5 || len(w.router.asked) != 0 {
t.Fatalf("after four rounds: %s, %d reviews, %d asks", e.Current.Status, len(e.Current.Reviews), len(w.router.asked))
}
// Round 5 asks changes: the operator is asked, showing the findings; no review runs, and no revision is taken, before the answer.
if q := w.review(t, n, 5, changes(5)); !strings.Contains(q, "This is round 5 of its review") {
t.Fatalf("round 5's question: %.300s", q)
}
if len(w.router.asked) != 1 {
t.Fatalf("asks after round 5's changes: %d", len(w.router.asked))
}
ask := w.router.asked[0]
if err := occ.Check(ask, w.clock); err != nil || !strings.Contains(ask.Explanation, "finding of review 5") || !strings.Contains(ask.Explanation, "round 5") {
t.Fatalf("the ask: %v %+v", err, ask)
}
if e := mustGet(t, w.k.Store, key); e.Current.Ask == nil || e.Current.Ask.Round != 5 || e.Current.Status != StatusProposed {
t.Fatalf("after round 5: %+v", e.Current.Ask)
}
w.judge.reviews = nil
if said := w.reviewAll(t); len(said) != 0 || len(w.judge.reviews) != 0 {
t.Fatalf("a review ran before the operator answered: %v", said)
}
p := valid(n)
p.Sections[2].Text = "Merges land sooner; revision 6."
if _, err := w.k.Propose("author@g14", p); err == nil || !strings.Contains(err.Error(), "the operator decides") {
t.Fatalf("a revision before the answer: %v", err)
}
// Decline: it stays proposed, and the revision's review is round 1.
w.router.answer(ask.ID, OptionDecline, w.clock.Add(time.Minute))
if _, err := w.k.Reconcile(); err != nil {
t.Fatal(err)
}
if q := w.review(t, n, 6, changes(6)); !strings.Contains(q, "This is round 1 of its review") || strings.Contains(q, "finding of review") {
t.Fatalf("after the Decline: %.400s", q)
}
if len(w.router.asked) != 1 {
t.Fatalf("round 1 asked the operator")
}
}
func TestRoundFiveApprovedByTheOperatorAcceptsTheVersionAsWritten(t *testing.T) {
w := imported(t, newMemStore())
n := w.reserved(t, "author@g14")
for i := 1; i <= 5; i++ {
w.review(t, n, i, changes(i))
}
w.router.answer(w.router.asked[0].ID, OptionApprove, w.clock.Add(time.Minute))
if _, err := w.k.Reconcile(); err != nil {
t.Fatal(err)
}
if e := mustGet(t, w.k.Store, Key{KindDecision, n}); e.Current.Status != StatusAccepted || e.Current.Ask.Said != AskApproved {
t.Fatalf("%s %+v", e.Current.Status, e.Current.Ask)
}
}
// From round 3 a finding that is only a remark sits under approve, and the record is accepted.
func TestARemarkUnderApproveAtRoundFourAcceptsTheRecord(t *testing.T) {
w := imported(t, newMemStore())
n := w.reserved(t, "author@g14")
for i := 1; i <= 3; i++ {
w.review(t, n, i, changes(i))
}
q := w.review(t, n, 4, `{"verdict":"approve","findings":["wording: the second section could say it shorter"]}`)
if !strings.Contains(q, "answer approve and give it as a finding under approve") {
t.Fatalf("the question: %.400s", q)
}
if e := mustGet(t, w.k.Store, Key{KindDecision, n}); e.Current.Status != StatusAccepted || len(e.Current.Reviews) != 4 {
t.Fatalf("%s, %d reviews", e.Current.Status, len(e.Current.Reviews))
}
}
// The rounds of a pending version (an insight to an accepted record) go on across its revisions.
func TestTheRoundCountGoesOnAcrossRevisionsOfAPendingInsight(t *testing.T) {
w := imported(t, newMemStore())
n := w.reserved(t, "author@g14")
w.accepted(t, "author@g14", valid(n))
var q string
for i := 1; i <= 3; i++ {
p := valid(n)
p.Insight = "it said only that merges land sooner"
p.Sections[2].Text = fmt.Sprintf("Merges land sooner, revision %d.", i)
if _, err := w.k.Propose("author@g14", p); err != nil {
t.Fatal(err)
}
w.judge.review, w.judge.reviews = changes(i), nil
w.reviewAll(t)
q = strings.Join(w.judge.reviews, "\n")
}
if !strings.Contains(q, "This is round 3 of its review") {
t.Fatalf("the third revision of the insight: %.300s", q)
}
}
// A pending version of an active effort: revisions and a closing keep the count (ADR 0665 §1), `get` says the round and
// the ask after round 5, and a close is refused while that ask waits (§3).
func TestARoundCountSurvivesAClosingAndAClosingWaitsForTheOperatorsAnswer(t *testing.T) {
w := imported(t, newMemStore())
n := w.effort(t, StatusActive)
revise := func(i int, answer string) string {
next := effortAsk(n, StatusActive, Section{Name: "00-overview.md", Text: fmt.Sprintf("What a roadmap entry would be, revision %d.", i)})
if _, err := w.k.Propose("author@g14", next); err != nil {
t.Fatal(err)
}
w.judge.review, w.judge.reviews = answer, nil
w.reviewAll(t)
return strings.Join(w.judge.reviews, "\n")
}
for i := 1; i <= 2; i++ {
revise(i, changes(i))
}
if g, err := w.k.Get(GetAsk{Kind: KindResearch, Number: n}); err != nil || g.Round != 3 {
t.Fatalf("get after two rounds: %+v, %v", g, err)
}
if g, err := w.k.Get(GetAsk{Kind: KindDecision, Number: 276}); err != nil || g.Round != 0 {
t.Fatalf("get of an accepted record shows round %d, %v", g.Round, err)
}
if _, err := w.k.Close("lead@shanks", CloseAsk{Number: n, To: StatusIdea, Note: "Parked: nothing carries it this month."}); err != nil {
t.Fatal(err)
}
w.judge.review, w.judge.reviews = "", nil
w.reviewAll(t)
if q := strings.Join(w.judge.reviews, "\n"); !strings.Contains(q, "This is round 3 of its review") || !strings.Contains(q, "finding of review 2") {
t.Fatalf("the closing's question: %.500s", q)
}
m := w.effort(t, StatusActive)
n = m
for i := 1; i <= 5; i++ {
revise(i, changes(i))
}
g, err := w.k.Get(GetAsk{Kind: KindResearch, Number: n})
if err != nil || !strings.Contains(g.Waiting, "after round 5 asked changes") {
t.Fatalf("get after round 5: %+v, %v", g.Waiting, err)
}
if _, err := w.k.Close("lead@shanks", CloseAsk{Number: n, To: StatusIdea, Note: "Parked: nothing carries it this month."}); err == nil ||
!strings.Contains(err.Error(), "the operator decides") {
t.Fatalf("a close before the answer: %v", err)
}
}