feat: 公开精确物理行范围接口
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import FrozenInstanceError
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from mdpolish.text_ranges import PhysicalLine, iter_physical_lines, line_ending_styles, physical_lines
|
||||
|
||||
|
||||
def _offsets(lines: tuple[PhysicalLine, ...]) -> tuple[tuple[int, int, int], ...]:
|
||||
return tuple((line.content_start, line.content_end, line.full_end) for line in lines)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
("", ()),
|
||||
("a", ((0, 1, 1),)),
|
||||
("a\n", ((0, 1, 2),)),
|
||||
("\n", ((0, 0, 1),)),
|
||||
("\r\n", ((0, 0, 2),)),
|
||||
("a\n\n", ((0, 1, 2), (2, 2, 3))),
|
||||
("\ntext", ((0, 0, 1), (1, 5, 5))),
|
||||
("a\r\nb\rc\n", ((0, 1, 3), (3, 4, 5), (5, 6, 7))),
|
||||
("a\u2028b", ((0, 3, 3),)),
|
||||
],
|
||||
)
|
||||
def test_scans_exact_physical_line_offsets(
|
||||
source: str,
|
||||
expected: tuple[tuple[int, int, int], ...],
|
||||
) -> None:
|
||||
assert _offsets(physical_lines(source)) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
"",
|
||||
"one line",
|
||||
"\nleading empty",
|
||||
"a\n\n",
|
||||
"a\r\nb\rc\n",
|
||||
"\ufeff中文😀e\u0301\u2028\x00\t\f",
|
||||
],
|
||||
)
|
||||
def test_ranges_reconstruct_source_without_normalization(source: str) -> None:
|
||||
lines = physical_lines(source)
|
||||
|
||||
assert "".join(source[line.content_start : line.full_end] for line in lines) == source
|
||||
assert lines == tuple(iter_physical_lines(source))
|
||||
assert lines == physical_lines(source)
|
||||
for line in lines:
|
||||
assert line.content(source) == source[line.content_start : line.content_end]
|
||||
assert line.line_ending(source) == source[line.content_end : line.full_end]
|
||||
|
||||
|
||||
def test_unicode_separators_and_control_characters_remain_content() -> None:
|
||||
source = "\ufeff中文😀e\u0301\u2028\u2029\x00\t\f"
|
||||
line = physical_lines(source)[0]
|
||||
|
||||
assert (line.content_start, line.content_end, line.full_end) == (0, len(source), len(source))
|
||||
assert line.content(source) == source
|
||||
assert line.line_ending(source) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
("", frozenset()),
|
||||
("no ending", frozenset()),
|
||||
("a\nb\n", frozenset(("\n",))),
|
||||
("a\r\nb\r\n", frozenset(("\r\n",))),
|
||||
("a\rb\r", frozenset(("\r",))),
|
||||
("a\nb\r\nc\r", frozenset(("\n", "\r\n", "\r"))),
|
||||
],
|
||||
)
|
||||
def test_reports_only_line_endings_present_in_source(source: str, expected: frozenset[str]) -> None:
|
||||
assert line_ending_styles(source) == expected
|
||||
|
||||
|
||||
def test_empty_means_zero_length_content_not_whitespace() -> None:
|
||||
empty, space, tab = physical_lines("\n \n\t\n")
|
||||
|
||||
assert empty.is_empty
|
||||
assert not space.is_empty
|
||||
assert not tab.is_empty
|
||||
|
||||
|
||||
def test_physical_line_is_frozen_and_slotted() -> None:
|
||||
line = PhysicalLine(0, 1, 1)
|
||||
mutable_view = cast(Any, line)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
mutable_view.content_start = 1
|
||||
assert not hasattr(line, "__dict__")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field_index", [0, 1, 2])
|
||||
@pytest.mark.parametrize("invalid", [True, 1.0, "1", None])
|
||||
def test_rejects_non_integer_offsets(field_index: int, invalid: object) -> None:
|
||||
values = [0, 0, 0]
|
||||
values[field_index] = cast(int, invalid)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
PhysicalLine(*values)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offsets",
|
||||
[
|
||||
(-1, 0, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, -1),
|
||||
(1, 0, 1),
|
||||
(0, 2, 1),
|
||||
(0, 0, 3),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_offset_ranges(offsets: tuple[int, int, int]) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
PhysicalLine(*offsets)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scanner",
|
||||
[iter_physical_lines, physical_lines, line_ending_styles],
|
||||
)
|
||||
@pytest.mark.parametrize("invalid", [b"text", Path("text.md"), None, 1])
|
||||
def test_scanners_reject_non_string_sources_immediately(
|
||||
scanner: Callable[[str], object],
|
||||
invalid: object,
|
||||
) -> None:
|
||||
with pytest.raises(TypeError, match="source must be a string"):
|
||||
scanner(cast(str, invalid))
|
||||
|
||||
|
||||
def test_iter_scanner_validates_before_iteration_begins() -> None:
|
||||
with pytest.raises(TypeError, match="source must be a string"):
|
||||
iter_physical_lines(cast(str, b"deferred validation would be wrong"))
|
||||
|
||||
|
||||
def test_accessors_reject_non_string_or_short_sources() -> None:
|
||||
line = PhysicalLine(0, 1, 2)
|
||||
|
||||
with pytest.raises(TypeError, match="source must be a string"):
|
||||
line.content(cast(str, b"a\n"))
|
||||
with pytest.raises(TypeError, match="source must be a string"):
|
||||
line.line_ending(cast(str, None))
|
||||
with pytest.raises(ValueError, match="exceeds source length"):
|
||||
line.content("a")
|
||||
with pytest.raises(ValueError, match="exceeds source length"):
|
||||
line.line_ending("a")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "source"),
|
||||
[
|
||||
(PhysicalLine(0, 1, 2), "ab"),
|
||||
(PhysicalLine(0, 0, 2), "\n\r"),
|
||||
],
|
||||
)
|
||||
def test_line_ending_rejects_non_physical_slices(line: PhysicalLine, source: str) -> None:
|
||||
with pytest.raises(ValueError, match="empty, LF, CR, or CRLF"):
|
||||
line.line_ending(source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["a", "a\n", "a\r", "a\r\n"])
|
||||
def test_line_ending_accepts_every_supported_slice(source: str) -> None:
|
||||
line = physical_lines(source)[0]
|
||||
|
||||
assert line.line_ending(source) in {"", "\n", "\r", "\r\n"}
|
||||
Reference in New Issue
Block a user