@@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test";
22import { defined } from "../../testkit/defined.js" ;
33import {
44 createMemoizedParseMarkdown ,
5+ nextStreamMarkdownState ,
56 parseMarkdown ,
7+ splitAtSettledBlock ,
68 splitAtSettledHeading ,
79 withholdIncompleteHeading ,
810 type StyledSegment ,
@@ -567,3 +569,230 @@ describe("splitAtSettledHeading", () => {
567569 expect ( splitAtSettledHeading ( "# Title" ) ) . toBeNull ( ) ;
568570 } ) ;
569571} ) ;
572+
573+ describe ( "splitAtSettledBlock" , ( ) => {
574+ test ( "freezes through the latest settled block (heading, fence, table)" , ( ) => {
575+ const lines = [
576+ "### Title" ,
577+ "" ,
578+ "```bash" ,
579+ "echo hi" ,
580+ "```" ,
581+ "" ,
582+ "| a | b |" ,
583+ "|---|---|" ,
584+ "| 1 | 2 |" ,
585+ "" ,
586+ "tail" ,
587+ ] ;
588+ expect ( splitAtSettledBlock ( lines . join ( "\n" ) ) ) . toEqual ( {
589+ frozen : lines . slice ( 0 , 9 ) . join ( "\n" ) ,
590+ live : "tail" ,
591+ gapRows : 1 ,
592+ } ) ;
593+ } ) ;
594+
595+ test ( "freezes a closed fence with prose after the closer" , ( ) => {
596+ expect ( splitAtSettledBlock ( "```bash\necho hi\n```\n\ntail" ) ) . toEqual ( {
597+ frozen : "```bash\necho hi\n```" ,
598+ live : "tail" ,
599+ gapRows : 1 ,
600+ } ) ;
601+ } ) ;
602+
603+ test ( "supports tilde fences and indented openers up to three spaces" , ( ) => {
604+ expect ( splitAtSettledBlock ( "~~~\ncode\n~~~\n\ntail" ) ) . toEqual ( {
605+ frozen : "~~~\ncode\n~~~" ,
606+ live : "tail" ,
607+ gapRows : 1 ,
608+ } ) ;
609+ expect ( splitAtSettledBlock ( " ```js\ncode\n ```\n\ntail" ) ) . toEqual ( {
610+ frozen : " ```js\ncode\n ```" ,
611+ live : "tail" ,
612+ gapRows : 1 ,
613+ } ) ;
614+ } ) ;
615+
616+ test ( "a four-space indented fence is not a fence, so earlier text can settle" , ( ) => {
617+ expect ( splitAtSettledBlock ( "## T\n\n ```\nx" ) ) . toEqual ( {
618+ frozen : "## T" ,
619+ live : " ```\nx" ,
620+ gapRows : 1 ,
621+ } ) ;
622+ } ) ;
623+
624+ test ( "an unclosed fence after settled text still leaves the settled split in place" , ( ) => {
625+ expect ( splitAtSettledBlock ( "### T\n\n```bash\necho hi" ) ) . toEqual ( {
626+ frozen : "### T" ,
627+ live : "```bash\necho hi" ,
628+ gapRows : 1 ,
629+ } ) ;
630+ expect ( splitAtSettledBlock ( "```bash\necho hi" ) ) . toBeNull ( ) ;
631+ } ) ;
632+
633+ test ( "a closed block at the last line falls back to the earlier split" , ( ) => {
634+ expect ( splitAtSettledBlock ( "## T\n\n```\ncode\n```" ) ) . toEqual ( {
635+ frozen : "## T" ,
636+ live : "```\ncode\n```" ,
637+ gapRows : 1 ,
638+ } ) ;
639+ expect ( splitAtSettledBlock ( "## T\n\n| h |\n|---|\n| r |" ) ) . toEqual ( {
640+ frozen : "## T" ,
641+ live : "| h |\n|---|\n| r |" ,
642+ gapRows : 1 ,
643+ } ) ;
644+ expect ( splitAtSettledBlock ( "```\ncode\n```" ) ) . toBeNull ( ) ;
645+ expect ( splitAtSettledBlock ( "| h |\n|---|\n| r |" ) ) . toBeNull ( ) ;
646+ } ) ;
647+
648+ test ( "freezes through a completed separator table, not just the heading" , ( ) => {
649+ const lines = [
650+ "## T" ,
651+ "" ,
652+ "Name | Age" ,
653+ "--- | ---" ,
654+ "Ada | 36" ,
655+ "" ,
656+ "tail" ,
657+ ] ;
658+ expect ( splitAtSettledBlock ( lines . join ( "\n" ) ) ) . toEqual ( {
659+ frozen : lines . slice ( 0 , 5 ) . join ( "\n" ) ,
660+ live : "tail" ,
661+ gapRows : 1 ,
662+ } ) ;
663+ } ) ;
664+
665+ test ( "freezes through a completed borderless table" , ( ) => {
666+ const lines = [
667+ "## T" ,
668+ "" ,
669+ "| h1 | h2 |" ,
670+ "| r1 | r2 |" ,
671+ "| r3 | r4 |" ,
672+ "" ,
673+ "tail" ,
674+ ] ;
675+ expect ( splitAtSettledBlock ( lines . join ( "\n" ) ) ) . toEqual ( {
676+ frozen : lines . slice ( 0 , 5 ) . join ( "\n" ) ,
677+ live : "tail" ,
678+ gapRows : 1 ,
679+ } ) ;
680+ } ) ;
681+
682+ test ( "a table that reaches the last line keeps the row live" , ( ) => {
683+ expect ( splitAtSettledBlock ( "| h |\n|---|\n| r1 |\n| r2" ) ) . toBeNull ( ) ;
684+ expect ( splitAtSettledBlock ( "## T\n\n| h |\n|---|\n| r1 |\n| r2" ) ) . toEqual ( {
685+ frozen : "## T" ,
686+ live : "| h |\n|---|\n| r1 |\n| r2" ,
687+ gapRows : 1 ,
688+ } ) ;
689+ } ) ;
690+
691+ test ( "a table with no separator is not a table" , ( ) => {
692+ expect ( splitAtSettledBlock ( "a | b\n\ntail" ) ) . toBeNull ( ) ;
693+ } ) ;
694+
695+ test ( "agrees with splitAtSettledHeading on heading-only content" , ( ) => {
696+ for ( const text of [ "# Title\nbody" , "# Title" , "plain" ] ) {
697+ expect ( splitAtSettledBlock ( text ) ) . toEqual ( splitAtSettledHeading ( text ) ) ;
698+ }
699+ } ) ;
700+ } ) ;
701+
702+ describe ( "nextStreamMarkdownState" , ( ) => {
703+ const frozen = "### T" ;
704+ const base = {
705+ content : `${ frozen } \n\ntail` ,
706+ frozen,
707+ width : 100 ,
708+ streaming : true ,
709+ } ;
710+
711+ test ( "a first paint always repaints" , ( ) => {
712+ expect (
713+ nextStreamMarkdownState (
714+ null ,
715+ base . content ,
716+ base . frozen ,
717+ base . width ,
718+ true ,
719+ ) ,
720+ ) . toEqual ( {
721+ state : base ,
722+ paintFrozen : true ,
723+ } ) ;
724+ } ) ;
725+
726+ test ( "tail-only growth skips the frozen repaint" , ( ) => {
727+ const grown = { ...base , content : `${ frozen } \n\ntail grows` } ;
728+ const next = nextStreamMarkdownState (
729+ base ,
730+ grown . content ,
731+ grown . frozen ,
732+ grown . width ,
733+ true ,
734+ ) ;
735+ expect ( next . paintFrozen ) . toBe ( false ) ;
736+ expect ( next . state . content ) . toBe ( grown . content ) ;
737+ } ) ;
738+
739+ test ( "a moved boundary repaints the frozen node" , ( ) => {
740+ const moved = { ...base , frozen : `${ frozen } \n\nsettled` } ;
741+ expect (
742+ nextStreamMarkdownState (
743+ base ,
744+ base . content ,
745+ moved . frozen ,
746+ base . width ,
747+ true ,
748+ ) . paintFrozen ,
749+ ) . toBe ( true ) ;
750+ } ) ;
751+
752+ test ( "width and streaming flips repaint the frozen node" , ( ) => {
753+ expect (
754+ nextStreamMarkdownState (
755+ base ,
756+ base . content ,
757+ base . frozen ,
758+ base . width + 1 ,
759+ true ,
760+ ) . paintFrozen ,
761+ ) . toBe ( true ) ;
762+ expect (
763+ nextStreamMarkdownState (
764+ base ,
765+ base . content ,
766+ base . frozen ,
767+ base . width ,
768+ false ,
769+ ) . paintFrozen ,
770+ ) . toBe ( true ) ;
771+ } ) ;
772+
773+ test ( "mid-stream edits repaint the frozen node" , ( ) => {
774+ const edited = `${ frozen } \n\nrewritten` ;
775+ const prev = { ...base , content : `${ frozen } \n\ntail` } ;
776+ const next = nextStreamMarkdownState (
777+ prev ,
778+ edited ,
779+ frozen ,
780+ base . width ,
781+ true ,
782+ ) ;
783+ expect ( next . paintFrozen ) . toBe ( true ) ;
784+ expect ( next . state ) . toBeDefined ( ) ;
785+ } ) ;
786+
787+ test ( "identical content still skips the frozen repaint" , ( ) => {
788+ const next = nextStreamMarkdownState (
789+ base ,
790+ base . content ,
791+ base . frozen ,
792+ base . width ,
793+ true ,
794+ ) ;
795+ expect ( next . paintFrozen ) . toBe ( false ) ;
796+ expect ( next . state ) . toEqual ( base ) ;
797+ } ) ;
798+ } ) ;
0 commit comments