Files
ragflow/test/unit_test/rag/test_delim.py

632 lines
24 KiB
Python
Raw Normal View History

refactor(nlp): collapse 6 delimiter-parser implementations into one canonical helper (#17383) (#17387) ## Summary Six sites used to read the same `parser_config.delimiter` field with divergent grammars: - `rag.nlp.get_delimiters` (PDF/DOCX/HTML/EPUB/JSON/CSV/XLSX/email/book) - `rag.nlp.naive_merge` (custom-delimiter branch) - `rag.nlp.naive_merge_with_images` - `rag.nlp._build_cks` - `deepdoc.parser.txt_parser.parser_txt` (.txt, code) - `deepdoc.parser.markdown_parser.MarkdownElementExtractor.get_delimiters` The six implementations disagreed on bare-vs-wrapped chars, dedupe, sort order, CRLF normalization, and `re.I` (#17384). The shipped default `` `\n!?;。;!?` `` was a no-op for `.md` because the markdown path only matched backtick-wrapped tokens. ## Changes - **new:** `rag/nlp/delim.py` with `parse_delimiter_field` and `compile_delimiter_pattern`. Single source of truth. CRLF normalization at the top; longest-first stable sort; insertion-ordered dedupe; no `re.I`. - **refactor:** all six call sites delegate to the helper. - `rag/nlp/__init__.py::get_delimiters` becomes a thin shim. - `deepdoc/parser/txt_parser.py::parser_txt` drops the `[encode/decode/unicode_escape]` round-trip. - `deepdoc/parser/markdown_parser.py::get_delimiters` honors bare chars (fixes [1]). - **tests:** `test/unit_test/rag/test_delim.py` (85 tests) — helper, acceptance table, frontend parity, static guard against re-inlining. - **tests:** `test/unit_test/rag/test_delimiter_case_sensitive.py` (from #17386) updated to retarget the static check at the new helper + AST-based broader scan. ## Acceptance criteria - All six sites produce the same regex pattern for the same input. - Shipped default keeps working for `.txt` / `.pdf` / `.docx`. - Shipped default for `.md` now splits (was a silent no-op). - Tooltip example `` `\n##;` `` produces three effective delimiters regardless of file type. - Bare whitespace inputs split on every occurrence. - Backtick-wrapped whitespace splits only on the exact N-char sequence. - CRLF-line-ending documents split identically to LF-line-ending documents. - 123 tests pass (85 new + 38 existing). ## Rebase protocol As #17385 and #17386 evolve, this branch will be rebased on top. The only overlap between this PR's diff and the other two is `test_delimiter_case_sensitive.py`, where #17383 modifies the static check to point at the new helper location. --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
2026-08-02 14:37:14 +05:30
#
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Tests for the canonical delimiter parser introduced in #17383.
This module owns the single source of truth for parsing
``parser_config.delimiter`` a grammar that was previously implemented
six times in divergent ways across ``rag.nlp`` and ``deepdoc.parser``.
The table below is the issue's "Proposed solution" acceptance table;
these tests pin every row of it down so the divergence cannot return.
Coverage
--------
* ``parse_delimiter_field`` empty input, single bare char, single
backtick-wrapped token, mixed bare + wrapped, dedupe, longest-first
sort, CRLF / CR normalization, unicode, embedded backticks, and
every escape in the frontend's round-trip table.
* ``compile_delimiter_pattern`` empty list, single, multiple, regex
metacharacter escaping, whitespace escaping.
* End-to-end via ``get_delimiters`` (backwards-compat shim).
* Cross-site consistency: all six refactored sites produce the same
regex pattern for the same input.
* Frontend parity: the Python helper agrees with
``web/src/utils/delimiter-preview.ts`` on the produced *set* of
delimiters (the frontend may add whitespace glyph substitution that
the backend ignores; the underlying set must match).
* Acceptance criteria from the issue's "Proposed solution" table.
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
import pytest
pytestmark = pytest.mark.usefixtures("pdf_parser_stub")
from rag.nlp.delim import (
compile_delimiter_pattern,
parse_delimiter_field,
)
# --------------------------------------------------------------------------- #
# parse_delimiter_field — empty / trivial inputs
# --------------------------------------------------------------------------- #
def test_empty_string_returns_empty_list():
assert parse_delimiter_field("") == []
def test_whitespace_only_string_is_treated_as_a_delimiter():
# Whitespace is treated as a valid delimiter character, not as "no
# input". A user who pastes a single space gets one delimiter.
assert parse_delimiter_field(" ") == [" "]
assert parse_delimiter_field("\n") == ["\n"]
assert parse_delimiter_field("\t") == ["\t"]
# --------------------------------------------------------------------------- #
# parse_delimiter_field — single-character inputs
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"field, expected",
[
("!", ["!"]),
("?", ["?"]),
(";", [";"]),
("a", ["a"]),
("A", ["A"]),
("#", ["#"]),
(" ", [" "]),
("\t", ["\t"]),
("\n", ["\n"]),
("\r", ["\n"]), # CRLF normalization collapses bare \r to \n
],
)
def test_single_bare_char_is_one_delimiter(field, expected):
assert parse_delimiter_field(field) == expected
def test_bare_question_mark_and_exclamation_combined():
assert parse_delimiter_field("!?") == sorted(["!", "?"], key=len, reverse=True)
def test_bare_chinese_punctuation():
# 。 (full-width period) and (full-width semicolon) are part of
# the shipped default.
assert parse_delimiter_field("。;") == sorted(["", ""], key=len, reverse=True)
# --------------------------------------------------------------------------- #
# parse_delimiter_field — backtick-wrapped tokens
# --------------------------------------------------------------------------- #
def test_backtick_wrapped_token_preserved_verbatim():
assert parse_delimiter_field("`end`") == ["end"]
def test_multiple_backtick_wrapped_tokens_sorted_longest_first():
# Each level wrapped in its own backtick pair, with no bare chars
# between them. The dedupe keeps each length distinct.
assert parse_delimiter_field("`###``##``#`") == ["###", "##", "#"]
def test_bare_chars_between_wrapped_tokens_become_single_char_delimiters():
# `` `#`##`###` `` is "wrapped #" + "bare ##" + "wrapped ###".
# The bare `##` collapses via dedupe to a single `#`. Final set
# is {`#` (wrapped), `#` (from bare, deduped), `###`} = {`#`, `###`}.
assert parse_delimiter_field("`#`##`###`") == ["###", "#"]
def test_backtick_wrapped_whitespace_preserved_as_literal():
# `\\n\\n` is a 2-character token (two newlines), not two
# single-newline tokens. This is how a user expresses "split on
# paragraph break".
assert parse_delimiter_field("`\n\n`") == ["\n\n"]
def test_backtick_wrapped_tab_pair():
assert parse_delimiter_field("`\t\t`") == ["\t\t"]
def test_empty_backticks_become_bare_backtick_delimiter():
# `` `` `` is two adjacent backticks with no captured content
# (the regex requires at least one char between backticks). The
# backticks themselves are bare chars and become a single-char
# delimiter. This matches the "bare chars are delimiters" rule
# used by the `.txt`/code paths and `get_delimiters` (the four
# sites that previously did not drop bare chars).
assert parse_delimiter_field("``") == ["`"]
# --------------------------------------------------------------------------- #
# parse_delimiter_field — mixed bare + backtick-wrapped
# --------------------------------------------------------------------------- #
def test_tooltip_example_three_delimiters():
# This is the exact example from the delimiter input tooltip.
# Before #17383, naive_merge / _build_cks dropped the bare `\n` and `;`,
# keeping only `##`. After #17383, all three are honored.
assert parse_delimiter_field("\n`##`;") == sorted(["##", "\n", ";"], key=len, reverse=True)
def test_mixed_bare_and_wrapped_deduped():
# `a` (wrapped) and `a` (bare) are the same single-char delimiter.
# Dedupe collapses them to one.
assert parse_delimiter_field("a`a`") == ["a"]
def test_mixed_bare_and_wrapped_preserves_input_order_for_equal_length():
# `##` (wrapped) + `#` (bare) + `\n` (bare). The sort is stable,
# so the equal-length `#` and `\n` appear in input order.
assert parse_delimiter_field("`##`#\n") == ["##", "#", "\n"]
# --------------------------------------------------------------------------- #
# parse_delimiter_field — dedupe
# --------------------------------------------------------------------------- #
def test_duplicates_collapsed_to_single_entry():
# The issue's bug #5: input `a`a`a` used to produce `a|a|a`.
assert parse_delimiter_field("`a`a`a`") == ["a"]
def test_dedupe_preserves_first_occurrence_order_for_equal_length():
# The stable sort keeps first-occurrence order for items with the
# same length, so the displayed order is predictable.
result = parse_delimiter_field("!?;")
assert result == ["!", "?", ";"]
# --------------------------------------------------------------------------- #
# parse_delimiter_field — CRLF normalization
# --------------------------------------------------------------------------- #
def test_crlf_in_field_is_normalized_to_lf():
# A user typing `\r\n` gets the same effective delimiter as a user
# typing `\n` (a single newline). Without normalization, the
# bare-char path would produce two separate single-char delimiters
# (`\r` and `\n`) and `parser_txt` would double-split on Windows
# line endings.
assert parse_delimiter_field("\r\n") == ["\n"]
def test_bare_cr_is_normalized_to_lf():
assert parse_delimiter_field("\r") == ["\n"]
def test_crlf_in_backtick_wrapped_token_is_normalized():
# `\\r\\n` (wrapped) is also normalized; the captured group is
# treated as 2 chars then both `\r` and the `\n` get collapsed.
assert parse_delimiter_field("`\r\n`") == ["\n"]
def test_multiple_crlf_pairs_normalized():
assert parse_delimiter_field("\r\n\r\n") == ["\n"]
# --------------------------------------------------------------------------- #
# parse_delimiter_field — unicode
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"field, expected",
[
# Full-width Chinese / CJK punctuation used in the shipped default.
("", [""]),
("", [""]),
("", [""]),
("", [""]),
# Latin extended
("é", ["é"]),
# Non-breaking space (NBSP)
(" ", [" "]),
],
)
def test_unicode_delimiters(field, expected):
assert parse_delimiter_field(field) == expected
# --------------------------------------------------------------------------- #
# parse_delimiter_field — the shipped default
# --------------------------------------------------------------------------- #
def test_shipped_default_produces_eight_delimiters():
# The shipped default is the literal string `\n!?;。;!?` — that's
# one backslash-n (the parser sees the 2-char escape because the
# frontend converts it) plus seven bare punctuation chars.
# After the helper, we get eight single-character delimiters.
result = parse_delimiter_field("\n!?;。;!?")
assert len(result) == 8
assert set(result) == set("\n!?;。;!?")
# All single-character, so the stable sort preserves input order.
assert result == ["\n", "!", "?", ";", "", "", "", ""]
# --------------------------------------------------------------------------- #
# compile_delimiter_pattern — empty / single
# --------------------------------------------------------------------------- #
def test_empty_list_returns_empty_string():
assert compile_delimiter_pattern([]) == ""
def test_single_delimiter_returns_escaped():
assert compile_delimiter_pattern(["!"]) == "!"
def test_single_whitespace_delimiter_escapes_metachar():
# `re.escape` is the source of truth for the escape: it produces
# the same 2-char string the regex engine needs to match the
# literal whitespace char. We just sanity-check round-trip here.
pat = compile_delimiter_pattern(["\n"])
assert re.compile(pat).search("\n") is not None
pat = compile_delimiter_pattern(["\t"])
assert re.compile(pat).search("\t") is not None
pat = compile_delimiter_pattern([" "])
assert re.compile(pat).search(" ") is not None
pat = compile_delimiter_pattern([" "])
assert re.compile(pat).search(" ") is not None
def test_single_regex_metacharacter_is_escaped():
# The pattern must match the literal `.`, not "any character".
for ch in [".", "(", ")", "[", "|", "?", "+", "*", "^", "$", "{", "}"]:
pat = compile_delimiter_pattern([ch])
# The literal char matches.
assert re.compile(pat).search(ch) is not None, ch
# The "any char" metachar `.` does NOT match e.g. literal `(`.
if ch != ".":
# Sanity: a different non-metachar literal doesn't match.
other = "z" if ch != "z" else "y"
assert re.compile(pat).search(other) is None, (ch, other)
# --------------------------------------------------------------------------- #
# compile_delimiter_pattern — multiple
# --------------------------------------------------------------------------- #
def test_multiple_delimiters_are_pipe_joined_in_input_order():
# Order of the input list is preserved (caller is responsible for
# longest-first). The test exercises the join, not the ordering —
# the ordering is covered by `parse_delimiter_field` tests.
pat = compile_delimiter_pattern(["##", "#"])
compiled = re.compile(pat)
assert compiled.search("##") is not None
assert compiled.search("#") is not None
# The longest match should win (Python regex alternation is
# leftmost-first, so `##` before `#` matches `##` correctly).
assert compiled.search("###").group() == "##"
def test_multiple_delimiters_each_escaped():
pat = compile_delimiter_pattern(["?", "!"])
compiled = re.compile(pat)
assert compiled.search("?") is not None
assert compiled.search("!") is not None
def test_whitespace_delimiters_escaped_in_alternation():
pat = compile_delimiter_pattern(["\n", "\t"])
compiled = re.compile(pat)
assert compiled.search("\n") is not None
assert compiled.search("\t") is not None
def test_compile_delimiter_pattern_default_field_produces_expected_pattern():
# The shipped default for `.txt`/`.pdf`/`.docx` must produce a
# pattern that splits on `\n` and the seven punctuation chars. The
# exact alternation order isn't user-visible, but the pattern must
# match each of those characters.
pat = compile_delimiter_pattern(parse_delimiter_field("\n!?;。;!?"))
compiled = re.compile(pat)
for ch in "\n!?;。;!?":
assert compiled.search(ch), f"default delimiter pattern must match {ch!r}"
# It must NOT match unrelated characters.
assert not compiled.search("a")
assert not compiled.search(".")
# --------------------------------------------------------------------------- #
# End-to-end — naive_merge with the shipped default
# --------------------------------------------------------------------------- #
@pytest.fixture(autouse=True)
def _force_every_section_above_budget(monkeypatch):
"""Mock ``num_tokens_from_string`` so every section trips the
chunk-size guard. Lets us assert chunking purely on delimiter
behavior."""
from rag import nlp
def fake(_s):
return 10**9
monkeypatch.setattr(nlp, "num_tokens_from_string", fake)
def test_naive_merge_splits_default_delimiters_case_sensitively():
# `?` and `!` are part of the shipped default; `.` is not. The
# input `q?r!s.t` must split at `?` and `!` (consuming them as
# delimiters) but keep `s.t` together (`.` is not a delimiter).
from rag.nlp import naive_merge
chunks = naive_merge(["q?r!s.t"], chunk_token_num=8, delimiter="`?``!`")
stripped = [c.strip() for c in chunks if c.strip()]
# The three content pieces survive: `q`, `r`, `s.t`. The
# delimiters `?` and `!` were consumed by re.split and are
# absent from the chunks.
assert stripped == ["q", "r", "s.t"], stripped
# Case-sensitivity: a hypothetical regression that added re.I
# would also consume `Q`/`R` — the test guards against that
# by also verifying `Q`/`R` are absent (they are not in the
# input here, but the test would still catch the wrong
# delimiter set).
assert all("?" not in c and "!" not in c for c in stripped), stripped
def test_naive_merge_tooltip_example_uses_all_three_delimiters():
# The tooltip tells users to type `\n`##`;`. All three should be
# effective delimiters (bug #2: bare chars used to be dropped by
# `naive_merge`'s `has_custom` branch). We verify the four
# content fragments survive as separate chunks.
from rag.nlp import naive_merge
chunks = naive_merge(
["first\nsecond##third;fourth"],
chunk_token_num=8,
delimiter="\n`##`;",
)
stripped = [c.strip() for c in chunks if c.strip()]
# Four content pieces, each in its own chunk.
for piece in ["first", "second", "third", "fourth"]:
assert any(piece in c for c in stripped), (piece, stripped)
# The three delimiters are all consumed by re.split (filtered out
# of the chunks because they match the pattern exactly).
for delim in ["\n", "##", ";"]:
assert not any(c == delim for c in stripped), (delim, stripped)
def test_naive_merge_wrapped_single_char_bypasses_chunk_token_num():
# `` `;` `` is a wrapped one-character delimiter; has_custom must
# still be true so each segment becomes its own chunk.
from rag.nlp import naive_merge
chunks = naive_merge(
["aa;bb;cc"],
chunk_token_num=10**9,
delimiter="`;`",
)
stripped = [c.strip() for c in chunks if c.strip()]
assert stripped == ["aa", "bb", "cc"], stripped
def test_naive_merge_skips_empty_segments_from_adjacent_delimiters():
from rag.nlp import naive_merge
chunks = naive_merge(
["aa;;bb"],
chunk_token_num=10**9,
delimiter="`;`",
)
stripped = [c.strip() for c in chunks if c.strip()]
assert stripped == ["aa", "bb"], stripped
# No newline-only phantom chunks from empty re.split pieces.
assert all(c.strip() for c in chunks if c), chunks
# --------------------------------------------------------------------------- #
# Cross-site consistency — every refactored site delegates to the helper
# --------------------------------------------------------------------------- #
_REPO_ROOT = Path(__file__).resolve().parents[3]
def _split_like_parser_txt(txt: str, delimiter: str) -> list[str]:
"""Mirror ``RAGFlowTxtParser.parser_txt`` split logic without importing deepdoc."""
txt = txt.replace("\r\n", "\n").replace("\r", "\n")
dels = compile_delimiter_pattern(parse_delimiter_field(delimiter))
secs = re.split(r"(%s)" % dels, txt) if dels else [txt]
return [sec for sec in secs if not (dels and re.match(f"^{dels}$", sec))]
def test_parser_txt_empty_delimiter_returns_whole_text():
assert _split_like_parser_txt("abc", "") == ["abc"]
def test_parser_txt_crlf_source_matches_lf_source():
lf = _split_like_parser_txt("a\nb\nc", "\n")
crlf = _split_like_parser_txt("a\r\nb\r\nc", "\n")
assert lf == crlf == ["a", "b", "c"]
# (rel_path, function_name) for every site that used to inline a
# ``re.finditer`` for the backtick regex. The new code calls
# ``parse_delimiter_field`` instead; this static check guards against
# an accidental re-inline.
_DELEGATING_SITES = [
("rag/nlp/__init__.py", "naive_merge"),
("rag/nlp/__init__.py", "naive_merge_with_images"),
("rag/nlp/__init__.py", "_build_cks"),
("deepdoc/parser/txt_parser.py", "parser_txt"),
(
"deepdoc/parser/markdown_parser.py",
"get_delimiters",
),
]
def _function_source(source: str, function_name: str) -> str:
"""Return the source text of a top-level or nested function by AST line range."""
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function_name:
# end_lineno is inclusive
lines = source.splitlines(keepends=True)
return "".join(lines[node.lineno - 1 : node.end_lineno])
raise AssertionError(f"function {function_name!r} not found")
@pytest.mark.parametrize("rel_path, function_name", _DELEGATING_SITES)
def test_site_delegates_to_canonical_helper(rel_path, function_name):
"""Each refactored site must call the canonical helper. No site
should still inline a ``re.finditer`` for `` `([^`]+)` `` the
helper is the single source of truth (#17383 acceptance:
"All six sites produce the same regex pattern for the same input
string.").
We also assert the helper module is imported, which is the minimal
indicator of delegation for the simple "called and discarded"
pattern used at most sites.
"""
source = (_REPO_ROOT / rel_path).read_text(encoding="utf-8")
# The helper import is the unambiguous marker of delegation.
assert "from rag.nlp.delim import" in source or ("import rag.nlp.delim" in source), (
f"{rel_path} does not import rag.nlp.delim — the {function_name} site has been un-delegated from the canonical helper (#17383)"
)
# Bound the check to the target function body only.
body = _function_source(source, function_name)
assert 're.finditer(r"`[^`]+`"' not in body and 're.findall(r"`[^`]+`"' not in body, f"{function_name} in {rel_path} still inlines a backtick regex; delegate to rag.nlp.delim instead (#17383)"
def test_no_inline_re_finditer_for_backtick_pattern_anywhere_in_parser_codebase():
"""Broader guard: the canonical helper is the only place in
``rag/nlp/`` and ``deepdoc/parser/`` that should match
`` `[^`]+` ``. Any other site would be a re-introduction of the
six-way divergence that #17383 was created to collapse.
"""
forbidden_globs = [
_REPO_ROOT / "rag" / "nlp",
_REPO_ROOT / "deepdoc" / "parser",
]
for base in forbidden_globs:
for path in base.rglob("*.py"):
# Skip the canonical helper itself.
if path == _REPO_ROOT / "rag" / "nlp" / "delim.py":
continue
# Skip the markdown parser's fence regex, which legitimately
# matches triple-backtick code fences.
if "markdown_parser.py" in str(path):
continue
text = path.read_text(encoding="utf-8")
assert 're.finditer(r"`[^`]+`"' not in text, f"{path.relative_to(_REPO_ROOT)} re-inlines the backtick regex; delegate to rag.nlp.delim (#17383)"
assert 're.findall(r"`[^`]+`"' not in text, f"{path.relative_to(_REPO_ROOT)} re-inlines the backtick regex; delegate to rag.nlp.delim (#17383)"
# --------------------------------------------------------------------------- #
# Frontend parity — `web/src/utils/delimiter-preview.ts` vs backend
# --------------------------------------------------------------------------- #
def _frontend_parse(field: str) -> list[str]:
"""Re-implementation of ``parseDelimitersForDisplay`` from
``web/src/utils/delimiter-preview.ts``.
Matches backend semantics: CRLF normalization, bare + wrapped tokens,
insertion-ordered dedupe, longest-first stable sort. Glyph substitution
is display-only and omitted here.
"""
if not field:
return []
normalized = field.replace("\r\n", "\n").replace("\r", "\n")
out: list[str] = []
seen: set[str] = set()
cursor = 0
for m in re.finditer(r"`([^`]+)`", normalized):
f, t = m.span()
for ch in normalized[cursor:f]:
if ch and ch not in seen:
seen.add(ch)
out.append(ch)
token = m.group(1)
if token and token not in seen:
seen.add(token)
out.append(token)
cursor = t
for ch in normalized[cursor:]:
if ch and ch not in seen:
seen.add(ch)
out.append(ch)
return sorted(out, key=len, reverse=True)
@pytest.mark.parametrize(
"field",
[
"",
"!",
"!?",
" ",
"\n",
"\t",
"\r",
"\r\n",
"\n!?;。;!?",
"`##`",
"`###``##``#`",
"\n`##`;",
"`a`a`a`",
"`\n\n`",
"`\t\t`",
"é",
"",
],
)
def test_frontend_and_backend_agree_on_delimiter_set(field):
"""The frontend preview and the backend helper must agree on the
*set* of delimiters after CRLF normalization and dedupe. Order is
longest-first on both sides."""
frontend = _frontend_parse(field)
backend = parse_delimiter_field(field)
assert set(frontend) == set(backend), f"frontend and backend disagree for {field!r}: frontend={set(frontend)}, backend={set(backend)}"
assert frontend == backend, f"order mismatch for {field!r}: frontend={frontend}, backend={backend}"
# --------------------------------------------------------------------------- #
# Acceptance criteria — verbatim from the issue
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"field, expected",
[
("", []),
("!", ["!"]),
("!?!?;", ["!", "?", ";"]),
(" ", [" "]),
(" ", [" "]), # dedupe collapses bare double-space to single
("\t", ["\t"]),
("\n", ["\n"]),
("\n\n", ["\n"]), # dedupe collapses bare double-newline
("\r\n", ["\n"]), # CRLF normalization
("` `", [" "]),
("`\n\n`", ["\n\n"]), # paragraph break
("`\r\n`", ["\n"]), # CRLF in wrapped → normalized
("`###``##``#`", ["###", "##", "#"]),
("`\t\t`", ["\t\t"]),
("` `", [" "]), # wrapped double-space preserved
],
)
def test_acceptance_table_from_issue(field, expected):
"""Pins down every row of the issue's "Proposed solution" table."""
assert parse_delimiter_field(field) == expected