Repository: leo-editor/leo-editor

Issue:
Read sentinels more robustly
See PR #3678.

**Leo will continue to support Black-compatible sentinels**

Leo's  `--black-sentinels` (`-b`) command-line option will remain.

**Leo's read code should handle sentinels more robustly**

The legacy code assumed that all sentinels have the same form (blackened or not) as the open `@+leo` sentinel.

This assumption can be incorrect when doing a `git merge`!

PR #3678 make the read code more robust and improves related unit tests.

**There is little need for write Black-compatible sentinels**

Those using Leo in an environment that mandates Black formatting are likely using `@clean` or `@auto` to avoid using sentinels.

Orange (Leo's beautifier) is superior to Black for all my work.

Discussion:

Patch:
diff --git a/leo/commands/gotoCommands.py b/leo/commands/gotoCommands.py
index 3acbc7cc9..6088ee05c 100644
--- a/leo/commands/gotoCommands.py
+++ b/leo/commands/gotoCommands.py
@@ -99,7 +99,7 @@ class GoToCommands:
         # if not g.unitTesting: g.printObj(contents)
 
         # Find the node with the correct gnx.
-        node_pat = re.compile(fr"\s*{re.escape(delim1)}@\+node:{re.escape(p.gnx)}:")
+        node_pat = re.compile(fr"\s*{re.escape(delim1)} ?@\+node:{re.escape(p.gnx)}:")
         for i, s in enumerate(contents):
             if node_pat.match(s):
                 if remove_sentinels:
@@ -114,7 +114,7 @@ class GoToCommands:
         # #3010: Special case for .vue files.
         #        Also look for nodes delimited by "//"
         if root.h.endswith('.vue'):
-            node_pat2 = re.compile(fr"\s{re.escape('//')}@\+node:{re.escape(p.gnx)}:")
+            node_pat2 = re.compile(fr"\s{re.escape('//')} ?@\+node:{re.escape(p.gnx)}:")
             for i, s in enumerate(contents):
                 if node_pat2.match(s):
                     return i + 1
@@ -216,7 +216,10 @@ class GoToCommands:
             is_sentinel = self.is_sentinel(delim1, delim2, s)
             # print(f"count: {count:2} offset: {offset} {s!r}")
             if is_sentinel:
-                s2 = s.strip()[len(delim1) :]  # Works for blackened sentinels.
+                # Handle blackened sentinels.
+                s2 = s.strip()[len(delim1) :]
+                if s2.startswith(' '):
+                    s2 = s2[1:]
                 if s2.startswith('@+node'):
                     # Invisible, but resets the offset.
                     offset = 0
@@ -263,7 +266,10 @@ class GoToCommands:
         stack = [(gnx, h, offset),]
         for i, s in enumerate(lines):
             if self.is_sentinel(delim1, delim2, s):
-                s2 = s.strip()[len(delim1) :]  # Works for blackened sentinels.
+                # Handle blackened sentinels.
+                s2 = s.strip()[len(delim1) :]
+                if s2.startswith(' '):
+                    s2 = s2[1:]
                 if s2.startswith('@+node'):
                     offset = 0
                     gnx, h = self.get_script_node_info(s, delim2)
diff --git a/leo/core/leoAtFile.py b/leo/core/leoAtFile.py
index f26de7558..5c886b9e1 100644
--- a/leo/core/leoAtFile.py
+++ b/leo/core/leoAtFile.py
@@ -36,12 +36,12 @@ class AtFile:
 
     # directives...
     noDirective     =  1  # not an at-directive.
-    allDirective    =  2  # at-all (4.2)
+    allDirective    =  2  # @all (4.2)
     docDirective    =  3  # @doc.
-    atDirective     =  4  # @<space> or @<newline>
+    atDirective     =  4  # @<space or newline>
     codeDirective   =  5  # @code
-    cDirective      =  6  # @c<space> or @c<newline>
-    othersDirective =  7  # at-others
+    cDirective      =  6  # @c<space or newline>
+    othersDirective =  7  # @others
     miscDirective   =  8  # All other directives
     startVerbatim   =  9  # @verbatim  Not a real directive. Used to issue warnings.
     #@-<< define class constants >>
@@ -167,7 +167,7 @@ class AtFile:
             # Encoding directive overrides everything else.
             encoding = g.getPythonEncodingFromString(root.b)
             if encoding:
-                at.encoding = encoding
+                at.encoding = encoding  # pragma: no cover (python 2)
         #
         # Clean root.v.
         if not at.errors and at.root:
@@ -1727,6 +1727,13 @@ class AtFile:
             has_at_others = False
             in_code = True
 
+            def __repr__(self) -> str:  # pragma: no cover (testing)
+                return (
+                    f"code? {int(self.in_code)} "
+                    # f"comment? {int(self.at_comment_seen)} "
+                    # f"delims? {int(self.at_delims_seen)} "
+                    # f"@others? {int(self.has_at_others)} "
+                )
 
         i = 0
         status = Status()
@@ -1944,12 +1951,10 @@ class AtFile:
         line = s[i:j]
 
         def put_verbatim_sentinel() -> None:
-            """
-            Put an @verbatim sentinel. *Always* use black-compatible indentation.
-            """
+            """Put an @verbatim sentinel."""
             if at.root.isAtCleanNode():
                 # #2996. Adding an @verbatim sentinel interferes with the @clean algorithm.
-                return
+                return  # # pragma: no cover (defensive)
             ws = s[i:k]
             self.putIndent(len(ws))
             self.putSentinel("@verbatim")
@@ -2028,6 +2033,7 @@ class AtFile:
             # A blank line.
             at.putBlankDocLine()
             return
+
         # Write the line as it is.
         at.putIndent(at.indent)
         if not at.endSentinelComment:
@@ -2148,8 +2154,8 @@ class AtFile:
         if at.sentinels or g.app.force_at_auto_sentinels:
             at.putIndent(at.indent)
             at.os(at.startSentinelComment)
-            # #2194. #2983: Put Black sentinels if --black-sentinels is in effect.
-            if g.app.write_black_sentinels:
+            # Blacken python sentinels if --black-sentinels is in effect.
+            if g.app.write_black_sentinels and self.language == 'python':
                 at.os(' ')
             # Apply the cweb hack to s:
             #   If the opening comment delim ends in '@',
@@ -2893,6 +2899,21 @@ class AtFile:
         aSet = d.get(fn, set())
         aSet.add(p.h)
         d[fn] = aSet
+    #@+node:ekr.20231207062321.1: *4* at.repr_kind
+    def repr_kind(self, kind: int) -> str:  # pragma: no cover (testing)
+        """Return the string representation of AtFile class constants."""
+        d: dict[int, str] = {
+            1: 'plain',
+            2: '@all',
+            3: '@doc',
+            4: '@ ',
+            5: '@code',
+            6: '@c',
+            7: '@others',
+            8: '@<misc>',
+            9: '@verbatim',
+        }
+        return d.get(kind) or f"<unknown AtFile class constant> {kind!r}"
     #@+node:ekr.20080923070954.4: *4* at.scanAllDirectives
     def scanAllDirectives(self, p: Position) -> dict[str, Any]:
         """
@@ -3039,38 +3060,44 @@ class FastAtRead:
 
     def get_patterns(self, comment_delims: Any) -> None:
         """Create regex patterns for the given comment delims."""
+        
         # This must be a function, because of @comments & @delims.
         comment_delim_start, comment_delim_end = comment_delims
-        delim1 = re.escape(comment_delim_start)
-        delim2 = re.escape(comment_delim_end or '')
+            
+        # Make no assumption about comment delims.
+        delim1 = re.escape(comment_delim_start.strip())
+        delim2 = re.escape(comment_delim_end.strip() or '')
         ref = g.angleBrackets(r'(.*)')
         table = (
-            # Note: Do *not* alter these regexes to handle black-compatible sentinels!
-            #       delim1 *already* contains the trailing blank!
+            # Leo 6.7.6. These patterns match both legacy and blackened sentinels.
+            #            During git merges a mix of sentinels is passible!
 
             # These patterns must be mutually exclusive.
-            ('after',       fr'^\s*{delim1}@afterref{delim2}$'),             # @afterref
-            ('all',         fr'^(\s*){delim1}@(\+|-)all\b(.*){delim2}$'),    # @all
-            ('code',        fr'^\s*{delim1}@@c(ode)?{delim2}$'),             # @c and @code
-            ('comment',     fr'^\s*{delim1}@@comment(.*){delim2}'),          # @comment
-            ('delims',      fr'^\s*{delim1}@delims(.*){delim2}'),            # @delims
-            ('doc',         fr'^\s*{delim1}@\+(at|doc)?(\s.*?)?{delim2}\n'), # @doc or @
-            ('first',       fr'^\s*{delim1}@@first{delim2}$'),               # @first
-            ('last',        fr'^\s*{delim1}@@last{delim2}$'),                # @last
+            ('after',       fr'^\s*{delim1} *@afterref{delim2}$'),             # @afterref
+            ('all',         fr'^(\s*){delim1} *@(\+|-)all\b(.*){delim2}$'),    # @all
+            ('code',        fr'^\s*{delim1} *@@c(ode)?\b(.*){delim2}$'),       # @c and @code
+            ('comment',     fr'^\s*{delim1} *@@comment(.*){delim2}'),          # @comment
+            ('delims',      fr'^\s*{delim1} *@delims(.*){delim2}'),            # @delims
+            ('doc',         fr'^\s*{delim1} *@\+(at|doc)?(\s.*?)?{delim2}\n'), # @doc or @
+            ('first',       fr'^\s*{delim1} *@@first{delim2}$'),               # @first
+            ('last',        fr'^\s*{delim1} *@@last{delim2}$'),                # @last
             #@verbatim
             # @node
-            ('node_start',  fr'^(\s*){delim1}@\+node:([^:]+): \*(\d+)?(\*?) (.*){delim2}$'),
-            ('others',      fr'^(\s*){delim1}@(\+|-)others\b(.*){delim2}$'), # @others
-            ('ref',         fr'^(\s*){delim1}@(\+|-){ref}\s*{delim2}$'),     # section ref
+            ('node_start',  fr'^(\s*){delim1} *@\+node:([^:]+): \*(\d+)?(\*?) (.*){delim2}$'),
+            ('others',      fr'^(\s*){delim1} *@(\+|-)others\b(.*){delim2}$'), # @others
+            ('ref',         fr'^(\s*){delim1} *@(\+|-){ref}\s*{delim2}$'),     # section ref
             #@verbatim
             # @section-delims
-            ('section_delims', fr'^\s*{delim1}@@section-delims[ \t]+([^ \w\n\t]+)[ \t]+([^ \w\n\t]+)[ \t]*{delim2}$'),
+            ('section_delims', fr'^\s*{delim1} *@@section-delims[ \t]+([^ \w\n\t]+)[ \t]+([^ \w\n\t]+)[ \t]*{delim2}$'),
         )
         # Set the ivars.
+        self.pattern_ivars: list[str] = []  # For debugging only.
         for (name, pattern) in table:
             ivar = f"{name}_pat"
+            self.pattern_ivars.append(ivar)
             assert hasattr(self, ivar), ivar
             setattr(self, ivar, re.compile(pattern))
+            
     #@+node:ekr.20180602103135.2: *3* fast_at.scan_header
     header_pattern = re.compile(
         r'''
@@ -3086,9 +3113,8 @@ class FastAtRead:
         """
         Scan for the header line, which follows any @first lines.
         Return (delims, first_lines, i+1) or None
-
-        Important: delims[0] will end with a blank when reading a file with blackened sentinels!
-                   This fact eliminates all special cases in scan_lines!
+        
+        *Note*: delims[0] will end with a blank when reading a file with blackened sentinels!
         """
         first_lines: list[str] = []
         i = 0  # To keep some versions of pylint happy.
@@ -3111,10 +3137,12 @@ class FastAtRead:
         # The start/end *comment* delims.
         # Important: scan_header ends comment_delim1 with a blank when using black sentinels.
         comment_delim1, comment_delim2 = comment_delims
+        comment_delim1 = comment_delim1.strip()
+        comment_delim2 = comment_delim2.strip()
         doc_skip = (comment_delim1 + '\n', comment_delim2 + '\n')  # To handle doc parts.
         first_i = 0  # Index into first array.
         in_doc = False  # True: in @doc parts.
-        is_cweb = comment_delim1 == '@q@' and comment_delim2 == '@>'  # True: cweb hack in effect.
+        is_cweb = comment_delim1.strip() == '@q@' and comment_delim2 == '@>'  # True: cweb hack in effect.
         indent = 0  # The current indentation.
         level_stack: list[tuple[VNode, VNode]] = []
         n_last_lines = 0  # The number of @@last directives seen.
@@ -3125,9 +3153,13 @@ class FastAtRead:
         section_delim2 = '>>'
         section_reference_seen = False
         sentinel = comment_delim1 + '@'
+        blackened_sentinel = comment_delim1 + ' @'
+        any_sentinel = (sentinel, blackened_sentinel)
         # The stack is updated when at+others, at+<section>, or at+all is seen.
         stack: list[tuple[str, int, str]] = []  # Entries are (gnx, indent, body)
         verbatim_line = comment_delim1 + '@verbatim' + comment_delim2
+        blackened_verbatim_line = comment_delim1 + ' @verbatim' + comment_delim2
+        verbatim_lines = (verbatim_line, blackened_verbatim_line)
         verbatim = False  # True: the next line must be added without change.
         #
         # Init the parent vnode.
@@ -3152,6 +3184,20 @@ class FastAtRead:
         #@-<< init scan_lines >>
         i = 0  # To keep pylint happy.
         for i, line in enumerate(lines[start:]):
+            #@+<< trace patterns >>
+            #@+node:ekr.20231205061620.1: *4* << trace patterns >>
+            # Keep this excellent trace, but comment it out for production code.
+            # if g.unitTesting:
+                # for ivar in self.pattern_ivars:
+                    # pattern = getattr(self, ivar)
+                    # assert isinstance(pattern, re.Pattern)
+                    # m = pattern.match(line)
+                    # if m:
+                        # print(f"{i:2} {ivar:15} {line!r}")
+                        # break
+                # else:
+                    # print(f"{i:2} {' ':15} {line!r}")
+            #@-<< trace patterns >>
             # Strip the line only once.
             strip_line = line.strip()
             if afterref:
@@ -3171,13 +3217,13 @@ class FastAtRead:
 
                 # 2022/12/02: Bug fix: adjust indentation.
                 if indent and line[:indent].isspace() and len(line) > indent:
-                    line = line[indent:]
+                    line = line[indent:]  # pragma: no cover (defensive?)
 
                 body.append(line)
                 verbatim = False
                 #@-<< handle verbatim line >>
                 continue
-            if strip_line == verbatim_line:  # <delim>@verbatim
+            if strip_line in verbatim_lines:
                 verbatim = True
                 continue
             #@+<< finalize line >>
@@ -3189,7 +3235,8 @@ class FastAtRead:
             if indent and line[:indent].isspace() and len(line) > indent:
                 line = line[indent:]
             #@-<< finalize line >>
-            if not in_doc and not strip_line.startswith(sentinel):  # Faster than a regex!
+            # Faster than a regex!
+            if not in_doc and not strip_line.startswith(any_sentinel):
                 body.append(line)
                 continue
             # These three sections might clear in_doc.
@@ -3310,8 +3357,11 @@ class FastAtRead:
                 #
                 # #1496: Retire the @doc convention.
                 #        An empty line is no longer a sentinel.
+
+                assert in_doc, repr(line)
+
                 if comment_delim2 and line in doc_skip:
-                    # doc_skip is (comment_delim1 + '\n', delim_end + '\n')
+                    # skip start/end comment delim!
                     continue
                 #
                 # Check for @c or @code.
@@ -3324,6 +3374,8 @@ class FastAtRead:
             else:
                 #@+<< handle @ or @doc >>
                 #@+node:ekr.20211031033754.1: *4* << handle @ or @doc >>
+                assert not in_doc, repr(line)
+
                 m = self.doc_pat.match(line)
                 if m:
                     #@verbatim
@@ -3338,7 +3390,7 @@ class FastAtRead:
                     in_doc = True
                     continue
                 #@-<< handle @ or @doc >>
-            if line.startswith(comment_delim1 + '@-leo'):  # Faster than a regex!
+            if line.startswith((comment_delim1 + '@-leo', comment_delim1 + ' @-leo')):  # Faster than a regex!
                 # The @-leo sentinel adds *nothing* to the text.
                 i += 1
                 break
@@ -3403,7 +3455,7 @@ class FastAtRead:
                 if delim1:
                     comment_delim1, comment_delim2 = delim1, ''
                 else:
-                    comment_delim1, comment_delim2 = delim2, delim3
+                    comment_delim1, comment_delim2 = delim2, delim3  # pragma: no cover (unimportant)
                 #
                 # Within these delimiters:
                 # - double underscores represent a newline.
@@ -3414,6 +3466,8 @@ class FastAtRead:
                 doc_skip = (comment_delim1 + '\n', comment_delim2 + '\n')
                 is_cweb = comment_delim1 == '@q@' and comment_delim2 == '@>'
                 sentinel = comment_delim1 + '@'
+                blackened_sentinel = comment_delim1 + ' @'
+                any_sentinel = (sentinel, blackened_sentinel)
                 #
                 # Recalculate the patterns.
                 comment_delims = comment_delim1, comment_delim2
@@ -3445,8 +3499,10 @@ class FastAtRead:
                 comment_delim2 = comment_delim2.replace('__', '\n').replace('_', ' ')
                 # Recalculate all delim-related values
                 doc_skip = (comment_delim1 + '\n', comment_delim2 + '\n')
-                is_cweb = comment_delim1 == '@q@' and comment_delim2 == '@>'
+                is_cweb = comment_delim1 in ('@q@', ' @q@') and comment_delim2 == '@>'
                 sentinel = comment_delim1 + '@'
+                blackened_sentinel = comment_delim1 + ' @'
+                any_sentinel = (sentinel, blackened_sentinel)
                 #
                 # Recalculate the patterns
                 comment_delims = comment_delim1, comment_delim2
@@ -3465,7 +3521,7 @@ class FastAtRead:
                     # Carefully update the section reference pattern!
                     section_delim1 = d1 = re.escape(m.group(1))
                     section_delim2 = d2 = re.escape(m.group(2) or '')
-                    self.ref_pat = re.compile(fr'^(\s*){comment_delim1}@(\+|-){d1}(.*){d2}\s*{comment_delim2}$')
+                    self.ref_pat = re.compile(fr'^(\s*){comment_delim1} *@(\+|-){d1}(.*){d2}\s*{comment_delim2}$')
                 body.append(f"@section-delims {m.group(1)} {m.group(2)}\n")
                 continue
             #@-<< handle @section-delims >>
@@ -3475,8 +3531,10 @@ class FastAtRead:
             #@verbatim
             # @first, @last, @delims and @comment generate @@ sentinels,
             # So this must follow all of those.
-            if line.startswith(comment_delim1 + '@@'):
+            if line.startswith((comment_delim1 + '@@', comment_delim1 + ' @@')):
                 ii = len(comment_delim1) + 1  # on second '@'
+                if line.startswith(comment_delim1 + ' @@'):
+                    ii += 1
                 jj = line.rfind(comment_delim2) if comment_delim2 else -1
                 body.append(line[ii:jj] + '\n')
                 continue
@@ -3484,14 +3542,20 @@ class FastAtRead:
             if in_doc:
                 #@+<< handle remaining @doc lines >>
                 #@+node:ekr.20180606054325.1: *4* << handle remaining @doc lines >>
+                assert in_doc, repr(line)
+
                 if comment_delim2:
-                    # doc lines are unchanged.
+                    # Inner doc lines have no comment delims.
                     body.append(line)
                     continue
 
+                # Legacy:
                 #    with --black-sentinels: comment_delim1 ends with a blank.
                 # without --black-sentinels: comment_delim1 does *not* end with a blank.
 
+                # Leo 6.7.6:
+                #   comment_delim never ends with a blank.
+
                 tail = line.lstrip()[len(comment_delim1.rstrip()) + 1 :]
                 if tail.strip():
                     body.append(tail)
@@ -3507,7 +3571,7 @@ class FastAtRead:
             if 1:  # pragma: no cover (defensive)
 
                 # This assert verifies the short-circuit test.
-                assert strip_line.startswith(sentinel), repr(line)
+                assert strip_line.startswith(any_sentinel), repr(line)
 
                 # Defensive: make *sure* we ignore verbatim lines.
                 if strip_line == verbatim_line:
diff --git a/leo/plugins/qt_gui.py b/leo/plugins/qt_gui.py
index 7d831c2a6..7f1373404 100644
--- a/leo/plugins/qt_gui.py
+++ b/leo/plugins/qt_gui.py
@@ -506,7 +506,7 @@ class LeoQtGui(leoGui.LeoGui):
         dialog.raise_()
         ok = dialog.exec_()
         return str(dialog.textValue()) if ok else None
-    #@+node:ekr.20110605121601.18495: *4* qt_gui.runAskOkDialog
+    #@+node:ekr.20110605121601.18495: *4* qt_gui.runAskOkDialog (changed)
     def runAskOkDialog(self, c: Cmdr, title: str, message: str = None, text: str = "Ok") -> None:
         """Create and run a qt askOK dialog ."""
         if g.unitTesting:


Test patch:
diff --git a/leo/unittests/commands/test_gotoCommands.py b/leo/unittests/commands/test_gotoCommands.py
index 4bb3fd95d..d11e821d2 100644
--- a/leo/unittests/commands/test_gotoCommands.py
+++ b/leo/unittests/commands/test_gotoCommands.py
@@ -5,6 +5,7 @@ from leo.core import leoGlobals as g
 from leo.core.leoTest2 import LeoUnitTest
 from leo.commands.gotoCommands import GoToCommands
 assert g
+from leo.core.leoNodes import Position
 
 #@+others
 #@+node:ekr.20230802060212.2: ** class TestGotoCommands(LeoUnitTest)
@@ -17,121 +18,141 @@ class TestGotoCommands(LeoUnitTest):
 
         c = self.c
         x = GoToCommands(c)
+        
+        clean_contents: list[str]
+        real_clean_contents: list[str]
+        root: Position
+        
+        #@+others  # Create helpers
+        #@+node:ekr.20230804093924.1: *4* function: create_test_tree
+        def create_test_tree() -> Position:
+
+            self.clean_tree()
+            self.create_test_paste_outline()
+
+            # Demote all of the root's headlines!
+            c.demote()
+
+            # Add body text to each node.
+            root = c.rootPosition()
+            assert root.h == 'root'
+            root.h = '@clean test.py'  # Make the root an @clean node.
+            for v in c.all_unique_nodes():
+                for i in range(2):
+                    v.b += f"{v.h} line {i}\n"
+            root.b = (
+                '@language python\n'
+                'before\n'
+                '@others\n'
+                'after\n'
+            )
+            return root
+        #@+node:ekr.20230804093956.1: *4* function: init_unchanging_data
+        def init_unchanging_data() -> None:
+            
+            nonlocal clean_contents, real_clean_contents
+
+            # Init the comment delims.
+            delim1, delim2 = x.get_delims(root)
+            assert(delim1, delim2) == ('#', None)
+            delims = x.get_3_delims(root)
+
+            # Create contents, the file *with* sentinels.
+            contents_s = x.get_external_file_with_sentinels(root)
+            contents = g.splitLines(contents_s)
+
+            # Create real_clean_contents, the contents produced by atCleanToString.
+            real_clean_s = c.atFileCommands.atCleanToString(root)
+            real_clean_contents = g.splitLines(real_clean_s)
+
+            # Create clean_contents, contents without invisible sentinels
+            # converting visible sentinels to their corresponding line in the outline.
+            clean_contents = [
+                z.replace('#@', '').replace('+others', '@others')
+                for i, z in enumerate(contents)
+                if not g.is_invisible_sentinel(delims, contents, i)
+            ]
+            
+            # Test 0: A strong test of g.is_invisible_sentinel.
+            self.assertEqual(clean_contents, real_clean_contents)
+            
+        #@+node:ekr.20230804094419.1: *4* test1
+        def test1() -> None:
+
+            # test the helper for show-file-line
+            for node_i, p in enumerate(c.all_positions()):
+                offset = x.find_node_start(p) - 1
+                line = clean_contents[offset]
+                if p.h.startswith('@clean'):
+                    if offset == 0:
+                        assert line == 'before\n', (offset, repr(p.h), repr(line))
+                        assert g.splitLines(p.b)[0] == '@language python\n', repr(p.b[0])
+                else:
+                    # print(f"{p.h:10} {offset:3} {line}")
+                    assert p.h in line, (offset, repr(p.h), repr(line))
+        #@+node:ekr.20230804094514.1: *4* test2
+        def test2() -> None:
+
+            # test the helper for goto-global-line.
+            for i, clean_line in enumerate(clean_contents):
+                p, offset = x.find_file_line(i)
+                assert offset is not None, repr(p)
+                assert offset > 0, (offset, repr(p))
+                line = g.splitLines(p.b)[offset - 1]
+
+                # g.trace(f"{i:>2} {offset} {p.h:20} {line!r}")
+                if p.h.startswith('@clean'):
+                    if offset == 1:
+                        # We are testing p.b, not clean_line.
+                        assert line == '@language python\n', (offset, repr(p.h), repr(line))
+                else:
+                    assert p.h in line, (offset, repr(p.h), repr(line))
+        #@+node:ekr.20230804105414.1: *4* test3
+        def test3() -> None:
+            # test show-file-line & goto-global-line directly >>
+
+            global_i = 0  # Global line index.
+            for p in c.all_positions():
+
+                # Init and test lines.
+                lines = g.splitLines(p.b)
+                if p != root:
+                    for i, line in enumerate(lines):
+                        # print(f"{global_i:2} {i} {p.h:30} {line!r}")
+                        assert p.h in line, (p.h, line)
+
+                # Test show-file-line.
+                show_offset = x.find_node_start(p)
+                assert show_offset not in (None, -1), (show_offset, p.h)
+
+                # Test goto-global-line.
+                goto_p, goto_offset = x.find_file_line(global_i)
+                assert p.h == goto_p.h, (p, goto_p)
+                assert goto_offset == 1, repr(p)
+
+                # Update global_i.
+                if p == root:
+                    # Count `before`, `@others`, but *not* `@language python`.
+                    global_i += 2
+                else:
+                    global_i += len(lines)
+        #@-others
 
         # All body lines are unique, which simplifies the tests below.
-        #@+<< create test tree >>
-        #@+node:ekr.20230804093924.1: *4* << create test tree >>
-        self.clean_tree()
-        self.create_test_paste_outline()
-
-        # Demote all of the root's headlines!
-        c.demote()
-
-        # Add body text to each node.
-        root = c.rootPosition()
-        assert root.h == 'root'
-        root.h = '@clean test.py'  # Make the root an @clean node.
-        for v in c.all_unique_nodes():
-            for i in range(2):
-                v.b += f"{v.h} line {i}\n"
-        root.b = (
-            '@language python\n'
-            'before\n'
-            '@others\n'
-            'after\n'
-        )
-        #@-<< create test tree >>
+        root = create_test_tree()
 
         # self.dump_headlines(c)
         # self.dump_clone_info(c)
 
-        #@+<< init unchanging data >>
-        #@+node:ekr.20230804093956.1: *4* << init unchanging data >>
-        # Init the comment delims.
-        delim1, delim2 = x.get_delims(root)
-        assert(delim1, delim2) == ('#', None)
-        delims = x.get_3_delims(root)
-
-        # Create contents, the file *with* sentinels.
-        contents_s = x.get_external_file_with_sentinels(root)
-        contents = g.splitLines(contents_s)
-
-        # Create real_clean_contents, the contents produced by atCleanToString.
-        real_clean_s = c.atFileCommands.atCleanToString(root)
-        real_clean_contents = g.splitLines(real_clean_s)
-
-        # Create clean_contents, contents without invisible sentinels
-        # converting visible sentinels to their corresponding line in the outline.
-        clean_contents = [
-            z.replace('#@', '').replace('+others', '@others')
-            for i, z in enumerate(contents)
-            if not g.is_invisible_sentinel(delims, contents, i)
-        ]
-        #@-<< init unchanging data >>
+        init_unchanging_data()
 
         # g.printObj(real_clean_contents, tag='atCleanToString')
         # g.printObj(contents, tag='With sentinels')
         # g.printObj(clean_contents, tag='No sentinels')
 
-        # Test 1: A strong test of g.is_invisible_sentinel.
-        self.assertEqual(real_clean_contents, clean_contents)
-        #@+<< Test 2: test the helper for show-file-line >>
-        #@+node:ekr.20230804094419.1: *4* << Test 2: test the helper for show-file-line >>
-        for node_i, p in enumerate(c.all_positions()):
-            offset = x.find_node_start(p) - 1
-            assert offset is not None, p.h
-            line = clean_contents[offset]
-            if p.h.startswith('@clean'):
-                if offset == 0:
-                    assert line == 'before\n', (offset, repr(p.h), repr(line))
-                    assert g.splitLines(p.b)[0] == '@language python\n', repr(p.b[0])
-            else:
-                # print(f"{p.h:10} {offset:3} {line}")
-                assert p.h in line, (offset, repr(p.h), repr(line))
-        #@-<< Test 2: test the helper for show-file-line >>
-        #@+<< Test 3: test the helper for goto-global-line >>
-        #@+node:ekr.20230804094514.1: *4* << Test 3: test the helper for goto-global-line >>
-        for i, clean_line in enumerate(clean_contents):
-            p, offset = x.find_file_line(i)
-            assert offset is not None, repr(p)
-            assert offset > 0, (offset, repr(p))
-            line = g.splitLines(p.b)[offset - 1]
-            # g.trace(f"{i:>2} {offset} {p.h:20} {line!r}")
-            if p.h.startswith('@clean'):
-                if offset == 1:
-                    # We are testing p.b, not clean_line.
-                    assert line == '@language python\n', (offset, repr(p.h), repr(line))
-            else:
-                assert p.h in line, (offset, repr(p.h), repr(line))
-        #@-<< Test 3: test the helper for goto-global-line >>
-        #@+<< Test 4: test show-file-line & goto-global-line directly >>
-        #@+node:ekr.20230804105414.1: *4* << Test 4: test show-file-line & goto-global-line directly >>
-        global_i = 0  # Global line index.
-        for p in c.all_positions():
-
-            # Init and test lines.
-            lines = g.splitLines(p.b)
-            if p != root:
-                for i, line in enumerate(lines):
-                    # print(f"{global_i:2} {i} {p.h:30} {line!r}")
-                    assert p.h in line, (p.h, line)
-
-            # Test show-file-line.
-            show_offset = x.find_node_start(p)
-            assert show_offset not in (None, -1), (show_offset, p.h)
-
-            # Test goto-global-line.
-            goto_p, goto_offset = x.find_file_line(global_i)
-            assert p.h == goto_p.h, (p, goto_p)
-            assert goto_offset == 1, repr(p)
-
-            # Update global_i.
-            if p == root:
-                # Count `before`, `@others`, but *not* `@language python`.
-                global_i += 2
-            else:
-                global_i += len(lines)
-        #@-<< Test 4: test show-file-line & goto-global-line directly >>
+        test1()
+        test2()
+        test3()
     #@-others
 #@-others
 #@-leo
diff --git a/leo/unittests/core/test_leoAtFile.py b/leo/unittests/core/test_leoAtFile.py
index 2604a2930..438f5e969 100644
--- a/leo/unittests/core/test_leoAtFile.py
+++ b/leo/unittests/core/test_leoAtFile.py
@@ -256,7 +256,7 @@ class TestAtFile(LeoUnitTest):
         contents = textwrap.dedent('''\
             ATall
         ''').replace('AT', '@')
-        expected = textwrap.dedent('''\
+        expected_contents = textwrap.dedent('''\
             #AT+all
             #AT+node:<GNX>: ** child
             def spam():
@@ -265,11 +265,24 @@ class TestAtFile(LeoUnitTest):
             @ A single-line doc part.
             #AT-all
         ''').replace('AT', '@')
-        root.b = contents
-        at.initWriteIvars(root)
-        at.putBody(root)
-        result = ''.join(at.outputList)
-        self.assertEqual(result, expected)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = expected_contents.replace('#@', '# @') if blacken else expected_contents
+
+            root.b = test_s 
+            at.initWriteIvars(root)
+            at.putBody(root)
+            results = ''.join(at.outputList)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
     #@+node:ekr.20211102111413.1: *3* TestAtFile.test_putBody_at_all_after_at_doc
     def test_putBody_at_all_after_at_doc(self):
 
@@ -281,16 +294,33 @@ class TestAtFile(LeoUnitTest):
             doc line 1
             ATall
         ''').replace('AT', '@')
-        expected = textwrap.dedent('''\
+
+        # Only @c or @code end an @doc part.
+        # Therefore, the @all line is part of the @doc part.
+
+        expected_contents = textwrap.dedent('''\
             #AT+doc
             # doc line 1
             # ATall
         ''').replace('AT', '@')
-        root.b = contents
-        at.initWriteIvars(root)
-        at.putBody(root)
-        result = ''.join(at.outputList)
-        self.assertEqual(result, expected)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = expected_contents.replace('#@', '# @') if blacken else expected_contents
+        
+            root.b = test_s 
+            at.initWriteIvars(root)
+            at.putBody(root)
+            results = ''.join(at.outputList)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
     #@+node:ekr.20211102150707.1: *3* TestAtFile.test_putBody_at_others
     def test_putBody_at_others(self):
 
@@ -304,18 +334,38 @@ class TestAtFile(LeoUnitTest):
         contents = textwrap.dedent('''\
             ATothers
         ''').replace('AT', '@')
-        expected = textwrap.dedent('''\
+        expected_contents = textwrap.dedent('''\
             #AT+others
             #AT+node:<GNX>: ** child
             #AT+others
             #AT-others
             #AT-others
         ''').replace('AT', '@')
-        root.b = contents
-        at.initWriteIvars(root)
-        at.putBody(root)
-        result = ''.join(at.outputList)
-        self.assertEqual(result, expected)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = expected_contents.replace('#@', '# @') if blacken else expected_contents
+
+            root.b = test_s 
+            at.initWriteIvars(root)
+            at.putBody(root)
+            results = ''.join(at.outputList)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+        
+        
+        # root.b = contents
+        # at.initWriteIvars(root)
+        # at.putBody(root)
+        # result = ''.join(at.outputList)
+        # self.assertEqual(result, expected)
     #@+node:ekr.20211102102024.1: *3* TestAtFile.test_putBody_unterminated_at_doc_part
     def test_putBody_unterminated_at_doc_part(self):
 
@@ -549,7 +599,8 @@ class TestFastAtRead(LeoUnitTest):
         #@+<< define contents >>
         #@+node:ekr.20211106112233.1: *4* << define contents >>
         # Be careful: no line should look like a Leo sentinel!
-        contents = textwrap.dedent(f'''\
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
             #AT+leo-ver=5-thin
             #AT+node:{root.gnx}: * {h}
             #AT@language python
@@ -564,23 +615,28 @@ class TestFastAtRead(LeoUnitTest):
              ):
                 a = 2
             #AT-leo
-        ''').replace('AT', '@').replace('LB', '<<')
+        ''').lstrip()
+        contents = contents.replace('AT', '@').replace('LB', '<<')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
         #@-<< define contents >>
         #@+<< define expected_body >>
         #@+node:ekr.20211106115654.1: *4* << define expected_body >>
-        expected_body = textwrap.dedent('''\
+        # Be careful: no line should look like a Leo sentinel!
+        # Use neither a raw string nor an f-string here.
+        expected_body = textwrap.dedent('''
             ATlanguage python
 
             a = 1
             if (
             LB test >> ):
                 a = 2
-        ''').replace('AT', '@').replace('LB', '<<')
+        ''').lstrip().replace('AT', '@').replace('LB', '<<')
         #@-<< define expected_body >>
         #@+<< define expected_contents >>
         #@+node:ekr.20211107053133.1: *4* << define expected_contents >>
         # Be careful: no line should look like a Leo sentinel!
-        expected_contents = textwrap.dedent(f'''\
+        # Use neither a raw string nor an f-string here.
+        expected_contents = textwrap.dedent('''
             #AT+leo-ver=5-thin
             #AT+node:{root.gnx}: * {h}
             #AT@language python
@@ -590,14 +646,27 @@ class TestFastAtRead(LeoUnitTest):
             LB test >> ):
                 a = 2
             #AT-leo
-        ''').replace('AT', '@').replace('LB', '<<')
+        ''').lstrip().replace('AT', '@').replace('LB', '<<')
+        expected_contents = expected_contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
         #@-<< define expected_contents >>
-        x.read_into_root(contents, path='test', root=root)
-        self.assertEqual(root.b, expected_body, msg='mismatch in body')
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        # Leo has *never* round-tripped the contents without change!
-        self.assertEqual(s, expected_contents, msg='mismatch in contents')
-
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = expected_contents.replace('#@', '# @') if blacken else expected_contents
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+            
+            self.assertEqual(root.b, expected_body, msg='mismatch in body')
     #@+node:ekr.20211103093332.1: *3* TestFastAtRead.test_at_all
     def test_at_all(self):
 
@@ -608,7 +677,8 @@ class TestFastAtRead(LeoUnitTest):
         #@+<< define contents >>
         #@+node:ekr.20211103093424.1: *4* << define contents >> (test_at_all)
         # Be careful: no line should look like a Leo sentinel!
-        contents = textwrap.dedent(f'''\
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
         #AT+leo-ver=5-thin
         #AT+node:{root.gnx}: * {h}
         # This is Leo's final resting place for dead code.
@@ -623,17 +693,31 @@ class TestFastAtRead(LeoUnitTest):
         LB missing reference >>
         #AT+node:ekr.20211103093633.1: ** node 2
         #ATverbatim
-        # ATothers doesn't matter
-
+        #ATothers doesn't generate anything.
         ATothers
         #AT-all
         #AT@nosearch
         #AT-leo
-        ''').replace('AT', '@').replace('LB', '<<')
+        ''').lstrip()
+        contents = contents.replace('AT', '@').replace('LB', '<<')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
         #@-<< define contents >>
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = test_s.replace("# @others doesn't", "#@others doesn't")
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
     #@+node:ekr.20211101085019.1: *3* TestFastAtRead.test_at_comment (and @first)
     def test_at_comment(self):
 
@@ -644,7 +728,8 @@ class TestFastAtRead(LeoUnitTest):
         #@+<< define contents >>
         #@+node:ekr.20211101090447.1: *4* << define contents >> (test_at_comment)
         # Be careful: no line should look like a Leo sentinel!
-        contents = textwrap.dedent(f'''\
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
         !!! -*- coding: utf-8 -*-
         !!!AT+leo-ver=5-thin
         !!!AT+node:{root.gnx}: * {h}
@@ -671,21 +756,37 @@ class TestFastAtRead(LeoUnitTest):
 
         !!!AT@language plain
         !!!AT-leo
-        ''').replace('AT', '@').replace('LB', '<<')
+        ''').lstrip()
+        contents = contents.replace('AT', '@').replace('LB', '<<')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
         #@-<< define contents >>
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
-        child1 = root.firstChild()
-        child2 = child1.next()
-        child3 = child2.next()
-        table = (
-            (child1, g.angleBrackets(' test ')),
-            (child2, 'spam'),
-            (child3, 'eggs'),
-        )
-        for child, h in table:
-            self.assertEqual(child.h, h)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents
+            expected = test_s
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+
+            child1 = root.firstChild()
+            child2 = child1.next()
+            child3 = child2.next()
+            table = (
+                (child1, g.angleBrackets(' test ')),
+                (child2, 'spam'),
+                (child3, 'eggs'),
+            )
+            for child, h in table:
+                self.assertEqual(child.h, h)
     #@+node:ekr.20211101111636.1: *3* TestFastAtRead.test_at_delims
     def test_at_delims(self):
         c, x = self.c, self.x
@@ -695,8 +796,9 @@ class TestFastAtRead(LeoUnitTest):
         #@+<< define contents >>
         #@+node:ekr.20211101111652.1: *4* << define contents >> (test_at_delims)
         # Be careful: no line should look like a Leo sentinel!
-        contents = textwrap.dedent(f'''\
-        !! -*- coding: utf-8 -*-
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
+        # -*- coding: utf-8 -*-
         #AT+leo-ver=5-thin
         #AT+node:{root.gnx}: * {h}
         #AT@first
@@ -720,21 +822,38 @@ class TestFastAtRead(LeoUnitTest):
 
         !!AT@language python
         !!AT-leo
-        ''').replace('AT', '@').replace('LB', '<<').replace('SPACE', ' ')
+        ''').lstrip()
+        contents = contents.replace('AT', '@').replace('LB', '<<').replace('SPACE', ' ')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+
         #@-<< define contents >>
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
-        child1 = root.firstChild()
-        child2 = child1.next()
-        child3 = child2.next()
-        table = (
-            (child1, g.angleBrackets(' test ')),
-            (child2, 'spam'),
-            (child3, 'eggs'),
-        )
-        for child, h in table:
-            self.assertEqual(child.h, h)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @').replace('!!@', '!! @') if blacken else contents
+            expected = test_s
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+
+            child1 = root.firstChild()
+            child2 = child1.next()
+            child3 = child2.next()
+            table = (
+                (child1, g.angleBrackets(' test ')),
+                (child2, 'spam'),
+                (child3, 'eggs'),
+            )
+            for child, h in table:
+                self.assertEqual(child.h, h)
     #@+node:ekr.20211103095616.1: *3* TestFastAtRead.test_at_last
     def test_at_last(self):
 
@@ -745,7 +864,8 @@ class TestFastAtRead(LeoUnitTest):
         #@+<< define contents >>
         #@+node:ekr.20211103095959.1: *4* << define contents >> (test_at_last)
         # Be careful: no line should look like a Leo sentinel!
-        contents = textwrap.dedent(f'''\
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
         #AT+leo-ver=5-thin
         #AT+node:{root.gnx}: * {h}
         # Test of ATlast
@@ -758,21 +878,41 @@ class TestFastAtRead(LeoUnitTest):
         #AT@last
         #AT-leo
         # last line
-        ''').replace('AT', '@')
+        ''').lstrip()
+        contents = contents.replace('AT', '@')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+
+
         #@-<< define contents >>
         #@+<< define expected_body >>
         #@+node:ekr.20211104052937.1: *4* << define expected_body >> (test_at_last)
-        expected_body = textwrap.dedent('''\
-        # Test of ATlast
+        # Use neither a raw string nor an f-string here.
+        # Be careful: no line should look like a Leo sentinel!
+        expected_body = textwrap.dedent('''
+        # Test of @last
         ATothers
         ATlanguage python
         ATlast # last line
-        ''').replace('AT', '@')
+        ''').lstrip().replace('AT', '@')
         #@-<< define expected_body >>
-        x.read_into_root(contents, path='test', root=root)
-        self.assertEqual(root.b, expected_body)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = test_s
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+
+            self.assertEqual(root.b, expected_body)
     #@+node:ekr.20211103092228.1: *3* TestFastAtRead.test_at_others
     def test_at_others(self):
 
@@ -798,59 +938,91 @@ class TestFastAtRead(LeoUnitTest):
         #AT-leo
         ''').replace('AT', '@').replace('LB', '<<')
         #@-<< define contents >>
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = test_s
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+
     #@+node:ekr.20211031093209.1: *3* TestFastAtRead.test_at_section_delim
     def test_at_section_delim(self):
 
+        # Test the contents of personal test file, slightly altered.
+
         c, x = self.c, self.x
         h = '@file /test/at_section_delim.py'
         root = c.rootPosition()
         root.h = h  # To match contents.
         #@+<< define contents >>
         #@+node:ekr.20211101050923.1: *4* << define contents >> (test_at_section_delim)
-        # The contents of a personal test file, slightly altered.
-        contents = textwrap.dedent(f'''\
-        #AT+leo-ver=5-thin
-        #AT+node:{root.gnx}: * {h}
+        # Be careful: no line should look like a Leo sentinel!
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
+        AT+leo-ver=5-thin
+        AT+node:{root.gnx}: * {h}
 
         """Classes to read and write @file nodes."""
 
-        #AT@section-delims <!< >!>
+        AT@section-delims <!< >!>
 
-        #AT+<!< test >!>
-        #AT+node:ekr.20211029054238.1: ** <!< test >!>
+        AT+<!< test >!>
+        AT+node:ekr.20211029054238.1: ** <!< test >!>
         print('in test section')
         print('done')
-        #AT-<!< test >!>
+        AT-<!< test >!>
 
-        #AT+others
-        #AT+node:ekr.20211030052810.1: ** spam
+        AT+others
+        AT+node:ekr.20211030052810.1: ** spam
         def spam():
         pass
-        #AT+node:ekr.20211030053502.1: ** eggs
+        AT+node:ekr.20211030053502.1: ** eggs
         def eggs():
         pass
-        #AT-others
+        AT-others
 
-        #AT@language python
-        #AT-leo
-        ''').replace('#AT', '#@')
+        AT@language python
+        AT-leo
+        ''').lstrip().replace('AT', '#@')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
         #@-<< define contents >>
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
-        child1 = root.firstChild()
-        child2 = child1.next()
-        child3 = child2.next()
-        table = (
-            (child1, '<!< test >!>'),
-            (child2, 'spam'),
-            (child3, 'eggs'),
-        )
-        for child, h in table:
-            self.assertEqual(child.h, h)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = test_s
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+
+            child1 = root.firstChild()
+            child2 = child1.next()
+            child3 = child2.next()
+            table = (
+                (child1, '<!< test >!>'),
+                (child2, 'spam'),
+                (child3, 'eggs'),
+            )
+            for child, h in table:
+                self.assertEqual(child.h, h)
     #@+node:ekr.20211101155930.1: *3* TestFastAtRead.test_clones
     def test_clones(self):
 
@@ -881,26 +1053,40 @@ class TestFastAtRead(LeoUnitTest):
         #AT-leo
         ''').replace('AT', '@').replace('LB', '<<')
         #@-<< define contents >>
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
-        child1 = root.firstChild()
-        child2 = child1.next()
-        grand_child1 = child1.firstChild()
-        grand_child2 = child2.firstChild()
-        table = (
-            (child1, 'cloned node'),
-            (child2, 'cloned node'),
-            (grand_child1, 'child'),
-            (grand_child2, 'child'),
-        )
-        for child, h in table:
-            self.assertEqual(child.h, h)
-        self.assertTrue(child1.isCloned())
-        self.assertTrue(child2.isCloned())
-        self.assertEqual(child1.v, child2.v)
-        self.assertFalse(grand_child1.isCloned())
-        self.assertFalse(grand_child2.isCloned())
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = test_s
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+
+            child1 = root.firstChild()
+            child2 = child1.next()
+            grand_child1 = child1.firstChild()
+            grand_child2 = child2.firstChild()
+            table = (
+                (child1, 'cloned node'),
+                (child2, 'cloned node'),
+                (grand_child1, 'child'),
+                (grand_child2, 'child'),
+            )
+            for child, h in table:
+                self.assertEqual(child.h, h)
+            self.assertTrue(child1.isCloned())
+            self.assertTrue(child2.isCloned())
+            self.assertEqual(child1.v, child2.v)
+            self.assertFalse(grand_child1.isCloned())
+            self.assertFalse(grand_child2.isCloned())
     #@+node:ekr.20211103080718.1: *3* TestFastAtRead.test_cweb
     #@@language python
 
@@ -912,44 +1098,59 @@ class TestFastAtRead(LeoUnitTest):
         root.h = h  # To match contents.
         #@+<< define contents >>
         #@+node:ekr.20211103080718.2: *4* << define contents >> (test_cweb)
-        # pylint: disable=anomalous-backslash-in-string
-        contents = textwrap.dedent(f'''\
-            ATq@@+leo-ver=5-thin@>
-            ATq@@+node:{root.gnx}: * @{h}@>
-            ATq@@@@language cweb@>
-            ATq@@@@comment @@q@@ @@>@>
-
-            % This is limbo in cweb mode... It should be in BSLaTeX mode, not BSc mode.
-            % The following should not be colorized: class,if,else.
-
-            @* this is a _cweb_ comment.  Code is written in BSc.
-            "strings" should not be colorized.
-            It should be colored in BSLaTeX mode.
-            The following are not keywords in latex mode: if, else, etc.
-            Section references are _valid_ in cweb comments!
-            ATq@@+LB section ref 1 >>@>
-            ATq@@+node:ekr.20211103082104.1: ** LB section ref 1 >>@>
-            This is section 1.
-            ATq@@-LB section ref 1 >>@>
-            @c
-
-            and this is C code. // It is colored in BSLaTeX mode by default.
-            /* This is a C block comment.  It may also be colored in restricted BSLaTeX mode. */
-
-            // Section refs are valid in code too, of course.
-            ATq@@+LB section ref 2 >>@>
-            ATq@@+node:ekr.20211103083538.1: ** LB section ref 2 >>@>
-            This is section 2.
-            ATq@@-LB section ref 2 >>@>
-
-            BSLaTeX and BSc should not be colored.
-            if else, while, do // C keywords.
-            ATq@@-leo@>
-        ''').replace('AT', '@').replace('LB', '<<').replace('BS', '\\')
+        # Lines must not look like Leo sentinels!
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
+        ATq@@+leo-ver=5-thin@>
+        ATq@@+node:{root.gnx}: * @{h}@>
+        ATq@@@@language cweb@>
+
+        % This is limbo in cweb mode... It should be in BSLaTeX mode, not BSc mode.
+        % The following should not be colorized: class,if,else.
+
+        @* this is a _cweb_ comment.  Code is written in BSc.
+        "strings" should not be colorized.
+        It should be colored in BSLaTeX mode.
+        The following are not keywords in latex mode: if, else, etc.
+        Section references are _valid_ in cweb comments!
+        ATq@@+LB section ref 1 >>@>
+        ATq@@+node:ekr.20211103082104.1: ** LB section ref 1 >>@>
+        This is section 1.
+        ATq@@-LB section ref 1 >>@>
+        ATc
+
+        and this is C code. // It is colored in BSLaTeX mode by default.
+        /* This is a C block comment.  It may also be colored in restricted BSLaTeX mode. */
+
+        // Section refs are valid in code too, of course.
+        ATq@@+LB section ref 2 >>@>
+        ATq@@+node:ekr.20211103083538.1: ** LB section ref 2 >>@>
+        This is section 2.
+        ATq@@-LB section ref 2 >>@>
+
+        BSLaTeX and BSc should not be colored.
+        if else, while, do // C keywords.
+        ATq@@-leo@>
+        ''').lstrip()
+        contents = contents.replace('AT', '@').replace('LB', '<<')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
         #@-<< define contents >>
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = test_s
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
     #@+node:ekr.20211101152817.1: *3* TestFastAtRead.test_doc_parts
     def test_doc_parts(self):
 
@@ -957,44 +1158,133 @@ class TestFastAtRead(LeoUnitTest):
         h = '@file /test/test_directives.py'
         root = c.rootPosition()
         root.h = h  # To match contents.
-
         #@+<< define contents >>
         #@+node:ekr.20211101152843.1: *4* << define contents >> (test_doc_parts)
         # Be careful: no line should look like a Leo sentinel!
-        contents = textwrap.dedent(f'''\
-        #AT+leo-ver=5-thin
-        #AT+node:{root.gnx}: * {h}
-        #AT@language python
+        # Use neither a raw string nor an f-string here.
+        # The doc part should contain at least one blank line.
+        contents = textwrap.dedent('''
+            #AT+leo-ver=5-thin
+            #AT+node:{root.gnx}: * {h}
+            #AT@language python
 
-        a = 1
+            a = 1
 
-        #AT+at A doc part
-        # Line 2.
-        #AT@c
+            #AT+at A doc part
+            # Line 2.
+            #AT@c
 
-        #AT+doc
-        # Line 2
-        #
-        # Line 3
-        #AT@c
+            #AT+doc
+            # Line 2
+            #
+            # Line 3
+            #AT@c
 
-        #AT-leo
-        ''').replace('AT', '@').replace('LB', '<<')
+            #AT-leo
+        ''').lstrip().replace('AT', '@').replace('LB', '<<')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
         #@-<< define contents >>
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = test_s
+            
+            x.read_into_root(test_s, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+           
+            self.assertEqual(results, expected)
+    #@+node:ekr.20231207083858.1: *3* TestFastAtRead.test_doc_parts_html
+    def test_doc_parts_html(self):
 
-        # Test 1: without black delims.
-        g.app.write_black_sentinels = False
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s, msg='Test 1')
+        c, x = self.c, self.x
+        h = '@file /test/test_directives.html'
+        root = c.rootPosition()
+        root.h = h  # To match contents.
+        #@+<< define contents >>
+        #@+node:ekr.20231207083858.2: *4* << define contents >> (test_doc_parts_html)
+        # Be careful: no line should look like a Leo sentinel!
+        # Use neither a raw string nor an f-string here.
 
-        # Test 2: with black delims.
-        g.app.write_black_sentinels = True
-        contents = contents.replace('#@', '# @')
-        x.read_into_root(contents, path='test', root=root)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        # g.printObj(contents2, tag='contents2')
-        self.assertEqual(contents, s, 'Test 2: -b')
+        # The doc part should contain at least one blank line.
+        contents = textwrap.dedent('''
+            <!--AT+leo-ver=5-thin-->
+            <!--AT+node:{root.gnx}: * {h}-->
+            <!--AT@language html-->
+
+            a = 1
+
+            <!--AT+at A doc part-->
+            <!--
+            Line 2.
+            -->
+            <!--AT@c-->
+
+            <!--AT+doc-->
+            <!--
+            Line 3.
+
+            Line 4.
+            -->
+            <!--AT@c-->
+
+            <!--AT-leo
+        ''').lstrip().replace('AT', '@').replace('LB', '<<')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+        #@-<< define contents >>
+        #@+<< define expected_contents >>
+        #@+node:ekr.20231207121222.1: *4* << define expected_contents >> (test_doc_parts_html)
+        # For languages with a trailing delim,
+        #@verbatim
+        # @doc parts contain extra lines for the start/end delims.
+        expected_contents = textwrap.dedent('''
+            <!--AT+leo-ver=5-thin-->
+            <!--AT+node:{root.gnx}: * {h}-->
+            <!--AT@language html-->
+
+            a = 1
+
+            <!--AT+at A doc part-->
+            <!--
+            Line 2.
+            -->
+            <!--AT@c-->
+
+            <!--AT+doc-->
+            <!--
+            Line 3.
+
+            Line 4.
+            -->
+            <!--AT@c-->
+
+            <!--AT-leo-->
+        ''').lstrip().replace('AT', '@').replace('LB', '<<')
+        expected_contents = expected_contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+        #@-<< define expected_contents >>
+
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            
+            # --black-sentinels applies only to python files.
+            test_s = contents
+            expected = expected_contents
+            
+            x.read_into_root(test_s, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+           
+            self.assertEqual(results, expected)
     #@+node:ekr.20211101154632.1: *3* TestFastAtRead.test_html_doc_part
     def test_html_doc_part(self):
 
@@ -1002,14 +1292,32 @@ class TestFastAtRead(LeoUnitTest):
         h = '@file /test/test_html_doc_part.py'
         root = c.rootPosition()
         root.h = h  # To match contents.
+
         #@+<< define contents >>
         #@+node:ekr.20211101154651.1: *4* << define contents >> (test_html_doc_part)
         # Be careful: no line should look like a Leo sentinel!
-        contents = textwrap.dedent(f'''\
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
         <!--AT+leo-ver=5-thin-->
         <!--AT+node:{root.gnx}: * {h}-->
         <!--AT@language html-->
+        <!--AT+at-->
+        Line 1.
 
+        Line 2.
+        <!--AT@c-->
+        <!--AT-leo-->
+        ''').lstrip().replace('AT', '@').replace('LB', '<<')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+        #@-<< define contents >>
+        #@+<< define expected >>
+        #@+node:ekr.20231204050205.1: *4* << define expected >> (test_html_doc_part)
+        # Be careful: no line should look like a Leo sentinel!
+        # Use neither a raw string nor an f-string here.
+        expected = textwrap.dedent('''
+        <!--AT+leo-ver=5-thin-->
+        <!--AT+node:{root.gnx}: * {h}-->
+        <!--AT@language html-->
         <!--AT+at-->
         <!--
         Line 1.
@@ -1018,11 +1326,168 @@ class TestFastAtRead(LeoUnitTest):
         -->
         <!--AT@c-->
         <!--AT-leo-->
-        ''').replace('AT', '@').replace('LB', '<<')
-        #@-<< define contents >>
+        ''').lstrip().replace('AT', '@')
+        expected = expected.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+        #@-<< define expected >>
+
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = expected.replace('#@', '# @') if blacken else expected
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+        
         x.read_into_root(contents, path='test', root=root)
         s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
+        # g.printObj(g.splitLines(s), tag='s')
+        
+        self.assertEqual(s, expected)
+    #@+node:ekr.20231203092436.1: *3* TestFastAtRead.test_minimal_cweb
+    #@@language python
+
+    def test_minimal_cweb(self):
+
+        c, x = self.c, self.x
+        h = '@file /test/test_cweb.w'
+        root = c.rootPosition()
+        root.h = h  # To match contents.
+        #@+<< define contents >>
+        #@+node:ekr.20231203092436.2: *4* << define contents >> (test_cweb)
+        # Be careful: no line should look like a Leo sentinel!
+        # Use a raw string so pyflakes won't complain abouit \document.
+        contents = textwrap.dedent(r'''
+        ATq@@+leo-ver=5-thin@>
+        ATq@@+node:{root.gnx}: * @{h}@>
+        ATq@@@@language cweb@>
+        % $Id: minimal.w,v 1.4 1995/08/25 19:12:41 schrod Exp $
+        %----------------------------------------------------------------------
+
+        % tests minimal CWEB w/ LaTeX input file
+
+        \documentclass{cweb}
+        \begin{document}
+
+        Test.
+
+        AT
+        \end{document}
+        ATq@@-leo@>
+        ''').lstrip()
+        contents = contents.replace('AT', '@').replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+        #@-<< define contents >>
+        
+        for blacken in (True, False):
+
+            g.app.write_black_sentinels = blacken
+            test_s = contents.replace('#@', '# @') if blacken else contents
+            expected = test_s
+
+            x.read_into_root(contents, path='test', root=root)
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+            
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+    #@+node:ekr.20231204095225.1: *3* TestFastAtRead.test_mixed_sentinels
+    def test_mixed_sentinels(self):
+
+        c, x = self.c, self.x
+        h = '@file /test/test_mixed_sentinels.py'
+        root = c.rootPosition()
+        root.h = h  # To match contents.
+        #@+<< define contents >>
+        #@+node:ekr.20231204100121.2: *4* << define contents >>
+        # Be careful: no line should look like a Leo sentinel!
+        # Use neither a raw string nor an f-string here.
+        contents = textwrap.dedent('''
+            #AT+leo-ver=5-thin
+            # AT+node:{root.gnx}: * {h}
+            #AT@language python
+
+            a = 1
+            if (
+            #AT+LB test >>
+            #AT+node:ekr.20211107051401.1: ** LB test >>
+            a == 2
+            # AT-LB test >>
+            #ATafterref
+             ):
+                a = 2
+            #AT-leo
+        ''').lstrip()
+        contents = contents.replace('AT', '@').replace('LB', '<<')
+        contents = contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+        #@-<< define contents >>
+        #@+<< define expected_body >>
+        #@+node:ekr.20231204100121.3: *4* << define expected_body >>
+        # Be careful: no line should look like a Leo sentinel!
+        # Use neither a raw string nor an f-string here.
+        expected_body = textwrap.dedent('''
+            ATlanguage python
+
+            a = 1
+            if (
+            LB test >> ):
+                a = 2
+        ''').lstrip().replace('AT', '@').replace('LB', '<<')
+        #@-<< define expected_body >>
+        #@+<< define expected_contents >>
+        #@+node:ekr.20231204100121.4: *4* << define expected_contents >>
+        # Be careful: no line should look like a Leo sentinel!
+        # Use neither a raw string nor an f-string here.
+        expected_contents = textwrap.dedent('''
+            #AT+leo-ver=5-thin
+            #AT+node:{root.gnx}: * {h}
+            #AT@language python
+
+            a = 1
+            if (
+            LB test >> ):
+                a = 2
+            #AT-leo
+        ''').lstrip().replace('AT', ' @').replace('LB', '<<')
+        expected_contents = expected_contents.replace('{root.gnx}', root.gnx).replace('{h}', root.h)
+        #@-<< define expected_contents >>
+        
+        for blacken in (True, False):
+            g.app.write_black_sentinels = blacken
+            test_s = contents
+            if blacken:
+                # All sentinels should be blackened.
+                expected = expected_contents.replace('#@', '# @')
+            else:
+                # No sentinels should be blackened.
+                expected = expected_contents.replace('# @', '#@')
+
+            x.read_into_root(test_s, path='test', root=root)
+
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected, msg='mismatch in contents')
+
+            if root.b != expected_body:
+                g.printObj(g.splitLines(contents), tag='contents')
+                g.printObj(g.splitLines(root.b), tag='root.b')
+                g.printObj(g.splitLines(expected_body), tag='expected_body')
+
+            self.assertEqual(root.b, expected_body, msg='mismatch in body')
     #@+node:ekr.20211101180354.1: *3* TestFastAtRead.test_verbatim
     def test_verbatim(self):
 
@@ -1040,7 +1505,7 @@ class TestFastAtRead(LeoUnitTest):
             # Test of @verbatim
             print('hi')
             #ATverbatim
-            #AT+node (should be protected by verbatim)
+            #AT+node (verbatim)
             #AT-leo
         ''').replace('AT', '@') # .replace('LB', '<<')
         #@-<< define contents >>
@@ -1050,26 +1515,97 @@ class TestFastAtRead(LeoUnitTest):
         ATlanguage python
         # Test of @verbatim
         print('hi')
-        #AT+node (should be protected by verbatim)
+        #AT+node (verbatim)
         ''').replace('AT', '@')
         #@-<< define expected_body >>
+        for blacken in (True, False):
+            g.app.write_black_sentinels = blacken
+            test_s = expected = contents
 
-        # Test 1: without black delims.
-        g.app.write_black_sentinels = False
-        x.read_into_root(contents, path='test', root=root)
-        self.assertEqual(root.b, expected_body)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
+            if blacken:
+                # All true sentinels should be blackened.
+                expected = test_s.replace('#@', '# @')
+                # Protect the @verbatim line.
+                expected = expected.replace('# @+node (verbatim)', '#@+node (verbatim)')
 
-        # Test 2: with black delims.
-        g.app.write_black_sentinels = True
-        contents = contents.replace('#@', '# @')
-        expected_body = expected_body.replace('#@', '# @')
-        x.read_into_root(contents, path='test', root=root)
-        self.assertEqual(root.b, expected_body)
-        s = c.atFileCommands.atFileToString(root, sentinels=True)
-        self.assertEqual(contents, s)
+            x.read_into_root(test_s, path='test', root=root)
+            
+            if root.b != expected_body:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(root.b), tag='root.b')
+                g.printObj(g.splitLines(expected_body), tag='expected_body')
+
+            self.assertEqual(root.b, expected_body)
+
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
+
+            self.assertEqual(results, expected)
+    #@+node:ekr.20231207080536.1: *3* TestFastAtRead.test_verbatim_html
+    def test_verbatim_html(self):
+
+        c, x = self.c, self.x
+        h = '@file /test/test_verbatim.html'
+        root = c.rootPosition()
+        root.h = h  # To match contents.
+        #@+<< define contents >>
+        #@+node:ekr.20231207080536.2: *4* << define contents >> (test_verbatim_html)
+        # Be careful: no line should look like a Leo sentinel!
+        contents = textwrap.dedent(f'''\
+            <!--AT+leo-ver=5-thin-->
+            <!--AT+node:{root.gnx}: * {h}-->
+            <!--AT@language html-->
+            <!-- Test of @verbatim-->
+            print('hi')
+            <!--ATverbatim-->
+            <!--AT+node (verbatim)-->
+                <!--ATverbatim-->
+                <!--AT+node (verbatim)-->
+            <!--AT-leo-->
+        ''').replace('AT', '@') # .replace('LB', '<<')
+        #@-<< define contents >>
+        #@+<< define expected_body >>
+        #@+node:ekr.20231207080536.3: *4* << define expected_body >> (test_verbatim_html)
+        expected_body = textwrap.dedent('''\
+        ATlanguage html
+        <!-- Test of @verbatim-->
+        print('hi')
+        <!--AT+node (verbatim)-->
+            <!--AT+node (verbatim)-->
+        ''').replace('AT', '@')
+        #@-<< define expected_body >>
+
+        for blacken in (True, False):
+            g.app.write_black_sentinels = blacken
+            test_s = expected = contents
+
+            if blacken:
+                # All true sentinels should be blackened.
+                expected = test_s.replace('#@', '# @')
+                # Protect the @verbatim line.
+                expected = expected.replace('<!-- @+node (verbatim)', '<!--@+node (verbatim)')
+
+            x.read_into_root(test_s, path='test', root=root)
+            
+            if root.b != expected_body:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(root.b), tag='root.b')
+                g.printObj(g.splitLines(expected_body), tag='expected_body')
+
+            self.assertEqual(root.b, expected_body)
+
+            results = c.atFileCommands.atFileToString(root, sentinels=True)
+
+            if results != expected:
+                g.printObj(g.splitLines(test_s), tag='test_s')
+                g.printObj(g.splitLines(results), tag='results')
+                g.printObj(g.splitLines(expected), tag='expected')
 
+            self.assertEqual(results, expected)
     #@-others
 #@-others
 #@-leo
diff --git a/leo/unittests/test_importers.py b/leo/unittests/test_importers.py
index c59630358..bd9218272 100644
--- a/leo/unittests/test_importers.py
+++ b/leo/unittests/test_importers.py
@@ -27,7 +27,6 @@ class BaseTestImporter(LeoUnitTest):
     def setUp(self):
         super().setUp()
         g.app.loadManager.createAllImporterData()
-        g.app.write_black_sentinels = False
 
     #@+others
     #@+node:ekr.20230526135305.1: *3* BaseTestImporter.check_outline