Files
pad/internal/decision/per_comment_test.go
xarmian 79922d4bab feat: conventions checked on each comment alone, with the chip on its card (TASK-3119 U2b) (#1872)
* feat: conventions checked on each comment alone, with the chip on its card (TASK-3119 U2b)

A new decision set, conventions_comments (QuestionSet.PerComment): the
conventions questions asked about each of an item's last 10 live comments,
one comment per state (BuildCommentState: the item's title and collection,
and the comment, 16k-rune cap), rows keyed conv:<ref>@<comment-id>. Gate 2
measured this subject: 7 of 7 comment-borne breaks caught, 0 of 9 false, a
credential past the old 2k trail clip caught at 0.99.

- Asked vs current (lead ruling): only the newest 10 comments are asked or
  re-asked, but an answer stays current while its comment is unchanged,
  however many newer comments arrive. Edited, deleted or moved: not current.
  A title edit re-asks the window (the title is in the state, as measured).
- Idempotent per comment: a new comment costs one call, an item write none.
- Web: DecisionChips publishes per-comment chips from its existing read into
  a small store keyed by item; TimelineCommentCard shows 'Possibly breaks
  CONVE-N' on the comment (and on a reply) at p >= 0.9, full-reload link, no
  compliance state. Cleared when the item goes.
- CLAUDE.md: the third set.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(web): a late decisions response cannot repopulate cleared comment chips (TASK-3119 U2b)

codex r1.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 01:43:43 -04:00

157 lines
5.2 KiB
Go

package decision
import (
"context"
"strconv"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3119 U2b: the conventions questions asked about each comment alone.
func newCommentFixture(t *testing.T) *convFixture {
t.Helper()
fx := newConvFixture(t)
if err := fx.r.registry.Register(ConventionsCommentsSet()); err != nil {
t.Fatal(err)
}
return fx
}
func (fx *convFixture) comment(t *testing.T, item *models.Item, body string) *models.Comment {
t.Helper()
c, err := fx.s.CreateComment(fx.ws.ID, item.ID, "", models.CommentCreate{Author: "wren", Body: body})
if err != nil {
t.Fatal(err)
}
return c
}
func (fx *convFixture) evalComments(t *testing.T, item *models.Item) bool {
t.Helper()
called, err := fx.r.Evaluate(context.Background(), item.ID, ConventionsCommentsSetName)
if err != nil {
t.Fatalf("evaluate comments: %v", err)
}
return called
}
// commentCurrent reports whether every conventions_comments row for the
// comment is current, and whether there are any.
func (fx *convFixture) commentCurrent(t *testing.T, item *models.Item, commentID string) (rows int, allCurrent bool) {
t.Helper()
ds, err := fx.r.Decisions(item.ID)
if err != nil {
t.Fatal(err)
}
allCurrent = true
for _, d := range ds {
if d.QuestionSet != ConventionsCommentsSetName {
continue
}
if _, cid, ok := SplitCommentKey(d.QuestionKey); !ok || cid != commentID {
continue
}
rows++
allCurrent = allCurrent && d.Current
}
return rows, allCurrent
}
func TestPerComment_EachCommentAskedAloneOnce(t *testing.T) {
fx := newCommentFixture(t)
fx.convention(t, "No creds", `{"status":"active","trigger":"always"}`, "no plaintext credentials")
item := fx.task(t, "Staging access")
c1 := fx.comment(t, item, "password is hunter2")
if !fx.evalComments(t, item) {
t.Fatal("a new comment cost no call")
}
if len(fx.f.rawBodies) != 1 {
t.Fatalf("calls = %d, want 1", len(fx.f.rawBodies))
}
// The state is the comment subject's: title, collection, the comment.
want, err := BuildCommentState(item.Title, item.CollectionSlug, c1.Body)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(fx.f.rawBodies[0], string(want.Bytes)) {
t.Fatalf("request does not carry the comment state %s: %s", want.Bytes, fx.f.rawBodies[0])
}
if n, cur := fx.commentCurrent(t, item, c1.ID); n != 1 || !cur {
t.Fatalf("rows for the comment = %d current=%v, want 1 current", n, cur)
}
// Nothing new: no call. A second comment: one more call, for it alone.
if fx.evalComments(t, item) {
t.Fatal("an unchanged comment was asked again")
}
fx.comment(t, item, "second")
fx.evalComments(t, item)
if len(fx.f.rawBodies) != 2 {
t.Fatalf("calls = %d, want 2", len(fx.f.rawBodies))
}
if !strings.Contains(fx.f.rawBodies[1], `"comment":"second"`) {
t.Fatalf("the second call is not about the new comment: %s", fx.f.rawBodies[1])
}
}
// Lead ruling: the window bounds what is ASKED, not what stays current. A
// breaking comment keeps its answer, and so its chip, after 11 newer ones.
func TestPerComment_AnswerStaysCurrentAfterElevenNewerComments(t *testing.T) {
fx := newCommentFixture(t)
fx.convention(t, "No creds", `{"status":"active","trigger":"always"}`, "no plaintext credentials")
item := fx.task(t, "Staging access")
breaking := fx.comment(t, item, "password is hunter2")
fx.evalComments(t, item)
// Comment timestamps have second resolution, so comments written in one
// burst tie and the window's (created_at DESC, id DESC) order could keep
// the breaking one inside it. Backdate it so it is unambiguously older.
if _, err := fx.s.DB().Exec(`UPDATE comments SET created_at = ? WHERE id = ?`, "2020-01-01T00:00:00Z", breaking.ID); err != nil {
t.Fatal(err)
}
for i := 0; i < 11; i++ {
fx.comment(t, item, "later comment "+strconv.Itoa(i))
}
before := len(fx.f.rawBodies)
fx.evalComments(t, item)
if got := len(fx.f.rawBodies) - before; got != RecentTrailWindow {
t.Fatalf("asked %d comments, want the window of %d", got, RecentTrailWindow)
}
if n, cur := fx.commentCurrent(t, item, breaking.ID); n != 1 || !cur {
t.Fatalf("the breaking comment's answer: rows=%d current=%v, want 1 current after 11 newer comments", n, cur)
}
}
// An edited comment's answer is not current until it is asked again; a
// deleted one's never is.
func TestPerComment_EditAndDeleteEndCurrency(t *testing.T) {
fx := newCommentFixture(t)
fx.convention(t, "No creds", `{"status":"active","trigger":"always"}`, "no plaintext credentials")
item := fx.task(t, "Staging access")
c := fx.comment(t, item, "password is hunter2")
gone := fx.comment(t, item, "to be deleted")
fx.evalComments(t, item)
if _, err := fx.s.UpdateComment(c.ID, "password is in the vault"); err != nil {
t.Fatal(err)
}
if _, cur := fx.commentCurrent(t, item, c.ID); cur {
t.Fatal("an edited comment's answer still reads current")
}
fx.evalComments(t, item)
if _, cur := fx.commentCurrent(t, item, c.ID); !cur {
t.Fatal("the edited comment was not re-asked")
}
if err := fx.s.DeleteComment(gone.ID); err != nil {
t.Fatal(err)
}
if n, cur := fx.commentCurrent(t, item, gone.ID); n == 0 || cur {
t.Fatalf("a deleted comment's answer: rows=%d current=%v, want its old row and not current", n, cur)
}
}