| 1 | package docs_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/docs/internal/docs" |
| 10 | ) |
| 11 | |
| 12 | // tagPattern strips element markup so TestSeedContentMatchesItsSource |
| 13 | // can hold the *prose* honest, not the exact tags — the block |
| 14 | // classifier in fixtures/generate-seed.mjs is free to change which |
| 15 | // element a given block becomes without this test caring. |
| 16 | var tagPattern = regexp.MustCompile(`<[^>]*>`) |
| 17 | |
| 18 | // TestSeedProducesReadableDocuments is the task brief's own test, |
| 19 | // adapted for this task's own move: Seed must leave at least four |
| 20 | // documents behind, and every one of them must carry substantial |
| 21 | // content read back through the real CRDT — as of this task, through |
| 22 | // Store.HTML, not Store.Text. The seed generator now builds a |
| 23 | // Y.XmlFragment on root "prosemirror" (fixtures/generate-seed.mjs), |
| 24 | // not a Y.Text on root "content", so Store.Text legitimately reads |
| 25 | // back empty for a seeded document; HTML is the read path that |
| 26 | // actually replays what Seed wrote. |
| 27 | func TestSeedProducesReadableDocuments(t *testing.T) { |
| 28 | app, _ := docs.NewForTest(t) |
| 29 | ctx := context.Background() |
| 30 | owner := app.TestPerson(t, "seed@example.com") |
| 31 | |
| 32 | if err := docs.Seed(ctx, app.Store(), app.TestActorForPerson(t, owner)); err != nil { |
| 33 | t.Fatalf("Seed: %v", err) |
| 34 | } |
| 35 | |
| 36 | list, err := app.Store().List(ctx, owner) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | if len(list) < 4 { |
| 41 | t.Fatalf("seeded %d documents, want at least 4", len(list)) |
| 42 | } |
| 43 | |
| 44 | // Every seeded document must have real content, through the CRDT. |
| 45 | for _, d := range list { |
| 46 | html, err := app.Store().HTML(ctx, d) |
| 47 | if err != nil { |
| 48 | t.Fatalf("HTML(%q): %v", d.Title, err) |
| 49 | } |
| 50 | if len(html) < 200 { |
| 51 | t.Errorf("%q has %d characters of HTML — seed content should be substantial", d.Title, len(html)) |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // TestSeedVariesTimestamps pins the plan's own requirement that |
| 57 | // seeded documents do not all land in the same second: List's own |
| 58 | // list.html groups by recency (handlers.go's recencyBucket), and every |
| 59 | // document created in the same second shows nothing of that. This |
| 60 | // does not assert exact values — Seed's own Ago durations are an |
| 61 | // implementation detail free to change — only that CreatedAt is not |
| 62 | // identical across every document, which a naive "loop and Create" |
| 63 | // seeder (no backdating at all) would fail. |
| 64 | func TestSeedVariesTimestamps(t *testing.T) { |
| 65 | app, _ := docs.NewForTest(t) |
| 66 | ctx := context.Background() |
| 67 | owner := app.TestPerson(t, "seed@example.com") |
| 68 | |
| 69 | if err := docs.Seed(ctx, app.Store(), app.TestActorForPerson(t, owner)); err != nil { |
| 70 | t.Fatalf("Seed: %v", err) |
| 71 | } |
| 72 | |
| 73 | list, err := app.Store().List(ctx, owner) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | |
| 78 | seen := map[string]bool{} |
| 79 | for _, d := range list { |
| 80 | seen[d.CreatedAt.Format("2006-01-02T15:04")] = true |
| 81 | } |
| 82 | if len(seen) < 2 { |
| 83 | t.Fatalf("all %d seeded documents share the same CreatedAt minute — timestamps are not varied", len(list)) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // TestSeedContentMatchesItsSource holds seed.go's committed |
| 88 | // .update.bin fixtures honest against the .txt files they were built |
| 89 | // from (fixtures/generate-seed.mjs): if a .txt is edited without |
| 90 | // regenerating its .bin, the document Store.HTML() actually replays |
| 91 | // stops matching what a person editing seeddata/ would expect to see, |
| 92 | // silently — this test is what makes that loud instead. It is the Go |
| 93 | // side of the same contract the wire-compatibility gate's own |
| 94 | // fixtures (fixtures/generate.mjs, scripts/wire-gate.sh) hold for |
| 95 | // fixtures/data. |
| 96 | // |
| 97 | // This checked Store.Text() before this task: the seed generator now |
| 98 | // builds a Y.XmlFragment on root "prosemirror", not a Y.Text on root |
| 99 | // "content" (generate-seed.mjs's own comment), so Text legitimately |
| 100 | // reads back empty for a seeded document — HTML is the read path |
| 101 | // that actually replays what Seed wrote. Tags are stripped before the |
| 102 | // prose checks below so this test holds the content honest, not the |
| 103 | // exact element the block classifier chose for a given block. |
| 104 | func TestSeedContentMatchesItsSource(t *testing.T) { |
| 105 | app, _ := docs.NewForTest(t) |
| 106 | ctx := context.Background() |
| 107 | owner := app.TestPerson(t, "source-check@example.com") |
| 108 | |
| 109 | if err := docs.Seed(ctx, app.Store(), app.TestActorForPerson(t, owner)); err != nil { |
| 110 | t.Fatalf("Seed: %v", err) |
| 111 | } |
| 112 | |
| 113 | list, err := app.Store().List(ctx, owner) |
| 114 | if err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | |
| 118 | titleSeen := map[string]bool{} |
| 119 | for _, d := range list { |
| 120 | titleSeen[d.Title] = true |
| 121 | } |
| 122 | |
| 123 | for _, want := range []string{ |
| 124 | "Proposal: Refitting the Elm Street Reading Room", |
| 125 | "Notes — Tuesday Planning Sync", |
| 126 | "Quick Reference: Resetting the Site Printer", |
| 127 | "Letter to the Harbourview Allotment Committee", |
| 128 | "A History of the Millbrook Waterworks", |
| 129 | "Packing List — Thursday Site Visit", |
| 130 | } { |
| 131 | if !titleSeen[want] { |
| 132 | t.Errorf("seeded set is missing %q", want) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | for _, d := range list { |
| 137 | html, err := app.Store().HTML(ctx, d) |
| 138 | if err != nil { |
| 139 | t.Fatalf("HTML(%q): %v", d.Title, err) |
| 140 | } |
| 141 | if !strings.Contains(string(html), "<p>") { |
| 142 | t.Errorf("%q rendered no paragraphs: %.120q", d.Title, html) |
| 143 | } |
| 144 | text := strings.TrimSpace(tagPattern.ReplaceAllString(string(html), " ")) |
| 145 | if text == "" { |
| 146 | t.Errorf("%q replayed to empty text", d.Title) |
| 147 | } |
| 148 | // A loose but real cross-check: the replayed text should read |
| 149 | // as prose (contains at least one space-separated sentence), |
| 150 | // not e.g. a stray binary artefact from a stale .bin. |
| 151 | if !strings.Contains(text, " ") { |
| 152 | t.Errorf("%q replayed to %q, which does not look like prose", d.Title, text) |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // TestSeededDocumentsRenderAsHTML holds the seeded documents to the |
| 158 | // ProseMirror-fragment round trip, not merely the plain-text one |
| 159 | // TestSeedContentMatchesItsSource already covers: the seed generator |
| 160 | // (fixtures/generate-seed.mjs) now builds a Y.XmlFragment, and |
| 161 | // Store.HTML reads it back through core's fragment renderer |
| 162 | // (core/src/fragment.rs). A fixture that applies cleanly but renders |
| 163 | // to nothing — the empty-fragment failure Task 1 introduced for the |
| 164 | // old Y.Text-only seed data — is the failure this test exists to |
| 165 | // catch. |
| 166 | func TestSeededDocumentsRenderAsHTML(t *testing.T) { |
| 167 | app, err := docs.NewForTest(t) |
| 168 | if err != nil { |
| 169 | t.Fatalf("NewForTest: %v", err) |
| 170 | } |
| 171 | ctx := context.Background() |
| 172 | owner := app.TestPerson(t, "render@example.com") |
| 173 | if err := docs.Seed(ctx, app.Store(), app.TestActorForPerson(t, owner)); err != nil { |
| 174 | t.Fatalf("Seed: %v", err) |
| 175 | } |
| 176 | docList, err := app.Store().List(ctx, owner) |
| 177 | if err != nil { |
| 178 | t.Fatalf("List: %v", err) |
| 179 | } |
| 180 | for _, d := range docList { |
| 181 | html, err := app.Store().HTML(ctx, d) |
| 182 | if err != nil { |
| 183 | t.Fatalf("HTML(%q): %v", d.Title, err) |
| 184 | } |
| 185 | if !strings.Contains(string(html), "<p>") { |
| 186 | t.Errorf("%q rendered no paragraphs: %.120q", d.Title, html) |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // TestWaterworksHistoryHasHeadings pins generate-seed.mjs's own block |
| 192 | // classifier: a short line with no trailing full stop, followed by a |
| 193 | // blank line, becomes a level-2 heading rather than an ordinary |
| 194 | // paragraph. "A History of the Millbrook Waterworks" is the one seed |
| 195 | // document long enough to have real section breaks ("Early years", |
| 196 | // "Construction and the first reservoir", ...) — if this renders no |
| 197 | // <h2>, the classifier stopped doing its job. |
| 198 | func TestWaterworksHistoryHasHeadings(t *testing.T) { |
| 199 | app, err := docs.NewForTest(t) |
| 200 | if err != nil { |
| 201 | t.Fatalf("NewForTest: %v", err) |
| 202 | } |
| 203 | ctx := context.Background() |
| 204 | owner := app.TestPerson(t, "headings@example.com") |
| 205 | if err := docs.Seed(ctx, app.Store(), app.TestActorForPerson(t, owner)); err != nil { |
| 206 | t.Fatalf("Seed: %v", err) |
| 207 | } |
| 208 | docList, _ := app.Store().List(ctx, owner) |
| 209 | for _, d := range docList { |
| 210 | if !strings.Contains(d.Title, "Millbrook") { |
| 211 | continue |
| 212 | } |
| 213 | html, err := app.Store().HTML(ctx, d) |
| 214 | if err != nil { |
| 215 | t.Fatalf("HTML: %v", err) |
| 216 | } |
| 217 | if !strings.Contains(string(html), "<h2>") { |
| 218 | t.Error("the long history rendered no headings — the block " + |
| 219 | "classifier in generate-seed.mjs is not doing its job") |
| 220 | } |
| 221 | return |
| 222 | } |
| 223 | t.Fatal("the Millbrook waterworks document is missing from the seed set") |
| 224 | } |
| 225 | |
| 226 | // TestSeedTextReturnsRealProse pins the plain-text accessor's own |
| 227 | // contract for a real document: Store.Text must return the seeded |
| 228 | // document's actual prose, not silently read back "". |
| 229 | // |
| 230 | // Fix round 1 on this task: Store.Text existed with zero production |
| 231 | // callers and read CRDT root "content" (a bare Y.Text) — the shape |
| 232 | // every real document left behind the moment seed data (and, later, |
| 233 | // the editor) moved to root "prosemirror" (a Y.XmlFragment, Task 1). |
| 234 | // Nothing caught that Text had quietly stopped meaning anything for a |
| 235 | // real document: it returned "" with no error, the exact silent-empty |
| 236 | // failure shape this codebase keeps producing. This test is the |
| 237 | // assertion that would have caught it — it must pass against |
| 238 | // core/src/abi.rs's text_string once that reads fragment_text instead |
| 239 | // of text_content, and it must fail before that change lands. |
| 240 | func TestSeedTextReturnsRealProse(t *testing.T) { |
| 241 | app, err := docs.NewForTest(t) |
| 242 | if err != nil { |
| 243 | t.Fatalf("NewForTest: %v", err) |
| 244 | } |
| 245 | ctx := context.Background() |
| 246 | owner := app.TestPerson(t, "text-prose@example.com") |
| 247 | if err := docs.Seed(ctx, app.Store(), app.TestActorForPerson(t, owner)); err != nil { |
| 248 | t.Fatalf("Seed: %v", err) |
| 249 | } |
| 250 | docList, err := app.Store().List(ctx, owner) |
| 251 | if err != nil { |
| 252 | t.Fatalf("List: %v", err) |
| 253 | } |
| 254 | for _, d := range docList { |
| 255 | if !strings.Contains(d.Title, "Packing List") { |
| 256 | continue |
| 257 | } |
| 258 | text, err := app.Store().Text(ctx, d) |
| 259 | if err != nil { |
| 260 | t.Fatalf("Text: %v", err) |
| 261 | } |
| 262 | if !strings.Contains(text, "Leaving from the depot at 7:30") { |
| 263 | t.Errorf("Text = %.200q, want it to contain the packing list's real opening line", text) |
| 264 | } |
| 265 | return |
| 266 | } |
| 267 | t.Fatal("the packing list document is missing from the seed set") |
| 268 | } |
| 269 | |