feat: generalize mapped line joining
This commit is contained in:
+509
-43
@@ -1,19 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from mdpolish import ModifierContractError, Pipeline, RunStatus
|
||||
from mdpolish import DocumentSnapshot, ModifierContractError, Pipeline, RunStatus
|
||||
from mdpolish.modifiers import mapped_line_join
|
||||
from mdpolish.modifiers.mapped_line_join import (
|
||||
ExactLineJoinRule,
|
||||
KeepLineJoinRule,
|
||||
LexicalAmbiguityPolicy,
|
||||
LexicalCandidateForm,
|
||||
LexicalLineJoinRule,
|
||||
LexiconBackend,
|
||||
LineBreakPolicy,
|
||||
LineEndRegexRule,
|
||||
LineJoinBlock,
|
||||
LineJoinConflictPolicy,
|
||||
LineJoinRule,
|
||||
RegexLineJoinRule,
|
||||
UnicodeNormalization,
|
||||
)
|
||||
|
||||
MAPPINGS = (
|
||||
if TYPE_CHECKING:
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
mapped_module = import_module("mdpolish.modifiers.mapped_line_join")
|
||||
|
||||
LEGACY_MAPPINGS = (
|
||||
("exam-", "ple", "example"),
|
||||
("rule-", "based", "rule-based"),
|
||||
("value.", "ues", "values"),
|
||||
)
|
||||
|
||||
|
||||
def transform(markdown: str): # type: ignore[no-untyped-def]
|
||||
return Pipeline([mapped_line_join(MAPPINGS)]).transform(markdown)
|
||||
def transform(
|
||||
markdown: str,
|
||||
rules: tuple[tuple[str, str, str] | LineJoinRule, ...] = LEGACY_MAPPINGS,
|
||||
**options: object,
|
||||
) -> str:
|
||||
modifier = mapped_line_join(rules, **options) # type: ignore[arg-type]
|
||||
result = Pipeline((modifier,)).transform(markdown)
|
||||
assert result.status is RunStatus.SUCCESS
|
||||
assert result.output_markdown is not None
|
||||
return result.output_markdown
|
||||
|
||||
|
||||
def exact_rule(**overrides: object) -> ExactLineJoinRule:
|
||||
fields: dict[str, object] = {
|
||||
"rule_id": "example.join",
|
||||
"left": "exam",
|
||||
"right": "ple",
|
||||
"replacement": "example",
|
||||
"separator": "-",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return ExactLineJoinRule(**fields) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -21,70 +64,493 @@ def transform(markdown: str): # type: ignore[no-untyped-def]
|
||||
[
|
||||
("an exam-\nple here", "an example here"),
|
||||
("an exam-\n\nple here", "an example here"),
|
||||
("a rule-\nbased method", "a rule-based method"),
|
||||
("the value.\n\nues differ", "the values differ"),
|
||||
("an exam-\r\n\r\nple here", "an example here"),
|
||||
("an exam-\r\rple here", "an example here"),
|
||||
("a rule-\nbased method", "a rule-based method"),
|
||||
("the value.\nues differ", "the values differ"),
|
||||
],
|
||||
)
|
||||
def test_applies_exact_mapping_across_supported_line_shapes(markdown: str, expected: str) -> None:
|
||||
result = transform(markdown)
|
||||
assert result.status is RunStatus.SUCCESS
|
||||
assert result.output_markdown == expected
|
||||
assert len(result.changes) == 1
|
||||
def test_legacy_exact_mapping_supports_lf_crlf_cr_and_one_blank_line(markdown: str, expected: str) -> None:
|
||||
assert transform(markdown) == expected
|
||||
|
||||
|
||||
def test_exact_rules_distinguish_explicit_and_empty_separator() -> None:
|
||||
hyphenated = exact_rule()
|
||||
unseparated = exact_rule(rule_id="example.unseparated", separator="")
|
||||
|
||||
assert transform("exam-\nple", (hyphenated,)) == "example"
|
||||
assert transform("exam\nple", (unseparated,)) == "example"
|
||||
assert transform("exam\nple", (hyphenated,)) == "exam\nple"
|
||||
|
||||
|
||||
def test_named_regex_combines_backreferences_from_both_sides() -> None:
|
||||
rule = RegexLineJoinRule(
|
||||
rule_id="regex.join",
|
||||
left_pattern=r"(?P<stem>[A-Za-z]+)",
|
||||
right_pattern=r"(?P<suffix>[a-z]+)",
|
||||
replacement=r"\g<stem>_\g<suffix>",
|
||||
separator="-",
|
||||
)
|
||||
|
||||
assert transform("prefix exam-\nple suffix", (rule,)) == "prefix exam_ple suffix"
|
||||
|
||||
|
||||
def test_regex_keeps_its_inline_flags() -> None:
|
||||
rule = RegexLineJoinRule(
|
||||
rule_id="regex.flags",
|
||||
left_pattern=r"(?i:(?P<stem>exam))",
|
||||
right_pattern=r"(?i:(?P<suffix>ple))",
|
||||
replacement=r"\g<stem>\g<suffix>",
|
||||
separator="-",
|
||||
)
|
||||
|
||||
assert transform("EXAM-\nPLE", (rule,)) == "EXAMPLE"
|
||||
|
||||
|
||||
def test_regex_case_insensitive_option_applies_to_the_pattern() -> None:
|
||||
rule = RegexLineJoinRule(
|
||||
rule_id="regex.ignore-case",
|
||||
left_pattern=r"(?P<stem>EXAM)",
|
||||
right_pattern=r"(?P<suffix>PLE)",
|
||||
replacement=r"\g<stem>\g<suffix>",
|
||||
separator="-",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
assert transform("exam-\nple", (rule,)) == "example"
|
||||
|
||||
|
||||
def test_regex_suffix_matching_considers_overlapping_starts() -> None:
|
||||
rule = RegexLineJoinRule(
|
||||
rule_id="regex.overlap",
|
||||
left_pattern=r"(?P<stem>aba)",
|
||||
right_pattern=r"(?P<suffix>x)",
|
||||
replacement=r"\g<stem>\g<suffix>",
|
||||
separator="-",
|
||||
)
|
||||
|
||||
assert transform("ababa-\nx", (rule,)) == "ababax"
|
||||
|
||||
|
||||
def test_line_end_regex_uses_right_match_only_as_a_condition() -> None:
|
||||
rule = LineEndRegexRule(
|
||||
rule_id="line-end.space",
|
||||
right_pattern=r"(?P<initial>[a-z])",
|
||||
line_break=LineBreakPolicy.SPACE,
|
||||
)
|
||||
|
||||
assert transform("foo\nbar", (rule,)) == "foo bar"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("policy", "expected"),
|
||||
[
|
||||
(LineBreakPolicy.PRESERVE, "example\n"),
|
||||
(LineBreakPolicy.DELETE, "example"),
|
||||
(LineBreakPolicy.SPACE, "example "),
|
||||
(LineBreakPolicy.PARAGRAPH, "example\n\n"),
|
||||
],
|
||||
)
|
||||
def test_all_line_break_policies_are_exact(policy: LineBreakPolicy, expected: str) -> None:
|
||||
assert transform("exam-\nple", (exact_rule(line_break=policy),)) == expected
|
||||
|
||||
|
||||
def test_paragraph_policy_restores_quote_and_list_prefixes() -> None:
|
||||
rule = exact_rule(line_break=LineBreakPolicy.PARAGRAPH)
|
||||
|
||||
assert transform("> exam-\n> ple", (rule,)) == "> example\n>\n> "
|
||||
assert transform("- exam-\n ple", (rule,)) == "- example\n\n "
|
||||
assert transform("> - exam-\n> ple", (rule,)) == "> - example\n>\n> "
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("markdown", "expected"),
|
||||
[
|
||||
("plain exam-\nple", "plain example"),
|
||||
("# exam-\nple", "# example"),
|
||||
("- exam-\n ple", "- example"),
|
||||
("> exam-\n> ple", "> example"),
|
||||
("> > > exam-\n> > > ple", "> > > example"),
|
||||
("1. outer\n 2. exam-\n ple", "1. outer\n 2. example"),
|
||||
],
|
||||
)
|
||||
def test_supported_markdown_blocks_and_valid_nested_containers(markdown: str, expected: str) -> None:
|
||||
assert transform(markdown, (exact_rule(),)) == expected
|
||||
|
||||
|
||||
def test_block_scope_is_explicit() -> None:
|
||||
heading_only = exact_rule(blocks=frozenset({LineJoinBlock.HEADING}))
|
||||
|
||||
assert transform("# exam-\nple", (heading_only,)) == "# example"
|
||||
assert transform("exam-\nple", (heading_only,)) == "exam-\nple"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"markdown",
|
||||
[
|
||||
"an unknown-\nword here",
|
||||
"an exam-\n\n\nple here",
|
||||
"an exam-\r\n\nple here",
|
||||
"an EXAM-\nple here",
|
||||
"an exam-\nplemore here",
|
||||
"an xrule-\nbased method",
|
||||
"> > > exam-\n> > ple",
|
||||
"> #broken exam-\n> ple",
|
||||
"1. exam-\n2. ple",
|
||||
"1. outer\n 2. exam-\n ple",
|
||||
"- exam-\n ple",
|
||||
"#broken exam-\nple",
|
||||
],
|
||||
)
|
||||
def test_unknown_or_unsafe_boundaries_are_preserved(markdown: str) -> None:
|
||||
assert transform(markdown).output_markdown == markdown
|
||||
|
||||
|
||||
def test_parameters_and_results_are_independent_of_mapping_order() -> None:
|
||||
first = mapped_line_join(MAPPINGS)
|
||||
second = mapped_line_join(reversed(MAPPINGS))
|
||||
markdown = "example becomes exam-\nple"
|
||||
|
||||
first_result = Pipeline([first]).transform(markdown)
|
||||
second_result = Pipeline([second]).transform(markdown)
|
||||
|
||||
assert first_result.modifiers == second_result.modifiers
|
||||
assert first_result.output_markdown == second_result.output_markdown
|
||||
|
||||
|
||||
def test_library_contains_no_default_mapping() -> None:
|
||||
modifier = mapped_line_join(())
|
||||
result = Pipeline([modifier]).transform("an exam-\nple here")
|
||||
|
||||
assert modifier.parameters == (("mappings", ()),)
|
||||
assert result.output_markdown == "an exam-\nple here"
|
||||
def test_unknown_or_mismatched_containers_fail_closed_instead_of_becoming_paragraphs(markdown: str) -> None:
|
||||
assert transform(markdown, (exact_rule(),)) == markdown
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mappings",
|
||||
"markdown",
|
||||
[
|
||||
"```text\nexam-\nple\n```",
|
||||
" exam-\n ple",
|
||||
"> exam-\n> ple",
|
||||
"- ```\n exam-\n ple\n ```",
|
||||
"| word |\n| --- |\n| exam- |\n| ple |",
|
||||
"<table>\nexam-\nple\n</table>",
|
||||
"<table>\nexam-\nple",
|
||||
],
|
||||
)
|
||||
def test_code_and_table_regions_are_always_excluded(markdown: str) -> None:
|
||||
assert transform(markdown, (exact_rule(),)) == markdown
|
||||
|
||||
|
||||
def test_three_line_chain_is_one_proposal_and_uses_virtual_output() -> None:
|
||||
rules = (
|
||||
exact_rule(rule_id="chain.first"),
|
||||
ExactLineJoinRule(
|
||||
rule_id="chain.second",
|
||||
left="example",
|
||||
right="based",
|
||||
replacement="example-based",
|
||||
separator="-",
|
||||
),
|
||||
)
|
||||
modifier = mapped_line_join(rules)
|
||||
proposals = modifier.propose(DocumentSnapshot("exam-\nple-\nbased"))
|
||||
|
||||
assert len(proposals) == 1
|
||||
assert len(proposals[0].edits) == 1
|
||||
assert "chain.first, chain.second" in proposals[0].reason
|
||||
assert transform("exam-\nple-\nbased", rules) == "example-based"
|
||||
|
||||
|
||||
def test_mixed_line_endings_fail_the_entire_connected_chain_closed() -> None:
|
||||
rules = (
|
||||
exact_rule(rule_id="chain.first"),
|
||||
ExactLineJoinRule("chain.second", "example", "based", "example-based", separator="-"),
|
||||
)
|
||||
markdown = "exam-\r\nple-\nbased"
|
||||
|
||||
assert transform(markdown, rules) == markdown
|
||||
|
||||
|
||||
def test_multiple_independent_chains_are_reported_in_source_order() -> None:
|
||||
modifier = mapped_line_join((exact_rule(),))
|
||||
snapshot = DocumentSnapshot("exam-\nple and\n\nexam-\nple")
|
||||
proposals = modifier.propose(snapshot)
|
||||
|
||||
assert len(proposals) == 2
|
||||
assert proposals[0].edits[0].span.start < proposals[1].edits[0].span.start
|
||||
|
||||
|
||||
def test_keep_rule_can_veto_a_lower_priority_replacement() -> None:
|
||||
keep = KeepLineJoinRule("keep.example", "exam", "ple", separator="-", priority=10)
|
||||
replace = exact_rule(priority=0)
|
||||
|
||||
assert transform("exam-\nple", (replace, keep)) == "exam-\nple"
|
||||
|
||||
|
||||
def test_conflict_first_and_priority_are_deterministic() -> None:
|
||||
first = exact_rule(rule_id="first", replacement="first", priority=0)
|
||||
second = exact_rule(rule_id="second", replacement="second", priority=10)
|
||||
|
||||
assert transform(
|
||||
"exam-\nple",
|
||||
(first, second),
|
||||
conflict_policy=LineJoinConflictPolicy.FIRST,
|
||||
) == "first"
|
||||
assert transform(
|
||||
"exam-\nple",
|
||||
(first, second),
|
||||
conflict_policy=LineJoinConflictPolicy.PRIORITY,
|
||||
) == "second"
|
||||
|
||||
|
||||
def test_priority_tie_uses_declaration_order() -> None:
|
||||
first = exact_rule(rule_id="first", replacement="first")
|
||||
second = exact_rule(rule_id="second", replacement="second")
|
||||
|
||||
assert transform("exam-\nple", (second, first)) == "second"
|
||||
|
||||
|
||||
def test_conflict_error_names_rules_but_not_surrounding_text() -> None:
|
||||
modifier = mapped_line_join(
|
||||
(exact_rule(rule_id="first"), exact_rule(rule_id="second")),
|
||||
conflict_policy=LineJoinConflictPolicy.ERROR,
|
||||
)
|
||||
|
||||
with pytest.raises(ModifierContractError, match=r"first, second") as raised:
|
||||
modifier.propose(DocumentSnapshot("secret exam-\nple material"))
|
||||
assert "secret" not in str(raised.value)
|
||||
|
||||
|
||||
def test_case_insensitive_matching_is_opt_in() -> None:
|
||||
assert transform("EXAM-\nPLE", (exact_rule(),)) == "EXAM-\nPLE"
|
||||
assert transform("EXAM-\nPLE", (exact_rule(case_sensitive=False),)) == "example"
|
||||
|
||||
|
||||
def test_unicode_normalization_is_opt_in_and_preserves_source_index_mapping() -> None:
|
||||
decomposed_left = "cafe\N{COMBINING ACUTE ACCENT}"
|
||||
markdown = "CAFÉ-\nteria"
|
||||
default = ExactLineJoinRule(
|
||||
"unicode.default",
|
||||
decomposed_left,
|
||||
"teria",
|
||||
"cafeteria",
|
||||
separator="-",
|
||||
case_sensitive=False,
|
||||
)
|
||||
normalized = ExactLineJoinRule(
|
||||
"unicode.nfc",
|
||||
decomposed_left,
|
||||
"teria",
|
||||
"cafeteria",
|
||||
separator="-",
|
||||
case_sensitive=False,
|
||||
normalization=UnicodeNormalization.NFC,
|
||||
)
|
||||
|
||||
assert transform(markdown, (default,)) == markdown
|
||||
assert transform(markdown, (normalized,)) == "cafeteria"
|
||||
|
||||
|
||||
def test_unicode_word_boundaries_and_explicit_override() -> None:
|
||||
strict = exact_rule()
|
||||
permissive = exact_rule(rule_id="permissive", require_word_boundaries=False)
|
||||
markdown = "éexam-\nplemore"
|
||||
|
||||
assert transform(markdown, (strict,)) == markdown
|
||||
assert transform(markdown, (permissive,)) == "éexamplemore"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_blank_lines", [0, 1])
|
||||
def test_blank_line_limit_is_configurable(max_blank_lines: int) -> None:
|
||||
expected = "exam-\n\nple" if max_blank_lines == 0 else "example"
|
||||
assert transform(
|
||||
"exam-\n\nple",
|
||||
(exact_rule(),),
|
||||
max_intervening_blank_lines=max_blank_lines,
|
||||
) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", [-1, True, 2])
|
||||
def test_invalid_blank_line_limits_are_rejected(invalid: object) -> None:
|
||||
with pytest.raises(ModifierContractError):
|
||||
mapped_line_join((), max_intervening_blank_lines=invalid) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_empty_document_and_empty_rule_set_are_no_ops() -> None:
|
||||
modifier = mapped_line_join(())
|
||||
|
||||
assert transform("", ()) == ""
|
||||
assert transform("exam-\nple", ()) == "exam-\nple"
|
||||
assert modifier.version == "2.0.0"
|
||||
assert dict(modifier.parameters)["rules"] == ()
|
||||
|
||||
|
||||
def test_parameters_preserve_rule_order_and_record_all_options() -> None:
|
||||
first = mapped_line_join((exact_rule(rule_id="first"), exact_rule(rule_id="second")))
|
||||
second = mapped_line_join((exact_rule(rule_id="second"), exact_rule(rule_id="first")))
|
||||
|
||||
assert first.parameters != second.parameters
|
||||
records = dict(first.parameters)["rules"]
|
||||
assert isinstance(records, tuple)
|
||||
first_record = cast(tuple[tuple[str, object], ...], records[0])
|
||||
assert ("rule_id", "first") in first_record
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rules",
|
||||
[
|
||||
(("", "right", "word"),),
|
||||
(("left", "right", "two words"),),
|
||||
(("left", "right", "word"), ("left", "right", "other")),
|
||||
(("left", "right"),),
|
||||
(exact_rule(rule_id="same"), exact_rule(rule_id="same")),
|
||||
(exact_rule(case_sensitive=1),),
|
||||
(exact_rule(blocks={LineJoinBlock.PARAGRAPH}),),
|
||||
(RegexLineJoinRule("regex", "", r"(?P<right>x)", "x"),),
|
||||
(RegexLineJoinRule("regex", r"(x)", r"(?P<right>x)", "x"),),
|
||||
(RegexLineJoinRule("regex", r"(?P<x>x)", r"(?P<x>x)", r"\g<x>"),),
|
||||
(RegexLineJoinRule("regex", r"(?P<x>x)", r"(?P<y>x)", r"\g<missing>"),),
|
||||
(LineEndRegexRule("line-end", r"(?P<x>x)", line_break=LineBreakPolicy.PRESERVE),),
|
||||
],
|
||||
)
|
||||
def test_invalid_mappings_raise_contract_error(mappings: object) -> None:
|
||||
def test_invalid_rules_raise_contract_error(rules: object) -> None:
|
||||
with pytest.raises(ModifierContractError):
|
||||
mapped_line_join(mappings) # type: ignore[arg-type]
|
||||
mapped_line_join(rules) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class FakeLexicon:
|
||||
package_version = "test-lexicon"
|
||||
|
||||
def __init__(self, scores: dict[str, tuple[float, float | None]]) -> None:
|
||||
self._scores = scores
|
||||
|
||||
def evidence(
|
||||
self,
|
||||
candidate: str,
|
||||
form: LexicalCandidateForm,
|
||||
left: str,
|
||||
right: str,
|
||||
) -> object:
|
||||
del form, left, right
|
||||
score = self._scores.get(candidate)
|
||||
return None if score is None else mapped_module._LexicalEvidence(*score)
|
||||
|
||||
|
||||
def lexical_rule(**overrides: object) -> LexicalLineJoinRule:
|
||||
fields: dict[str, object] = {
|
||||
"rule_id": "english.lexical",
|
||||
"left_pattern": r"[A-Za-z]+",
|
||||
"right_pattern": r"[a-z]+",
|
||||
"separator": "-",
|
||||
"backend": LexiconBackend.WORDFREQ,
|
||||
"language": "en",
|
||||
"candidate_forms": (LexicalCandidateForm.JOINED, LexicalCandidateForm.HYPHENATED),
|
||||
"minimum_score": 1.0,
|
||||
"minimum_score_margin": 0.5,
|
||||
}
|
||||
fields.update(overrides)
|
||||
return LexicalLineJoinRule(**fields) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def install_fake_lexicon(monkeypatch: MonkeyPatch, scores: dict[str, tuple[float, float | None]]) -> None:
|
||||
def build(rule: LexicalLineJoinRule) -> FakeLexicon:
|
||||
del rule
|
||||
return FakeLexicon(scores)
|
||||
|
||||
monkeypatch.setattr(mapped_module, "_build_lexicon", build)
|
||||
|
||||
|
||||
def test_lexical_rule_joins_a_unique_dictionary_candidate_without_per_word_mapping(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
install_fake_lexicon(monkeypatch, {"example": (5.0, 5.0)})
|
||||
|
||||
assert transform("exam-\nple", (lexical_rule(),)) == "example"
|
||||
|
||||
|
||||
def test_lexical_rule_can_select_the_natural_hyphenated_form_by_score(monkeypatch: MonkeyPatch) -> None:
|
||||
install_fake_lexicon(monkeypatch, {"rulebased": (3.0, 3.0), "rule-based": (6.0, 6.0)})
|
||||
|
||||
assert transform("rule-\nbased", (lexical_rule(),)) == "rule-based"
|
||||
|
||||
|
||||
def test_lexical_margin_and_keep_policy_preserve_an_ambiguous_boundary(monkeypatch: MonkeyPatch) -> None:
|
||||
install_fake_lexicon(monkeypatch, {"rulebased": (5.0, 5.0), "rule-based": (4.8, 4.8)})
|
||||
|
||||
assert transform("rule-\nbased", (lexical_rule(minimum_score_margin=0.5),)) == "rule-\nbased"
|
||||
|
||||
|
||||
def test_spellchecker_style_spaced_proxy_is_not_ranked_against_single_token(monkeypatch: MonkeyPatch) -> None:
|
||||
install_fake_lexicon(monkeypatch, {"inside": (6.0, 6.0), "in side": (5.0, None)})
|
||||
rule = lexical_rule(
|
||||
separator="",
|
||||
candidate_forms=(LexicalCandidateForm.JOINED, LexicalCandidateForm.SPACED),
|
||||
)
|
||||
|
||||
assert transform("in\nside", (rule,)) == "in\nside"
|
||||
|
||||
|
||||
def test_lexical_ambiguity_error_is_source_safe(monkeypatch: MonkeyPatch) -> None:
|
||||
install_fake_lexicon(monkeypatch, {"inside": (6.0, 6.0), "in side": (5.0, None)})
|
||||
rule = lexical_rule(
|
||||
separator="",
|
||||
candidate_forms=(LexicalCandidateForm.JOINED, LexicalCandidateForm.SPACED),
|
||||
ambiguity=LexicalAmbiguityPolicy.ERROR,
|
||||
)
|
||||
modifier = mapped_line_join((rule,))
|
||||
|
||||
with pytest.raises(ModifierContractError, match=r"english\.lexical") as raised:
|
||||
modifier.propose(DocumentSnapshot("secret in\nside"))
|
||||
assert "secret" not in str(raised.value)
|
||||
|
||||
|
||||
class FakeHyphenator:
|
||||
def __init__(self, positions: tuple[int, ...]) -> None:
|
||||
self._positions = positions
|
||||
|
||||
def positions(self, word: str) -> tuple[int, ...]:
|
||||
del word
|
||||
return self._positions
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("positions", "expected"), [((4,), "example"), ((), "exam-ple")])
|
||||
def test_pyphen_gate_only_controls_the_joined_candidate(
|
||||
monkeypatch: MonkeyPatch,
|
||||
positions: tuple[int, ...],
|
||||
expected: str,
|
||||
) -> None:
|
||||
install_fake_lexicon(monkeypatch, {"example": (6.0, 6.0), "exam-ple": (4.0, 4.0)})
|
||||
monkeypatch.setattr(
|
||||
mapped_module,
|
||||
"_build_hyphenator",
|
||||
lambda language: (FakeHyphenator(positions), f"test-{language}"),
|
||||
)
|
||||
rule = lexical_rule(hyphenation_language="en_US", minimum_score_margin=0.5)
|
||||
|
||||
assert transform("exam-\nple", (rule,)) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"separator": "/"},
|
||||
{"candidate_forms": (LexicalCandidateForm.JOINED,)},
|
||||
{"candidate_forms": (LexicalCandidateForm.JOINED, LexicalCandidateForm.JOINED)},
|
||||
{"minimum_score": -1.0},
|
||||
{"minimum_score": float("nan")},
|
||||
{"minimum_score_margin": True},
|
||||
{"language": ""},
|
||||
{"hyphenation_language": ""},
|
||||
],
|
||||
)
|
||||
def test_invalid_lexical_rules_fail_before_loading_a_backend(overrides: dict[str, object]) -> None:
|
||||
with pytest.raises(ModifierContractError):
|
||||
mapped_line_join((lexical_rule(**overrides),))
|
||||
|
||||
|
||||
def test_missing_requested_optional_backend_raises_without_fallback(monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(mapped_module, "_zipf_frequency", None)
|
||||
|
||||
with pytest.raises(ModifierContractError, match="frequency"):
|
||||
mapped_line_join((lexical_rule(),))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backend", "available"),
|
||||
[
|
||||
(LexiconBackend.SPELLCHECKER, mapped_module._SpellChecker is not None),
|
||||
(LexiconBackend.WORDFREQ, mapped_module._zipf_frequency is not None),
|
||||
],
|
||||
)
|
||||
def test_installed_optional_backend_constructs_or_is_reported_as_skipped(
|
||||
backend: LexiconBackend,
|
||||
available: bool,
|
||||
) -> None:
|
||||
if not available:
|
||||
pytest.skip(f"optional backend is not installed: {backend.value}")
|
||||
mapped_line_join((lexical_rule(backend=backend),))
|
||||
|
||||
|
||||
def test_successful_output_is_idempotent() -> None:
|
||||
pipeline = Pipeline([mapped_line_join(MAPPINGS)])
|
||||
rules = (exact_rule(),)
|
||||
pipeline = Pipeline((mapped_line_join(rules),))
|
||||
first = pipeline.transform("an exam-\n\nple here")
|
||||
|
||||
assert first.status is RunStatus.SUCCESS
|
||||
assert first.output_markdown is not None
|
||||
assert pipeline.transform(first.output_markdown).changes == ()
|
||||
|
||||
Reference in New Issue
Block a user