1package chat
2
3import (
4 "strings"
5 "testing"
6
7 "charm.land/glamour/v2"
8 "github.com/charmbracelet/crush/internal/ui/styles"
9 "github.com/stretchr/testify/require"
10)
11
12// newTestRenderer builds a fresh glamour renderer for the given
13// width. We deliberately do NOT share renderers between calls in
14// the equivalence tests so any hidden state in
15// [glamour.TermRenderer] cannot leak from a "cached" rendering
16// path into a "fresh" rendering path.
17func newTestRenderer(t *testing.T, width int) *glamour.TermRenderer {
18 t.Helper()
19 sty := styles.CharmtonePantera()
20 r, err := glamour.NewTermRenderer(
21 glamour.WithStyles(sty.Markdown),
22 glamour.WithWordWrap(width),
23 )
24 require.NoError(t, err)
25 return r
26}
27
28// freshRender renders content as a single document with a fresh
29// glamour renderer and applies the same trailing-newline trim
30// that streamingMarkdown.Render does. Use this for byte- and
31// visible-equivalence comparisons against the streaming path.
32func freshRender(t *testing.T, content string, width int) string {
33 t.Helper()
34 r := newTestRenderer(t, width)
35 out, err := r.Render(content)
36 require.NoError(t, err)
37 return strings.TrimSuffix(out, "\n")
38}
39
40// stripANSI removes all ANSI CSI escape sequences from s so two
41// renders with different colour state can be compared on their
42// visible glyphs alone.
43func stripANSI(s string) string {
44 var b strings.Builder
45 b.Grow(len(s))
46 i := 0
47 for i < len(s) {
48 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
49 j := i + 2
50 for j < len(s) {
51 c := s[j]
52 if c >= 0x40 && c <= 0x7e {
53 j++
54 break
55 }
56 j++
57 }
58 i = j
59 continue
60 }
61 b.WriteByte(s[i])
62 i++
63 }
64 return b.String()
65}
66
67// normalizeRender canonicalises a rendered glamour string for
68// visual-equivalence comparison: strip ANSI, drop per-line
69// trailing whitespace, drop leading/trailing blank lines, and
70// collapse consecutive blank lines to a single blank line.
71//
72// Glamour pads rendered lines with trailing spaces and adds top/
73// bottom block margins that differ subtly between "render the
74// whole document at once" and "render two halves and concatenate
75// them." Per F8 design principle D, those byte-level differences
76// are acceptable as long as the visible content matches; this
77// helper makes that comparison explicit.
78func normalizeRender(s string) string {
79 clean := stripANSI(s)
80 lines := strings.Split(clean, "\n")
81 for i, l := range lines {
82 lines[i] = strings.TrimRight(l, " \t")
83 }
84 // Collapse consecutive blank lines.
85 out := make([]string, 0, len(lines))
86 prevBlank := false
87 for _, l := range lines {
88 blank := l == ""
89 if blank && prevBlank {
90 continue
91 }
92 out = append(out, l)
93 prevBlank = blank
94 }
95 // Trim leading and trailing blanks.
96 for len(out) > 0 && out[0] == "" {
97 out = out[1:]
98 }
99 for len(out) > 0 && out[len(out)-1] == "" {
100 out = out[:len(out)-1]
101 }
102 return strings.Join(out, "\n")
103}
104
105// containsRawMarkdownSource reports whether the visible portion of
106// rendered contains literal markdown source markers that should
107// have been consumed by glamour. Used by T2 to assert that
108// intermediate streaming flushes don't leak raw source through to
109// the user. We deliberately only flag markers that glamour
110// removes during rendering ("```" fence delimiters, "|" table
111// pipes embedded in a line that also contains pipes β actual
112// table syntax β and bare "###" headers); pipes-in-prose and
113// dashes are too common to flag.
114func containsRawMarkdownSource(rendered string) bool {
115 clean := stripANSI(rendered)
116 if strings.Contains(clean, "```") {
117 return true
118 }
119 for _, line := range strings.Split(clean, "\n") {
120 if strings.HasPrefix(strings.TrimLeft(line, " \t"), "###") {
121 return true
122 }
123 }
124 return false
125}
126
127// -----------------------------------------------------------------------
128// T1: findSafeMarkdownBoundary unit tests.
129// -----------------------------------------------------------------------
130
131// TestFindSafeMarkdownBoundary_TableDriven exercises the
132// findSafeMarkdownBoundary decision tree across the full set of
133// constructs Β§4.4 calls out: plain paragraphs, fenced code (open
134// and closed), lists, tables, block quotes, and setext headers.
135func TestFindSafeMarkdownBoundary_TableDriven(t *testing.T) {
136 t.Parallel()
137
138 cases := []struct {
139 name string
140 content string
141 // want is the expected boundary; -1 means "no safe
142 // boundary." When >=0 the test asserts content[:want]
143 // ends after a blank-line separator and content[:want]
144 // is a complete prefix.
145 want int
146 }{
147 {
148 name: "empty",
149 content: "",
150 want: -1,
151 },
152 {
153 name: "single line",
154 content: "Just a single paragraph",
155 want: -1,
156 },
157 {
158 name: "two paragraphs",
159 content: "First paragraph.\n\nSecond paragraph.",
160 // boundary at start of "Second"
161 want: len("First paragraph.\n\n"),
162 },
163 {
164 name: "three paragraphs picks latest",
165 content: "First.\n\nSecond.\n\nThird.",
166 want: len("First.\n\nSecond.\n\n"),
167 },
168 {
169 name: "open fence at end",
170 content: "Para.\n\n```go\nfoo()\n",
171 // no closing fence β every blank-line candidate
172 // before content end is INSIDE the fence (the open
173 // fence opened at offset 7). Actually the ONLY
174 // blank line is between "Para." and "```go", so
175 // candidate boundary is right before "```go". At
176 // that point fence count = 0, even, but the line
177 // AFTER (the first non-blank) is "```go" which
178 // would change rendering of the prefix⦠hmm,
179 // actually it wouldn't change the prefix's
180 // rendering because the prefix is just "Para.\n\n".
181 // The boundary would be ACCEPTED. Let's check
182 // what our impl does.
183 want: len("Para.\n\n"),
184 },
185 {
186 name: "inside open fence: no candidate after open",
187 content: "Para.\n\n```go\nfoo()\n\nbar()\n",
188 // blank line after "foo()" is INSIDE the fence
189 // (fence count at that prefix = 1, odd), must
190 // reject. The earlier blank line between "Para."
191 // and "```go" should still be safe (fence count
192 // at that prefix = 0).
193 want: len("Para.\n\n"),
194 },
195 {
196 name: "closed fence followed by paragraph",
197 content: "Para1.\n\n```\nfoo()\n```\n\nPara2.",
198 // latest blank line is between "```" and "Para2.";
199 // fence count at that prefix = 2 (even), last
200 // non-blank line is "```" which is not a list/
201 // table/quote/setext.
202 want: len("Para1.\n\n```\nfoo()\n```\n\n"),
203 },
204 {
205 name: "open list at end",
206 content: "Para.\n\n- one\n- two\n",
207 // last non-blank line of any blank-bounded prefix
208 // is a list item; our boundary check rejects.
209 // The blank line between "Para." and "- one" is
210 // the only candidate, but the line AFTER (first
211 // non-blank of suffix) is "- one" β that's fine,
212 // a list opening doesn't change the prefix's
213 // rendering. So the boundary BEFORE the list is
214 // accepted.
215 want: len("Para.\n\n"),
216 },
217 {
218 name: "list interior: no boundary",
219 content: "- one\n- two\n",
220 // no blank line at all.
221 want: -1,
222 },
223 {
224 name: "closed list then paragraph",
225 content: "- one\n- two\n\nPara.",
226 // blank line after the list. Last non-blank line
227 // of prefix is "- two" β a list item β so the
228 // candidate is REJECTED. (Conservative: we don't
229 // know the list is "closed" without looking at
230 // what follows.)
231 want: -1,
232 },
233 {
234 name: "table at end",
235 content: "Para.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n",
236 // blank-line candidate is between "Para." and
237 // table opener. Last non-blank line of prefix is
238 // "Para." β fine. Line AFTER is "| a | b |"
239 // which is a table line; doesn't retroactively
240 // change "Para." Boundary accepted.
241 want: len("Para.\n\n"),
242 },
243 {
244 name: "table interior with internal blank line: no late boundary",
245 content: "| a | b |\n| --- | --- |\n\n| 1 | 2 |\n",
246 // the blank line in the middle is followed by
247 // another table line. Last non-blank line of
248 // prefix is "| --- | --- |" which contains a
249 // pipe β we reject.
250 want: -1,
251 },
252 {
253 name: "block quote at end",
254 content: "Para.\n\n> quoted\n> still quoted\n",
255 // Last non-blank line of any prefix that ends
256 // inside the quote block is a "> ..." line β
257 // rejected. The blank line BEFORE the quote
258 // gives a prefix of "Para.\n\n" β last non-blank
259 // "Para." β accepted.
260 want: len("Para.\n\n"),
261 },
262 {
263 name: "setext underline pending",
264 content: "Heading\n\n=====\n",
265 // blank line between "Heading" and "=====".
266 // Prefix = "Heading\n\n", last non-blank "Heading"
267 // β fine. But the FIRST non-blank line of the
268 // suffix is "=====", a setext-underline
269 // candidate. Splitting here would render the
270 // prefix as a paragraph "Heading", but the
271 // canonical render would treat the whole thing
272 // as a setext header. Reject.
273 //
274 // (Note: per CommonMark, a blank line between a
275 // paragraph and an underline actually breaks the
276 // setext, so the setext interpretation may not
277 // apply. But the boundary check is conservative
278 // β being wrong costs one slow frame, being
279 // over-aggressive costs visible breakage.)
280 want: -1,
281 },
282 {
283 name: "indented code at end of prefix",
284 content: "Para.\n\n code line\n\nNext.",
285 // prefix candidates:
286 // "Para.\n\n" β last non-blank "Para.", accepted
287 // "Para.\n\n code line\n\n" β last non-blank
288 // is " code line" which is indented 4
289 // spaces β REJECTED.
290 // Latest accepted is the first.
291 want: len("Para.\n\n"),
292 },
293 }
294
295 for _, c := range cases {
296 t.Run(c.name, func(t *testing.T) {
297 t.Parallel()
298 got := findSafeMarkdownBoundary(c.content)
299 require.Equalf(t, c.want, got,
300 "findSafeMarkdownBoundary(%q) = %d, want %d", c.content, got, c.want)
301 if got > 0 {
302 // Boundary must point to the start of a line
303 // (i.e. just after a newline) when the prefix
304 // is non-empty.
305 require.True(t, got <= len(c.content),
306 "boundary %d out of range (len=%d)", got, len(c.content))
307 if got > 0 && got <= len(c.content) {
308 require.Equal(t, byte('\n'), c.content[got-1],
309 "boundary %d does not sit immediately after a newline", got)
310 }
311 }
312 })
313 }
314}
315
316// -----------------------------------------------------------------------
317// T2: streaming-equivalence tests.
318// -----------------------------------------------------------------------
319
320// streamingScenarios returns the four canonical document shapes
321// that exercise different boundary-detection paths.
322func streamingScenarios() []struct {
323 name string
324 doc string
325} {
326 return []struct {
327 name string
328 doc string
329 }{
330 {
331 name: "plain-paragraphs",
332 doc: strings.Join([]string{
333 "This is the first paragraph of the document.",
334 "",
335 "Here is the second paragraph; it has some words.",
336 "",
337 "And a third paragraph for good measure.",
338 "",
339 "Finally a fourth paragraph to push past one boundary.",
340 }, "\n"),
341 },
342 {
343 name: "paragraphs-with-fence",
344 doc: strings.Join([]string{
345 "Intro paragraph.",
346 "",
347 "Some explanatory prose before the code.",
348 "",
349 "```go",
350 "func hello() {",
351 "\tfmt.Println(\"hi\")",
352 "}",
353 "```",
354 "",
355 "And a closing paragraph after the code block.",
356 }, "\n"),
357 },
358 {
359 name: "paragraphs-with-list",
360 doc: strings.Join([]string{
361 "Intro paragraph.",
362 "",
363 "- list item one",
364 "- list item two",
365 "- list item three",
366 "",
367 "Trailing paragraph.",
368 }, "\n"),
369 },
370 {
371 name: "paragraphs-with-table",
372 doc: strings.Join([]string{
373 "Intro paragraph.",
374 "",
375 "| col a | col b |",
376 "| ----- | ----- |",
377 "| 1 | 2 |",
378 "| 3 | 4 |",
379 "",
380 "Trailing paragraph after the table.",
381 }, "\n"),
382 },
383 }
384}
385
386// progressivePrefixes splits doc into n monotonically growing
387// byte prefixes, ending with the full document. n>=1.
388func progressivePrefixes(doc string, n int) []string {
389 if n < 1 {
390 n = 1
391 }
392 out := make([]string, 0, n)
393 for i := 1; i <= n; i++ {
394 // integer scaling so the last entry is exactly len(doc)
395 size := len(doc) * i / n
396 if i == n {
397 size = len(doc)
398 }
399 out = append(out, doc[:size])
400 }
401 return out
402}
403
404// TestStreamingMarkdown_FinalVisuallyEquivalent drives a sequence
405// of progressive prefixes through streamingMarkdown and asserts
406// the FINAL output is visually equivalent (per design principle
407// D) to a fresh full-document render. Strict byte-equality is
408// not the bar β see the comment in normalizeRender for why.
409func TestStreamingMarkdown_FinalVisuallyEquivalent(t *testing.T) {
410 t.Parallel()
411
412 const width = 80
413 const steps = 15
414
415 for _, sc := range streamingScenarios() {
416 t.Run(sc.name, func(t *testing.T) {
417 t.Parallel()
418 renderer := newTestRenderer(t, width)
419 var sm streamingMarkdown
420 prefixes := progressivePrefixes(sc.doc, steps)
421
422 var lastOut string
423 for _, p := range prefixes {
424 lastOut = sm.Render(p, width, renderer)
425 }
426
427 fresh := freshRender(t, sc.doc, width)
428 require.Equal(t, normalizeRender(fresh), normalizeRender(lastOut),
429 "final streaming output must match a fresh full render visually")
430 })
431 }
432}
433
434// TestStreamingMarkdown_IntermediateOutputsPlausible asserts that
435// every intermediate flush returns a non-empty string and does
436// not leak raw markdown source through to the user. This is the
437// "visually plausible" half of T2.
438func TestStreamingMarkdown_IntermediateOutputsPlausible(t *testing.T) {
439 t.Parallel()
440
441 const width = 80
442 const steps = 12
443
444 for _, sc := range streamingScenarios() {
445 t.Run(sc.name, func(t *testing.T) {
446 t.Parallel()
447 renderer := newTestRenderer(t, width)
448 var sm streamingMarkdown
449
450 for i, p := range progressivePrefixes(sc.doc, steps) {
451 if p == "" {
452 continue
453 }
454 out := sm.Render(p, width, renderer)
455 require.NotEmptyf(t, out, "step %d: empty render for prefix len %d", i, len(p))
456 require.Falsef(t, containsRawMarkdownSource(out),
457 "step %d: render leaked raw markdown source.\nprefix=%q\nout=%s",
458 i, p, normalizeRender(out))
459 }
460 })
461 }
462}
463
464// -----------------------------------------------------------------------
465// T3: cache invalidation tests.
466// -----------------------------------------------------------------------
467
468// TestStreamingMarkdown_WidthChangeInvalidates asserts that a
469// width change blows away the cached prefix so the next render
470// is keyed against the new width. We can't observe the cache
471// directly without reaching into the struct, so we assert the
472// observable contract: after a width change, the rendered output
473// reflects the new width AND the streamingMarkdown's internal
474// cache fields are reset to the new state.
475func TestStreamingMarkdown_WidthChangeInvalidates(t *testing.T) {
476 t.Parallel()
477
478 doc := "Para one.\n\nPara two.\n\nPara three."
479 r80 := newTestRenderer(t, 80)
480 r40 := newTestRenderer(t, 40)
481 var sm streamingMarkdown
482
483 out80 := sm.Render(doc, 80, r80)
484 require.Equal(t, 80, sm.width, "width must be cached after first render")
485 cachedPrefix := sm.stablePrefix
486
487 out40 := sm.Render(doc, 40, r40)
488 require.Equal(t, 40, sm.width, "width change must update cached width")
489 require.NotEqual(t, out80, out40,
490 "different widths must produce different rendered output")
491 // stablePrefix may legitimately have re-advanced after the
492 // reset (tryAdvanceFromEmpty), but if it has, it can no
493 // longer carry the OLD width's render. We assert the cache
494 // reset by checking that the cached prefix length is at
495 // most the current content length.
496 require.True(t, len(sm.stablePrefix) <= len(doc),
497 "stable prefix must be a prefix of the current content")
498 _ = cachedPrefix
499}
500
501// TestStreamingMarkdown_NonPrefixContentInvalidates verifies
502// that content which is NOT a prefix-extension of the cached
503// stable prefix triggers a Reset and a fresh render path. This
504// guards the "user retried the turn" case.
505func TestStreamingMarkdown_NonPrefixContentInvalidates(t *testing.T) {
506 t.Parallel()
507
508 const width = 80
509 r := newTestRenderer(t, width)
510 var sm streamingMarkdown
511
512 // Drive a streaming sequence so the cache picks up a stable
513 // prefix.
514 doc := "Para one.\n\nPara two.\n\nPara three."
515 for _, p := range progressivePrefixes(doc, 6) {
516 _ = sm.Render(p, width, r)
517 }
518 require.NotEmpty(t, sm.stablePrefix,
519 "stable prefix must be populated after streaming a multi-paragraph doc")
520
521 // Now switch to entirely different content (user retried).
522 other := "Completely different opening paragraph.\n\nAnd a second."
523 out := sm.Render(other, width, r)
524 require.NotEmpty(t, out)
525 // stablePrefix must be a prefix of `other`, i.e. cache was
526 // reset off the OLD content.
527 require.True(t, strings.HasPrefix(other, sm.stablePrefix),
528 "stable prefix must be reset to a prefix of the new content")
529
530 // Visual equivalence to a fresh render of `other`.
531 fresh := freshRender(t, other, width)
532 require.Equal(t, normalizeRender(fresh), normalizeRender(out),
533 "render after non-prefix content change must match a fresh render")
534}
535
536// TestStreamingMarkdown_ResetClearsCache asserts Reset() drops
537// every cached field; the next render is necessarily a full
538// render path.
539func TestStreamingMarkdown_ResetClearsCache(t *testing.T) {
540 t.Parallel()
541
542 const width = 80
543 r := newTestRenderer(t, width)
544 var sm streamingMarkdown
545
546 doc := "Para one.\n\nPara two.\n\nPara three."
547 _ = sm.Render(doc, width, r)
548 // The sample doc has safe boundaries so the cache should
549 // have advanced. If for some reason it didn't, we still
550 // want Reset to be a no-op-safe operation; assert the
551 // post-Reset state directly.
552 sm.Reset()
553 require.Equal(t, 0, sm.width)
554 require.Equal(t, "", sm.stablePrefix)
555 require.Equal(t, "", sm.stablePrefixRender)
556
557 // Next render must be a full render path. Drive one step
558 // and verify the output matches a fresh full render.
559 out := sm.Render(doc, width, r)
560 fresh := freshRender(t, doc, width)
561 require.Equal(t, normalizeRender(fresh), normalizeRender(out))
562}
563
564// -----------------------------------------------------------------------
565// T4: fallback safety.
566// -----------------------------------------------------------------------
567
568// TestStreamingMarkdown_NoSafeBoundaryAlwaysFullRenders covers
569// the "one giant table being built character by character" case.
570// Every flush must fall back to a full render; the cache must
571// not advance into an unsafe state. We compare each flush to a
572// fresh full render of the same prefix; bytes must match for
573// each prefix individually.
574//
575// (Byte equality is sound here because no concatenation happens:
576// the streaming path delegates straight to renderer.Render when
577// the cache is empty and no safe boundary exists.)
578func TestStreamingMarkdown_NoSafeBoundaryAlwaysFullRenders(t *testing.T) {
579 t.Parallel()
580
581 const width = 80
582
583 // One growing table β no blank lines anywhere, so no
584 // boundary candidate is ever found.
585 doc := strings.Join([]string{
586 "| col a | col b | col c |",
587 "| ----- | ----- | ----- |",
588 "| 1 | 2 | 3 |",
589 "| 4 | 5 | 6 |",
590 "| 7 | 8 | 9 |",
591 "| 10 | 11 | 12 |",
592 "| 13 | 14 | 15 |",
593 "| 16 | 17 | 18 |",
594 "| 19 | 20 | 21 |",
595 "| 22 | 23 | 24 |",
596 }, "\n")
597 require.Equal(t, -1, findSafeMarkdownBoundary(doc),
598 "sanity check: no blank lines, no safe boundary")
599
600 r := newTestRenderer(t, width)
601 var sm streamingMarkdown
602
603 prefixes := progressivePrefixes(doc, 10)
604 for i, p := range prefixes {
605 if p == "" {
606 continue
607 }
608 out := sm.Render(p, width, r)
609 fresh := freshRender(t, p, width)
610 require.Equalf(t, fresh, out,
611 "step %d (len=%d): streaming output must byte-equal a fresh render when boundary detection fails",
612 i, len(p))
613 }
614 // Cache must remain empty: no boundary was ever found, no
615 // width change occurred, no advance ever cached anything.
616 require.Equal(t, "", sm.stablePrefix,
617 "stable prefix must remain empty when no safe boundary ever exists")
618}
619
620// TestStreamingMarkdown_NoSafeBoundaryDoesNotCrash is the
621// minimum-viability assertion of T4: even when boundary
622// detection fails on every flush the streaming path must not
623// crash and must produce non-empty output for non-empty input.
624func TestStreamingMarkdown_NoSafeBoundaryDoesNotCrash(t *testing.T) {
625 t.Parallel()
626
627 const width = 80
628 r := newTestRenderer(t, width)
629 var sm streamingMarkdown
630
631 // A deeply-pathological input: a single line that grows
632 // one character at a time. There is never a blank-line
633 // separator so the cache is never advanced.
634 src := "The quick brown fox jumps over the lazy dog."
635 for i := 1; i <= len(src); i++ {
636 out := sm.Render(src[:i], width, r)
637 require.NotEmpty(t, out, "streaming output must not be empty for non-empty input")
638 }
639}
640
641// -----------------------------------------------------------------------
642// Integration assertions on the wired-in path.
643// -----------------------------------------------------------------------
644
645// -----------------------------------------------------------------------
646// T5 / T6 / T7: anywhere-in-prefix hazards (B1 / B2 / B3 from the
647// F8 round-2 review). For each hazard we drive every progressive
648// prefix of a document that exercises the hazard through the cache
649// and assert two contracts:
650//
651// 1. The cached stable prefix never contains the hazard. If the
652// hazard line is at byte offset H, then after every flush
653// len(sm.stablePrefix) <= H. This is the "no silent
654// corruption" half β the algorithm cannot accept a boundary
655// that splits across the hazard.
656//
657// 2. The final flush is visually equivalent to a fresh full
658// render of the complete document. This is the same T2-style
659// equivalence assertion ported to the new doc shapes.
660// -----------------------------------------------------------------------
661
662// nonBlankLines returns the non-blank visible lines of s with
663// per-line trailing whitespace trimmed. Used to compare two
664// rendered fragments for content equivalence when paragraph-
665// margin behaviour legitimately differs between a single fresh
666// render and a streaming split render (per F8 design principle D
667// β visual equivalence is the bar, byte-equivalence is not).
668//
669// Some glamour block types (notably HTML blocks and reference
670// link definitions) interact with adjacent paragraph blocks
671// during a single render β adjacency effectively suppresses the
672// blank-line margin between blocks. When the streaming path
673// renders the prefix and trail in separate calls, the seam is
674// re-introduced as a blank line. The visible TEXT is identical;
675// only the inter-block margin differs.
676func nonBlankLines(s string) []string {
677 clean := stripANSI(s)
678 out := make([]string, 0)
679 for _, l := range strings.Split(clean, "\n") {
680 l = strings.TrimRight(l, " \t")
681 if strings.TrimSpace(l) == "" {
682 continue
683 }
684 out = append(out, l)
685 }
686 return out
687}
688
689// runProgressiveBoundaryRespectTest is the shared body of T5/T6/T7.
690// It accepts a document and the byte offset of the line whose
691// PRESENCE in the prefix must trigger the hazard reject; the
692// cached stable prefix may never extend past hazardLineOffset.
693//
694// The final-output equivalence check is content-based (non-blank
695// lines compared) rather than full-normalization: see
696// nonBlankLines for the reason.
697func runProgressiveBoundaryRespectTest(t *testing.T, doc string, hazardLineOffset int) {
698 t.Helper()
699 const width = 80
700 const steps = 25
701
702 renderer := newTestRenderer(t, width)
703 var sm streamingMarkdown
704
705 prefixes := progressivePrefixes(doc, steps)
706 var lastOut string
707 for i, p := range prefixes {
708 if p == "" {
709 continue
710 }
711 lastOut = sm.Render(p, width, renderer)
712 require.NotEmptyf(t, lastOut, "step %d: empty render", i)
713 require.LessOrEqualf(t, len(sm.stablePrefix), hazardLineOffset,
714 "step %d: cached stable prefix advanced past the hazard line\n"+
715 "prefix len=%d, hazard at %d, sm.stablePrefix=%q",
716 i, len(sm.stablePrefix), hazardLineOffset, sm.stablePrefix)
717 }
718
719 fresh := freshRender(t, doc, width)
720 require.Equal(t, nonBlankLines(fresh), nonBlankLines(lastOut),
721 "final streaming output must contain the same non-blank lines as a fresh full render")
722}
723
724// TestStreamingMarkdown_LooseListContinuation locks in the B1 fix.
725// A loose list followed by a continuation paragraph and then a
726// trailing paragraph creates a candidate boundary between the list
727// item and its continuation; the trailing non-blank line of that
728// candidate prefix is the continuation paragraph (not a list
729// marker), so the line-only check would accept it. The
730// anywhere-in-prefix list-marker check rejects it.
731func TestStreamingMarkdown_LooseListContinuation(t *testing.T) {
732 t.Parallel()
733
734 doc := strings.Join([]string{
735 "Intro paragraph.",
736 "",
737 "- item one",
738 "",
739 " continuation paragraph still belongs to item one",
740 "",
741 "- item two",
742 "",
743 "Trailing paragraph after the list.",
744 }, "\n")
745
746 // The first list marker line begins after "Intro paragraph.\n\n".
747 // The cached stable prefix may include that boundary (BEFORE
748 // the list opens) but must never advance into the list.
749 hazardOffset := strings.Index(doc, "- item one")
750 require.Greater(t, hazardOffset, 0, "test setup")
751
752 runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
753}
754
755// TestStreamingMarkdown_HTMLBlock locks in the B2 fix. A raw HTML
756// block followed by a paragraph creates a candidate boundary
757// between the closed HTML block and the trailing paragraph. The
758// anywhere-in-prefix HTML-opener check rejects any boundary that
759// would include the HTML block in the stable prefix.
760func TestStreamingMarkdown_HTMLBlock(t *testing.T) {
761 t.Parallel()
762
763 doc := strings.Join([]string{
764 "Intro paragraph.",
765 "",
766 "<div>",
767 "some block content",
768 "</div>",
769 "",
770 "Trailing paragraph after the HTML block.",
771 }, "\n")
772
773 hazardOffset := strings.Index(doc, "<div>")
774 require.Greater(t, hazardOffset, 0, "test setup")
775
776 runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
777}
778
779// TestStreamingMarkdown_HTMLBlockType7 covers HTML block type 7
780// (CommonMark): a generic open/close tag whose name is NOT in the
781// fixed type-6 set still opens an HTML block and must forfeit any
782// boundary that would split the block off from following content.
783func TestStreamingMarkdown_HTMLBlockType7(t *testing.T) {
784 t.Parallel()
785
786 doc := strings.Join([]string{
787 "Intro paragraph.",
788 "",
789 "<custom-tag>",
790 "some block content",
791 "</custom-tag>",
792 "",
793 "Trailing paragraph after the custom-tag block.",
794 }, "\n")
795
796 hazardOffset := strings.Index(doc, "<custom-tag>")
797 require.Greater(t, hazardOffset, 0, "test setup")
798
799 runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
800}
801
802// TestStreamingMarkdown_LinkRefDefinition locks in the B3 fix. A
803// reference link definition followed by a paragraph that uses the
804// reference creates a boundary candidate between the def and the
805// paragraph; rendering them in separate glamour passes loses the
806// definition. The anywhere-in-prefix ref-def check rejects.
807func TestStreamingMarkdown_LinkRefDefinition(t *testing.T) {
808 t.Parallel()
809
810 doc := strings.Join([]string{
811 "Intro paragraph.",
812 "",
813 "[ref]: http://example.com",
814 "",
815 "Trailing paragraph that links to [the example][ref] inline.",
816 }, "\n")
817
818 hazardOffset := strings.Index(doc, "[ref]:")
819 require.Greater(t, hazardOffset, 0, "test setup")
820
821 runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
822}
823
824// TestAssistantStreamingContent_ResetOnClearCache guards the
825// integration contract that ClearItemCaches (style change) drops
826// the streaming-markdown cache. Without this, a style change
827// would leave the OLD style's ANSI sequences embedded in the
828// stable-prefix render and the next flush would visually mix
829// styles.
830func TestAssistantStreamingContent_ResetOnClearCache(t *testing.T) {
831 t.Parallel()
832
833 sty := styles.CharmtonePantera()
834 doc := "Para one.\n\nPara two.\n\nPara three."
835 msg := finishedAssistantMessage("stream-clear", doc)
836 item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
837
838 const width = 80
839 _ = item.RawRender(width)
840 // Drive a second message that extends the content so the
841 // streaming cache has a chance to advance (if it would).
842 doc2 := doc + "\n\nFour."
843 item.SetMessage(finishedAssistantMessage("stream-clear", doc2))
844 _ = item.RawRender(width)
845
846 // Now wipe the caches the way ClearItemCaches does.
847 item.clearCache()
848
849 require.Equal(t, "", item.streamingContent.stablePrefix,
850 "clearCache must Reset the streaming-markdown cache")
851 require.Equal(t, "", item.streamingContent.stablePrefixRender)
852 require.Equal(t, 0, item.streamingContent.width)
853}