Files
mdpolish/tests/test_text_ranges.py

175 lines
5.3 KiB
Python

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"}