@@ -0,0 +1,853 @@
7+ "charm.land/glamour/v2"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/stretchr/testify/require"
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.
17+func newTestRenderer(t *testing.T, width int) *glamour.TermRenderer {
19+ sty := styles.CharmtonePantera()
20+ r, err := glamour.NewTermRenderer(
21+ glamour.WithStyles(sty.Markdown),
22+ glamour.WithWordWrap(width),
24+ require.NoError(t, err)
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.
32+func freshRender(t *testing.T, content string, width int) string {
34+ r := newTestRenderer(t, width)
35+ out, err := r.Render(content)
36+ require.NoError(t, err)
37+ return strings.TrimSuffix(out, "\n")
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.
43+func stripANSI(s string) string {
44+ var b strings.Builder
48+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
52+ if c >= 0x40 && c <= 0x7e {
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.
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.
78+func 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")
84+ // Collapse consecutive blank lines.
85+ out := make([]string, 0, len(lines))
87+ for _, l := range lines {
89+ if blank && prevBlank {
95+ // Trim leading and trailing blanks.
96+ for len(out) > 0 && out[0] == "" {
99+ for len(out) > 0 && out[len(out)-1] == "" {
100+ out = out[:len(out)-1]
102+ return strings.Join(out, "\n")
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.
114+func containsRawMarkdownSource(rendered string) bool {
115+ clean := stripANSI(rendered)
116+ if strings.Contains(clean, "```") {
119+ for _, line := range strings.Split(clean, "\n") {
120+ if strings.HasPrefix(strings.TrimLeft(line, " \t"), "###") {
127+// -----------------------------------------------------------------------
128+// T1: findSafeMarkdownBoundary unit tests.
129+// -----------------------------------------------------------------------
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.
135+func TestFindSafeMarkdownBoundary_TableDriven(t *testing.T) {
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.
153+ name: "single line",
154+ content: "Just a single paragraph",
158+ name: "two paragraphs",
159+ content: "First paragraph.\n\nSecond paragraph.",
160+ // boundary at start of "Second"
161+ want: len("First paragraph.\n\n"),
164+ name: "three paragraphs picks latest",
165+ content: "First.\n\nSecond.\n\nThird.",
166+ want: len("First.\n\nSecond.\n\n"),
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"),
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"),
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"),
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
215+ want: len("Para.\n\n"),
218+ name: "list interior: no boundary",
219+ content: "- one\n- two\n",
220+ // no blank line at all.
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
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"),
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.
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"),
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.
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.)
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"),
295+ for _, c := range cases {
296+ t.Run(c.name, func(t *testing.T) {
298+ got := findSafeMarkdownBoundary(c.content)
299+ require.Equalf(t, c.want, got,
300+ "findSafeMarkdownBoundary(%q) = %d, want %d", c.content, got, c.want)
302+ // Boundary must point to the start of a line
303+ // (i.e. just after a newline) when the prefix
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)
316+// -----------------------------------------------------------------------
317+// T2: streaming-equivalence tests.
318+// -----------------------------------------------------------------------
320+// streamingScenarios returns the four canonical document shapes
321+// that exercise different boundary-detection paths.
322+func streamingScenarios() []struct {
331+ name: "plain-paragraphs",
332+ doc: strings.Join([]string{
333+ "This is the first paragraph of the document.",
335+ "Here is the second paragraph; it has some words.",
337+ "And a third paragraph for good measure.",
339+ "Finally a fourth paragraph to push past one boundary.",
343+ name: "paragraphs-with-fence",
344+ doc: strings.Join([]string{
347+ "Some explanatory prose before the code.",
351+ "\tfmt.Println(\"hi\")",
355+ "And a closing paragraph after the code block.",
359+ name: "paragraphs-with-list",
360+ doc: strings.Join([]string{
365+ "- list item three",
367+ "Trailing paragraph.",
371+ name: "paragraphs-with-table",
372+ doc: strings.Join([]string{
375+ "| col a | col b |",
376+ "| ----- | ----- |",
380+ "Trailing paragraph after the table.",
386+// progressivePrefixes splits doc into n monotonically growing
387+// byte prefixes, ending with the full document. n>=1.
388+func progressivePrefixes(doc string, n int) []string {
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
399+ out = append(out, doc[:size])
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.
409+func TestStreamingMarkdown_FinalVisuallyEquivalent(t *testing.T) {
415+ for _, sc := range streamingScenarios() {
416+ t.Run(sc.name, func(t *testing.T) {
418+ renderer := newTestRenderer(t, width)
419+ var sm streamingMarkdown
420+ prefixes := progressivePrefixes(sc.doc, steps)
423+ for _, p := range prefixes {
424+ lastOut = sm.Render(p, width, renderer)
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")
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.
438+func TestStreamingMarkdown_IntermediateOutputsPlausible(t *testing.T) {
444+ for _, sc := range streamingScenarios() {
445+ t.Run(sc.name, func(t *testing.T) {
447+ renderer := newTestRenderer(t, width)
448+ var sm streamingMarkdown
450+ for i, p := range progressivePrefixes(sc.doc, steps) {
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))
464+// -----------------------------------------------------------------------
465+// T3: cache invalidation tests.
466+// -----------------------------------------------------------------------
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.
475+func TestStreamingMarkdown_WidthChangeInvalidates(t *testing.T) {
478+ doc := "Para one.\n\nPara two.\n\nPara three."
479+ r80 := newTestRenderer(t, 80)
480+ r40 := newTestRenderer(t, 40)
481+ var sm streamingMarkdown
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
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")
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.
505+func TestStreamingMarkdown_NonPrefixContentInvalidates(t *testing.T) {
509+ r := newTestRenderer(t, width)
510+ var sm streamingMarkdown
512+ // Drive a streaming sequence so the cache picks up a stable
514+ doc := "Para one.\n\nPara two.\n\nPara three."
515+ for _, p := range progressivePrefixes(doc, 6) {
516+ _ = sm.Render(p, width, r)
518+ require.NotEmpty(t, sm.stablePrefix,
519+ "stable prefix must be populated after streaming a multi-paragraph doc")
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")
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")
536+// TestStreamingMarkdown_ResetClearsCache asserts Reset() drops
537+// every cached field; the next render is necessarily a full
539+func TestStreamingMarkdown_ResetClearsCache(t *testing.T) {
543+ r := newTestRenderer(t, width)
544+ var sm streamingMarkdown
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.
553+ require.Equal(t, 0, sm.width)
554+ require.Equal(t, "", sm.stablePrefix)
555+ require.Equal(t, "", sm.stablePrefixRender)
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))
564+// -----------------------------------------------------------------------
565+// T4: fallback safety.
566+// -----------------------------------------------------------------------
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.
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.)
578+func TestStreamingMarkdown_NoSafeBoundaryAlwaysFullRenders(t *testing.T) {
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+ "| ----- | ----- | ----- |",
597+ require.Equal(t, -1, findSafeMarkdownBoundary(doc),
598+ "sanity check: no blank lines, no safe boundary")
600+ r := newTestRenderer(t, width)
601+ var sm streamingMarkdown
603+ prefixes := progressivePrefixes(doc, 10)
604+ for i, p := range prefixes {
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",
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")
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.
624+func TestStreamingMarkdown_NoSafeBoundaryDoesNotCrash(t *testing.T) {
628+ r := newTestRenderer(t, width)
629+ var sm streamingMarkdown
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")
641+// -----------------------------------------------------------------------
642+// Integration assertions on the wired-in path.
643+// -----------------------------------------------------------------------
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:
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.
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+// -----------------------------------------------------------------------
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).
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.
676+func 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) == "" {
684+ out = append(out, l)
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.
694+// The final-output equivalence check is content-based (non-blank
695+// lines compared) rather than full-normalization: see
696+// nonBlankLines for the reason.
697+func runProgressiveBoundaryRespectTest(t *testing.T, doc string, hazardLineOffset int) {
702+ renderer := newTestRenderer(t, width)
703+ var sm streamingMarkdown
705+ prefixes := progressivePrefixes(doc, steps)
707+ for i, p := range prefixes {
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)
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")
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.
731+func TestStreamingMarkdown_LooseListContinuation(t *testing.T) {
734+ doc := strings.Join([]string{
739+ " continuation paragraph still belongs to item one",
743+ "Trailing paragraph after the list.",
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")
752+ runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
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.
760+func TestStreamingMarkdown_HTMLBlock(t *testing.T) {
763+ doc := strings.Join([]string{
767+ "some block content",
770+ "Trailing paragraph after the HTML block.",
773+ hazardOffset := strings.Index(doc, "<div>")
774+ require.Greater(t, hazardOffset, 0, "test setup")
776+ runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
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.
783+func TestStreamingMarkdown_HTMLBlockType7(t *testing.T) {
786+ doc := strings.Join([]string{
790+ "some block content",
793+ "Trailing paragraph after the custom-tag block.",
796+ hazardOffset := strings.Index(doc, "<custom-tag>")
797+ require.Greater(t, hazardOffset, 0, "test setup")
799+ runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
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.
807+func TestStreamingMarkdown_LinkRefDefinition(t *testing.T) {
810+ doc := strings.Join([]string{
813+ "[ref]: http://example.com",
815+ "Trailing paragraph that links to [the example][ref] inline.",
818+ hazardOffset := strings.Index(doc, "[ref]:")
819+ require.Greater(t, hazardOffset, 0, "test setup")
821+ runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
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
830+func TestAssistantStreamingContent_ResetOnClearCache(t *testing.T) {
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)
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)
846+ // Now wipe the caches the way ClearItemCaches does.
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)