Files
petal/internal/docs/passport_test.go
prosolis 78ed1dd281 Writing passport: evidence of process instead of an AI score
She's submitting work that gets run through an AI detector and wants to
pre-check she won't be wrongly flagged. Petal should not answer that with
a detector of its own: they misfire badly on non-native English (Stanford
2023 found >50% of TOEFL essays flagged as AI vs. near-zero for native
writers), so a percentage aimed at an ESL writer is worse than nothing —
it either scares her off her own voice or gives false comfort.

So the artifact is provenance, not a verdict. Petal already snapshots
every ~3 minutes; this turns that history into a standalone printable
report: session breakdown, word-count growth, span, active time. No score
is emitted anywhere.

Two schema additions back it. preserve_history opts a document out of the
40-snapshot prune cap — right for recovery, wrong for provenance, where
you want the whole span including the oldest rows. content_hash/prev_hash
chain each snapshot to the one before it, so a history edited or thinned
after the fact fails verification. Pruning legitimately severs links, so a
link break reports as "gaps" unless preserve_history is on; only a hash
that fails against its own contents is unconditionally "broken".

The chart's x axis is snapshot order, not wall-clock, and that is the load
-bearing decision. On a linear time axis an essay written in three
sittings across three days renders as three vertical cliffs separated by
empty space — visually identical to text pasted in three chunks, i.e. the
report would have argued the opposite of the truth. Breaks are compressed
into explicitly labelled gutters instead. TestChartGivesWidthToWriting
pins it.

The report volunteers its largest single word-count jump and states its
own limits: it cannot show who was at the keyboard, or whether typed text
was composed or copied in. Overclaiming would be self-defeating — a reader
who catches it overstating discounts all of it.

HTML rather than server-rendered PDF, as with the other exports: a CJK-safe
PDF needs an embedded Unicode font or a headless browser. Print styles are
there so the browser's Save as PDF is the handoff path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-07-19 11:45:08 -07:00

