from __future__ import annotations from collections.abc import Mapping from typing import cast import pytest from mdpolish import Component, ComponentContractError, DocumentSnapshot, ProposedChange, TextEdit, TextSpan class ExampleComponent(Component): def __init__( self, *, component_id: object = "test.example", version: object = "1.2.3", parameters: object = None, applicability: object = "处理测试标记,要求精确匹配,排除所有其他内容。", ) -> None: self._component_id = component_id self._version = version self._parameters = {} if parameters is None else parameters self._applicability = applicability @property def component_id(self) -> str: return cast(str, self._component_id) @property def version(self) -> str: return cast(str, self._version) @property def parameters(self) -> Mapping[str, object]: return cast(Mapping[str, object], self._parameters) @property def applicability(self) -> str: return cast(str, self._applicability) def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: if not snapshot.markdown: return () edit = TextEdit(snapshot.sha256, TextSpan(0, 1), snapshot.markdown[0], "X") return (ProposedChange(snapshot.sha256, "replace first character", (edit,)),) class ListReturningComponent(ExampleComponent): def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: return cast(tuple[ProposedChange, ...], []) class WrongValueComponent(ExampleComponent): def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: return cast(tuple[ProposedChange, ...], ("wrong",)) def test_component_metadata_is_validated_and_parameters_are_frozen_deterministically() -> None: component = ExampleComponent( parameters={ "z": [1, {"b": False, "a": None}], "a": "value", } ) info = component._component_info() assert info.component_id == "test.example" assert info.version == "1.2.3" assert info.parameters == ( ("a", "value"), ("z", (1, (("a", None), ("b", False)))), ) @pytest.mark.parametrize("component_id", ["", "Uppercase", "has space", "two..dots", "_leading"]) def test_invalid_component_id_is_a_contract_error(component_id: str) -> None: with pytest.raises(ComponentContractError, match="component_id"): ExampleComponent(component_id=component_id)._component_info() @pytest.mark.parametrize("version", ["1", "1.2", "v1.2.3", "01.2.3", "1.2.3-alpha"]) def test_invalid_version_is_a_contract_error(version: str) -> None: with pytest.raises(ComponentContractError, match=r"MAJOR.MINOR.PATCH"): ExampleComponent(version=version)._component_info() def test_empty_applicability_is_a_contract_error() -> None: with pytest.raises(ComponentContractError, match="applicability"): ExampleComponent(applicability=" \n")._component_info() @pytest.mark.parametrize( "parameters", [ {"bad": {1, 2}}, {"bad": float("inf")}, {"bad": float("nan")}, {1: "non-string key"}, ["not", "a", "mapping"], ], ) def test_unrepresentable_parameters_are_contract_errors(parameters: object) -> None: with pytest.raises(ComponentContractError, match="parameter"): ExampleComponent(parameters=parameters)._component_info() def test_collect_proposals_requires_a_tuple_of_proposed_changes() -> None: snapshot = DocumentSnapshot("abc") with pytest.raises(ComponentContractError, match="return a tuple"): ListReturningComponent()._collect_proposals(snapshot) with pytest.raises(ComponentContractError, match="only ProposedChange"): WrongValueComponent()._collect_proposals(snapshot) def test_component_exposes_no_public_check_or_transform_shortcut() -> None: component = ExampleComponent() assert not hasattr(component, "check") assert not hasattr(component, "transform")