package engine import ( "context" "encoding/json" "path/filepath" "strings" "testing" "time" "git.pablomurad.com/pablo/patchwork/internal/config" "git.pablomurad.com/pablo/patchwork/internal/diff" "git.pablomurad.com/pablo/patchwork/internal/store" ) type harness struct { t *testing.T e *Engine now time.Time } func newHarness(t *testing.T) *harness { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "pw.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) cfg := config.Default() cfg.Source.Slug = "datestrip-slugify" cfg.Site.Timezone = "America/Sao_Paulo" e, err := New(st, cfg) if err != nil { t.Fatal(err) } h := &harness{t: t, e: e, now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)} e.Now = func() time.Time { return h.now } return h } func (h *harness) after(d time.Duration) *harness { h.now = h.now.Add(d); return h } func (h *harness) put(path, body string) Result { h.t.Helper() res, err := h.e.Ingest(context.Background(), Observation{Path: path, Raw: []byte(body)}, false) if err != nil { h.t.Fatal(err) } return res } func (h *harness) expect(res Result, action Action, number int) { h.t.Helper() if res.Action != action || res.Number != number { h.t.Fatalf("got %s #%d (warnings %q), want %s #%d", res.Action, res.Number, res.Warnings, action, number) } } func (h *harness) revisions(path string) []store.Revision { h.t.Helper() ctx := context.Background() p, err := h.e.Store.PageByPath(ctx, "folder", path) if err != nil { h.t.Fatal(err) } revs, err := h.e.Store.Revisions(ctx, p.ID) if err != nil { h.t.Fatal(err) } return revs } func (h *harness) diffOf(r store.Revision) diff.Full { h.t.Helper() var f diff.Full if err := json.Unmarshal([]byte(r.DiffJSON), &f); err != nil { h.t.Fatal(err) } return f } func page(note, body string) string { fm := "---\ntitle: Remote work\n" if note != "" { fm += "revision: " + note + "\n" } return fm + "---\n" + body } const ( p1 = "The hybrid model is the worst of both worlds." p2 = "Small teams should pick one place and stay there." ) func TestBaselineThenSilentThenPublish(t *testing.T) { h := newHarness(t) h.expect(h.put("remote.md", page("", p1+"\n\n"+p2)), Baseline, 1) h.expect(h.after(time.Hour).put("remote.md", page("", p1+"\n\nSmall teams should pick a place and stay there.")), Silent, 0) h.expect(h.after(time.Hour).put("remote.md", page("Changed my mind.", "The hybrid model works with fixed office days.\n\n"+p2)), Published, 2) revs := h.revisions("remote.md") if len(revs) != 2 || revs[1].Note != "Changed my mind." { t.Fatalf("revisions %+v", revs) } // The silent edit to p2 was undone; the published diff is against v1, // so only the first paragraph shows as changed. f := h.diffOf(revs[1]) if f.Stats.BlocksChanged != 1 || f.Ops[0].Op != diff.OpModify || f.Ops[1].Op != diff.OpEq { t.Fatalf("diff %+v", f) } } func TestSilentEditsBatchIntoOneRevision(t *testing.T) { h := newHarness(t) h.put("a.md", page("", "One.\n\nTwo.\n\nThree.")) h.after(time.Hour).put("a.md", page("", "One!\n\nTwo.\n\nThree.")) h.after(time.Hour).put("a.md", page("", "One!\n\nTwo.\n\nThree!")) h.expect(h.after(time.Hour).put("a.md", page("Punctuation pass.", "One!\n\nTwo.\n\nThree!")), Published, 2) f := h.diffOf(h.revisions("a.md")[1]) if f.Stats.BlocksChanged != 2 { t.Fatalf("want both silent edits in the published diff, got %+v", f.Stats) } } func TestUnchangedTextIsNotASnapshot(t *testing.T) { h := newHarness(t) h.put("a.md", page("", p1)) h.expect(h.after(time.Hour).put("a.md", page("", p1)), Unchanged, 0) crlf := strings.ReplaceAll(page("", p1+" \n"), "\n", "\r\n") h.expect(h.after(time.Hour).put("a.md", crlf), Unchanged, 0) p, _ := h.e.Store.PageByPath(context.Background(), "folder", "a.md") if n, _ := h.e.Store.SnapshotCount(context.Background(), p.ID); n != 1 { t.Fatalf("snapshots %d", n) } } func TestNoteOnlyChangeAmends(t *testing.T) { h := newHarness(t) h.put("a.md", page("", p1)) h.expect(h.after(time.Hour).put("a.md", page("First tak.", p2)), Published, 2) h.expect(h.after(time.Hour).put("a.md", page("First take.", p2)), Amended, 2) revs := h.revisions("a.md") if len(revs) != 2 || revs[1].Note != "First take." || !revs[1].UpdatedAt.After(revs[1].PublishedAt) { t.Fatalf("revisions %+v", revs) } } func TestNoteWithoutChangeSinceBaselineWarns(t *testing.T) { h := newHarness(t) h.put("a.md", page("", p1)) res := h.after(time.Hour).put("a.md", page("A note", p1)) h.expect(res, Silent, 0) if len(res.Warnings) != 1 { t.Fatalf("warnings %q", res.Warnings) } } func TestBaselineNoteIsNotRepublished(t *testing.T) { h := newHarness(t) h.put("a.md", page("Old note from before Patchwork.", p1)) h.expect(h.after(time.Hour).put("a.md", page("Old note from before Patchwork.", p2)), Silent, 0) } func TestFoldWindow(t *testing.T) { h := newHarness(t) h.put("a.md", page("", p1)) h.expect(h.after(time.Hour).put("a.md", page("Rewrote it.", "The hybrid modle works.")), Published, 2) h.expect(h.after(5*time.Minute).put("a.md", page("Rewrote it.", "The hybrid model works.")), Folded, 2) revs := h.revisions("a.md") f := h.diffOf(revs[1]) var ins string for _, s := range f.Ops[0].Segments { if s.T == diff.SegIns { ins += s.S } } if strings.Contains(ins, "modle") { t.Fatalf("fold should recompute the diff against v1: %+v", f.Ops) } if !revs[1].PublishedAt.Equal(revs[1].UpdatedAt.Add(-5 * time.Minute)) { t.Fatal("fold keeps published_at and moves updated_at") } // The window counts from published_at: 25 minutes after publishing, // even right after the fold, the edit is silent. h.expect(h.after(20*time.Minute).put("a.md", page("Rewrote it.", "The hybrid model works well.")), Silent, 0) } func TestFoldDisabled(t *testing.T) { h := newHarness(t) h.e.Cfg.Publish.FoldWindow.Duration = 0 h.put("a.md", page("", p1)) h.after(time.Hour).put("a.md", page("Note.", p2)) h.expect(h.after(time.Minute).put("a.md", page("Note.", p2+" More.")), Silent, 0) } func TestNoteRemovedAndRestoredDoesNotRepublish(t *testing.T) { h := newHarness(t) h.put("a.md", page("", p1)) h.after(time.Hour).put("a.md", page("Note A.", p2)) h.expect(h.after(time.Hour).put("a.md", page("", p2+" Edit.")), Silent, 0) h.expect(h.after(time.Hour).put("a.md", page("Note A.", p2+" Edit.")), Silent, 0) if n := len(h.revisions("a.md")); n != 2 { t.Fatalf("revisions %d", n) } } func TestMaturityChangeWithNoteIsARevision(t *testing.T) { h := newHarness(t) h.put("a.md", "---\nmaturity: seed\n---\n"+p1) h.expect(h.after(time.Hour).put("a.md", "---\nmaturity: sprout\nrevision: It grew.\n---\n"+p1), Published, 2) r := h.revisions("a.md")[1] f := h.diffOf(r) if r.Maturity != "sprout" || len(f.Meta) != 1 || f.Meta[0].Key != "maturity" || f.Stats.BlocksChanged != 0 { t.Fatalf("rev %+v diff %+v", r, f) } // Maturity alone, without a note, stays silent. h.expect(h.after(time.Hour).put("a.md", "---\nmaturity: bloom\nrevision: It grew.\n---\n"+p1), Silent, 0) } func TestInvalidEnumsAreDropped(t *testing.T) { h := newHarness(t) h.put("a.md", p1) res := h.after(time.Hour).put("a.md", "---\nrevision: X\nrevision_kind: rant\nmaturity: tree\n---\n"+p2) h.expect(res, Published, 2) r := h.revisions("a.md")[1] if r.Kind != "" || r.Maturity != "" || len(res.Warnings) != 2 { t.Fatalf("rev %+v warnings %q", r, res.Warnings) } } func TestMalformedFrontmatterNeverPublishes(t *testing.T) { h := newHarness(t) h.put("a.md", p1) res := h.after(time.Hour).put("a.md", "---\nrevision: [broken\n---\n"+p2) h.expect(res, Silent, 0) if len(res.Warnings) == 0 { t.Fatal("want a YAML warning") } } func TestOriginalDate(t *testing.T) { h := newHarness(t) ctx := context.Background() h.put("2026-06-21-bem-vindo.md", "x") h.put("dated.md", "---\ndate: 2026-03-02\n---\nx") h.put("instant.md", "---\ndate: 2026-03-02T01:30:00Z\n---\nx") h.put("plain.md", "x") want := map[string]string{ "2026-06-21-bem-vindo.md": "2026-06-21", "dated.md": "2026-03-02", "instant.md": "2026-03-01", // 01:30 UTC is still March 1 in São Paulo "plain.md": "2026-10-01", } for path, date := range want { p, err := h.e.Store.PageByPath(ctx, "folder", path) if err != nil { t.Fatal(err) } if p.OriginalDate != date { t.Errorf("%s: original date %s, want %s", path, p.OriginalDate, date) } } p, _ := h.e.Store.PageByPath(ctx, "folder", "2026-06-21-bem-vindo.md") if p.Slug != "bem-vindo" { t.Fatalf("slug %s", p.Slug) } } func TestOriginalDateFromMtimeStaysPut(t *testing.T) { h := newHarness(t) ctx := context.Background() planted := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC) obs := func(body string, mtime time.Time) { if _, err := h.e.Ingest(ctx, Observation{Path: "undated.md", Raw: []byte(body), Mtime: mtime}, false); err != nil { t.Fatal(err) } } obs("First text.", planted) h.after(48 * time.Hour) obs("---\nrevision: Edited.\n---\nSecond text.", h.now.Add(-time.Minute)) p, _ := h.e.Store.PageByPath(ctx, "folder", "undated.md") if p.OriginalDate != "2026-07-03" { t.Fatalf("original date %s: should be the first mtime, kept after later uploads", p.OriginalDate) } } func TestReconcile(t *testing.T) { h := newHarness(t) ctx := context.Background() h.put("a.md", p1) h.put("b.md", p2) present := map[string]bool{"a.md": true} res, _ := h.e.Reconcile(ctx, present, false) if len(res) != 0 { t.Fatalf("one miss should not remove: %+v", res) } res, _ = h.e.Reconcile(ctx, present, false) if len(res) != 1 || res[0].Action != Removed || res[0].Path != "b.md" { t.Fatalf("second miss should remove b.md: %+v", res) } if n, _ := h.e.ActivePages(ctx); n != 1 { t.Fatalf("active %d", n) } res, _ = h.e.Reconcile(ctx, map[string]bool{"a.md": true, "b.md": true}, false) if len(res) != 1 || !res[0].Restored { t.Fatalf("b.md should come back: %+v", res) } h.after(time.Hour) if r := h.put("b.md", p2); r.Action != Unchanged || r.Restored { t.Fatalf("restored page with same text: %+v", r) } } func TestDryRunWritesNothing(t *testing.T) { h := newHarness(t) res, err := h.e.Ingest(context.Background(), Observation{Path: "a.md", Raw: []byte(p1)}, true) if err != nil || res.Action != Baseline { t.Fatalf("%+v %v", res, err) } if n, _ := h.e.ActivePages(context.Background()); n != 0 { t.Fatal("dry run wrote a page") } }