incremental_glamour_test.go

  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}