377 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package docs
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// chained builds a valid hash-chained snapshot run from (minutes-offset, words)
// pairs, so tests can describe a writing history in terms a reader recognises
// and get correct hashes for free.
func chained(docID string, base time.Time, points ...[2]int) []db.DocumentVersion {
var (
out []db.DocumentVersion
prev string
)
for i, p := range points {
at := base.Add(time.Duration(p[0]) * time.Minute)
text := strings.Repeat("word ", p[1])
v := db.DocumentVersion{
ID: fmt.Sprintf("v%d", i),
DocID: docID,
ContentText: text,
WordCount: p[1],
Kind: db.VersionKindAuto,
CreatedAt: at,
PrevHash: prev,
}
v.ContentHash = chainHash(prev, docID, at, p[1], text)
prev = v.ContentHash
out = append(out, v)
}
return out
}
func TestBuildPassportSessions(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
doc := db.Document{ID: "d1", Title: "Essay"}
// Two sittings: 09:0009:30, then a three-hour break, then 12:3013:00.
vs := chained("d1", base,
[2]int{0, 40}, [2]int{15, 120}, [2]int{30, 210},
[2]int{210, 260}, [2]int{240, 330},
)
d := buildPassport(doc, vs)
if len(d.Sessions) != 2 {
t.Fatalf("sessions = %d, want 2", len(d.Sessions))
}
if got := d.Sessions[0].Duration(); got != 30*time.Minute {
t.Errorf("session 1 duration = %v, want 30m", got)
}
if got := d.Sessions[1].Snapshots; got != 2 {
t.Errorf("session 2 snapshots = %d, want 2", got)
}
if got := d.Span; got != 4*time.Hour {
t.Errorf("span = %v, want 4h", got)
}
// Active time counts only time inside sessions, never the break.
if got := d.ActiveTime; got != 60*time.Minute {
t.Errorf("active time = %v, want 60m", got)
}
// Session 2 measures from session 1's final count (210 → 330).
if got := d.Sessions[1].WordsAdded; got != 120 {
t.Errorf("session 2 words = %d, want 120", got)
}
if d.ChainStatus != chainVerified {
t.Errorf("chain = %q, want %q", d.ChainStatus, chainVerified)
}
}
func TestBuildPassportFlagsLargeJump(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
doc := db.Document{ID: "d1"}
t.Run("steady growth is not flagged", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 100}, [2]int{5, 200}, [2]int{10, 300}, [2]int{15, 400})
if d := buildPassport(doc, vs); d.NoteJump {
t.Errorf("even growth flagged a jump of %d", d.LargestJump)
}
})
t.Run("a paste-shaped jump is flagged", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 20}, [2]int{5, 40}, [2]int{10, 900})
d := buildPassport(doc, vs)
if !d.NoteJump {
t.Fatal("large jump not flagged")
}
if d.LargestJump != 860 {
t.Errorf("largest jump = %d, want 860", d.LargestJump)
}
if !d.LargestJumpAt.Equal(base.Add(10 * time.Minute)) {
t.Errorf("jump at %v, want +10m", d.LargestJumpAt)
}
})
}
func TestVerifyChain(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
good := func() []db.DocumentVersion {
return chained("d1", base, [2]int{0, 50}, [2]int{5, 90}, [2]int{10, 160})
}
t.Run("intact chain verifies", func(t *testing.T) {
got, _ := verifyChain(db.Document{ID: "d1"}, good())
if got != chainVerified {
t.Errorf("got %q, want %q", got, chainVerified)
}
})
t.Run("edited content breaks it", func(t *testing.T) {
vs := good()
vs[1].ContentText = "something else entirely"
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("backdating breaks it", func(t *testing.T) {
vs := good()
vs[2].CreatedAt = base.Add(-time.Hour)
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("a removed snapshot reads as a gap when pruning is allowed", func(t *testing.T) {
vs := good()
pruned := []db.DocumentVersion{vs[0], vs[2]} // middle snapshot gone
got, _ := verifyChain(db.Document{ID: "d1"}, pruned)
if got != chainGaps {
t.Errorf("got %q, want %q", got, chainGaps)
}
})
t.Run("a removed snapshot is tampering when history is preserved", func(t *testing.T) {
vs := good()
pruned := []db.DocumentVersion{vs[0], vs[2]}
got, _ := verifyChain(db.Document{ID: "d1", PreserveHistory: true}, pruned)
if got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("pre-chain snapshots are partial, not failures", func(t *testing.T) {
vs := good()
vs[0].ContentHash, vs[0].PrevHash = "", ""
got, unhashed := verifyChain(db.Document{ID: "d1"}, vs)
if got != chainPartial {
t.Errorf("got %q, want %q", got, chainPartial)
}
if unhashed != 1 {
t.Errorf("unhashed = %d, want 1", unhashed)
}
})
t.Run("no hashes at all is unverifiable", func(t *testing.T) {
vs := good()
for i := range vs {
vs[i].ContentHash, vs[i].PrevHash = "", ""
}
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainUnverifiable {
t.Errorf("got %q, want %q", got, chainUnverifiable)
}
})
}
// The report must survive the degenerate histories — one snapshot, an empty
// document — rather than dividing by a zero span or a zero maximum.
func TestRenderPassportEdgeCases(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
t.Run("no history", func(t *testing.T) {
out := string(renderPassport(buildPassport(db.Document{Title: "Empty"}, nil)))
if !strings.Contains(out, "no saved history") {
t.Errorf("missing empty-state copy:\n%s", out)
}
})
t.Run("single snapshot", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 12})
out := string(renderPassport(buildPassport(db.Document{ID: "d1", Title: "One"}, vs)))
if strings.Contains(out, "NaN") || strings.Contains(out, "+Inf") {
t.Errorf("degenerate geometry leaked into output:\n%s", out)
}
})
t.Run("empty document", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 0}, [2]int{5, 0})
out := string(renderPassport(buildPassport(db.Document{ID: "d1"}, vs)))
if strings.Contains(out, "NaN") {
t.Errorf("zero word count produced NaN:\n%s", out)
}
})
t.Run("title is escaped", func(t *testing.T) {
doc := db.Document{ID: "d1", Title: `<script>alert(1)</script>`}
out := string(renderPassport(buildPassport(doc, chained("d1", base, [2]int{0, 5}))))
if strings.Contains(out, "<script>") {
t.Error("title was not escaped")
}
})
}
// The chart must give its width to the writing, not to the gaps between
// sittings. Three short sessions spread over three days is the case that a
// wall-clock x axis renders as three vertical cliffs — visually identical to
// pasted text, and wrong.
func TestChartGivesWidthToWriting(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
// ~30 minutes of work on each of three consecutive days.
var pts [][2]int
words := 0
for day := 0; day < 3; day++ {
for k := 0; k < 6; k++ {
words += 50
pts = append(pts, [2]int{day*1440 + k*5, words})
}
}
d := buildPassport(db.Document{ID: "d1"}, chained("d1", base, pts...))
if len(d.Sessions) != 3 {
t.Fatalf("sessions = %d, want 3", len(d.Sessions))
}
out := renderChart(d)
// Every session band should be a substantial share of the plot, not a sliver.
widths := regexp.MustCompile(`<rect [^>]*width="([0-9.]+)"`).FindAllStringSubmatch(out, -1)
if len(widths) != 3 {
t.Fatalf("session bands = %d, want 3", len(widths))
}
for i, m := range widths {
w, err := strconv.ParseFloat(m[1], 64)
if err != nil {
t.Fatal(err)
}
if w < plotW*0.15 {
t.Errorf("session %d band is %.1fpx of %dpx plot — writing got crushed", i+1, w, plotW)
}
}
// The compressed breaks must be stated, not silently removed.
if got := strings.Count(out, `class="gap"`); got != 2 {
t.Errorf("break labels = %d, want 2", got)
}
if !strings.Contains(out, "away") {
t.Error("break labels do not name their duration")
}
}
// Chart coordinates must stay inside the viewBox whatever the history looks like.
func TestChartStaysInBounds(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
histories := map[string][][2]int{
"single snapshot": {{0, 30}},
"two sessions": {{0, 30}, {5, 90}, {600, 140}, {605, 210}},
"words removed": {{0, 400}, {5, 380}, {10, 120}},
"all zero": {{0, 0}, {5, 0}},
"many snapshots": func() (p [][2]int) {
for i := 0; i < 60; i++ {
p = append(p, [2]int{i * 4, i * 20})
}
return
}(),
}
num := regexp.MustCompile(`(?:x|cx)="([0-9.-]+)"`)
for name, pts := range histories {
t.Run(name, func(t *testing.T) {
out := renderChart(buildPassport(db.Document{ID: "d1"}, chained("d1", base, pts...)))
if strings.Contains(out, "NaN") || strings.Contains(out, "Inf") {
t.Fatalf("degenerate geometry:\n%s", out)
}
for _, m := range num.FindAllStringSubmatch(out, -1) {
v, err := strconv.ParseFloat(m[1], 64)
if err != nil {
t.Fatal(err)
}
if v < 0 || v > chartW {
t.Errorf("x coordinate %.1f outside 0..%d", v, chartW)
}
}
})
}
}
func TestHumanDuration(t *testing.T) {
cases := []struct {
in time.Duration
want string
}{
{0, "under a minute"},
{30 * time.Second, "under a minute"},
{18 * time.Minute, "18m"},
{2 * time.Hour, "2h"},
{2*time.Hour + 40*time.Minute, "2h 40m"},
{50 * time.Hour, "2d 2h"},
}
for _, c := range cases {
if got := humanDuration(c.in); got != c.want {
t.Errorf("humanDuration(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// --- endpoint / persistence -------------------------------------------------
func TestPassportEndpoint(t *testing.T) {
srv := newTestServer(t)
id := newDoc(t, srv)
do(t, srv, http.MethodPut, "/"+id,
`{"content":"{}","content_text":"the first draft","word_count":3}`)
rec := do(t, srv, http.MethodGet, "/"+id+"/passport", "")
if rec.Code != http.StatusOK {
t.Fatalf("passport: code=%d body=%s", rec.Code, rec.Body)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("content-type = %q, want text/html", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "Writing passport") {
t.Errorf("report body missing heading:\n%s", body)
}
// Snapshots written through the real insert path must verify.
if !strings.Contains(body, "History intact") {
t.Errorf("live-written history did not verify:\n%s", body)
}
if rec := do(t, srv, http.MethodGet, "/does-not-exist/passport", ""); rec.Code != http.StatusNotFound {
t.Errorf("missing doc: code = %d, want 404", rec.Code)
}
}
func TestPreserveHistoryExemptsFromPruning(t *testing.T) {
srv := newTestServer(t)
id := newDoc(t, srv)
rec := do(t, srv, http.MethodPut, "/"+id, `{"preserve_history":true}`)
if rec.Code != http.StatusOK {
t.Fatalf("set preserve_history: code=%d body=%s", rec.Code, rec.Body)
}
decodeDoc := func(rec *httptest.ResponseRecorder) db.Document {
t.Helper()
var doc db.Document
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("decode doc: %v", err)
}
return doc
}
if !decodeDoc(rec).PreserveHistory {
t.Fatal("preserve_history did not persist")
}
// An ordinary body save must not clear the flag.
rec = do(t, srv, http.MethodPut, "/"+id,
`{"content":"{}","content_text":"hello there","word_count":2}`)
if !decodeDoc(rec).PreserveHistory {
t.Error("a normal save cleared preserve_history")
}
}