From fe260bf169e18b10161d4b43b5953a55729e13ef Mon Sep 17 00:00:00 2001 From: danceratopz Date: Thu, 22 Jan 2026 17:46:30 +0100 Subject: [PATCH 1/3] Add comprehensive test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix glob patterns in template packaging - Use explicit patterns instead of recursive ** globs. - Setuptools doesn't reliably include files with ** in sdist builds. Add test extras with pytest-cov dependency - Add dedicated test extras with pytest and pytest-cov. Include test extras in tox environments - Add test extras to both test and type environments so pytest and pytest-cov are available alongside lint deps. Add pytest and coverage configuration - Configure pytest to run with coverage enabled. - Set minimum coverage threshold to 80%. - Exclude common non-testable patterns from coverage. Add development section to README Add comprehensive test suite - Add tests for CLI, settings, context, and document modules. - Add tests for plugin loader and transform pipeline. - Add tests for HTML, mistletoe, and verbatim plugins. - Add end-to-end HTML rendering pipeline tests. - Add tests for Python CST parsing and node types. - Add tests for references, search, and resources plugins. - Add integration tests for end-to-end workflows. - Add behavior-level pipeline contract tests. - Achieve 90% code coverage. test(html): add TextNode repr tests for special characters Add tests for TextNode repr with double quotes and single quotes, verifying Python's repr behavior with these characters. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854497710 test(html): assert `append` puts child at end of children list Strengthen `test_append_child` and `test_append_text` to verify the appended node is the last child, not just present somewhere. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854502333 test(html): assert `replace_child` preserves position of old node Strengthen `test_replace_child` to verify the new node takes the exact index position of the old node, not just that it's present. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854505156 test(html): add `replace_child` test with duplicate children Test that when the same child is appended twice, `replace_child` replaces both occurrences with the new node. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854511263 test(html): add parser test for `
` self-closing syntax Verify that the XHTML-style `
` is parsed identically to `
`. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854697928 test(html): reorganize into four focused test modules Replace `test_html.py`, `test_html_comprehensive.py`, and `test_html_extended.py` with four clearly-scoped modules: - `test_html_nodes.py` — `TextNode`, `HTMLTag`, `HTMLRoot`, `_to_element`. - `test_html_parser.py` — `HTMLParser`, `_ElementTreeVisitor`, `_make_relative`. - `test_html_render.py` — render callbacks, `HTMLVisitor`, `_FindVisitor`, `render_reference`. - `test_html_plugin.py` — `HTMLContext`, `HTMLDiscover`, `HTMLTransform`, `HTMLRoot.output`. Drop test classes in favour of flat `test_` functions (no shared parametrisation or fixtures justified classes). Deduplicate 5 tests that appeared identically across the old files (105 → 100). Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854493090 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854620556 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854632666 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854641593 test(mistletoe): assert on length and order after `replace_child` Strengthen `MarkdownNode.replace_child` test to verify the children list has exactly one element and it is the replacement node. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2873281703 test(mistletoe): remove lazy evaluation assertion Remove `test_children_lazy_evaluation` — whether `MarkdownNode` caches children internally is an implementation detail, not a behavioral contract worth testing. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2873331623 test(mistletoe): reorganize into two focused test modules Replace `test_mistletoe_comprehensive.py` and `test_mistletoe_extended.py` with two clearly-scoped modules: - `test_mistletoe.py` — `MarkdownNode`, visitors (`_DocstringVisitor`, `_ReferenceVisitor`, `_SearchVisitor`), and transforms. - `test_mistletoe_render.py` — all `_render_*` functions and `render_html` dispatch. Move `TestMarkdownNode` and `TestSearchVisitor` from `test_integration.py` into `test_mistletoe.py` where they belong. Drop test classes in favour of flat `test_` functions. Deduplicate tests that appeared across files (80 → 76). Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2873209746 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2873284692 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2873111000 test(integration): remove `test_determinism` The determinism test runs the same pipeline twice and compares output, but this doesn't test meaningful behaviour — the pipeline is already deterministic by construction. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2873130558 test(settings): remove `TestSettingsConstants` Remove tests that just pin `MAX_DEPTH == 10` and `FILE_NAME == "pyproject.toml"` — these assert constant values with no behavioural insight. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2947787700 test: unwrap single-method test classes into bare functions Convert 22 single-method test classes across 10 files into top-level `test_` functions. Classes with multiple methods are left as-is. Renamed functions to be self-descriptive without the class namespace, for example `TestBuilderContextManager::test_builder_with_statement` becomes `test_builder_context_manager`. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2848972333 test: replace hand-rolled `temp_dir` fixtures with `tmp_path` Replace all `@pytest.fixture def temp_dir()` using `tempfile.TemporaryDirectory` with pytest's built-in `tmp_path` fixture across 11 test files. Also convert inline `tempfile.TemporaryDirectory()` usage in `test_context.py`. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2848944948 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849510630 test(document): parametrize base node operations Add parametrized tests for `children` iterability and `repr` across `BlankNode` and `ListNode`, making it easy to extend when new node types are added. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849599608 test(cli): replace `os.chdir` with `monkeypatch.chdir` Replace all manual `os.chdir()` + try/finally blocks with pytest's `monkeypatch.chdir()` which automatically restores the working directory. This is safer for parallel test execution. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849321887 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849457201 test(cli): strengthen assertions and fix mock type annotations - Assert "Output path is required" in log when `main()` exits without an output path, verifying the _reason_ for failure. - Add return type to `ContainerNode.children`. - Raise `NotImplementedError` in `ContainerNode.replace_child`. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849031227 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849327850 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849328966 test(cli): use `ListNode` for multiple output node traversal Use `ListNode([first, second]).visit(visitor)` instead of manually calling `enter`/`exit`, making the test a fairer integration check. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849343058 test(cli): assert "Module docstring" appears in rendered output Verify the end-to-end pipeline actually renders the docstring into the output HTML, not just that files are created. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849368476 test(context): add test storing both `Base` and `Derived` Verify that storing instances under both `Base` and `Derived` keys resolves each independently via exact type matching. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849494632 test(files): add compound extension test for `FileSource.output_path` Test that `.tar.gz` removes only the final suffix (`.gz`), leaving `.tar` — documenting the current single-suffix-stripping behaviour. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854313079 test(files): reorder builder assertions for clarity Check `source in processed` first (did it get processed?), then `len(processed) == 1` (nothing unexpected?), then `len(unprocessed)`. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854437554 test(search): extract `_parse_search_output` helper Replace 5 duplicated JSON extraction blocks with a shared `_parse_search_output()` function that validates the prefix/suffix and parses the payload. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2947774134 test(listing): use `any()` instead of `next(..., None)` pattern Simplify existence checks to use `any(s.relative_path == ...)`. Where the result is used afterward, drop the `None` sentinel and let `StopIteration` propagate on failure. Applied to all 3 occurrences in `test_listing.py`. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2860831050 test(python_cst): remove line reference from docstring Remove `nodes.py:44` reference that will go out of date. No other line references remain in test files. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2947730902 test(listing): trim redundant comment Remove the third line that restates what the first two already say. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2860843219 test(document): refactor visitor helpers to use `id()` identity Replace string-based event tracking (`f"enter:{repr(node)}"`) with `(action, id(node))` tuples so assertions verify exact object identity, not just repr equality. Make `SkippingVisitor` and `ConditionalSkipVisitor` inherit from `RecordingVisitor` via a `ClassVar` return-value override, removing duplicated enter/exit logic. Co-authored-by: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849629569 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849636494 test: remove redundant `testpaths` from pytest config pytest discovers `tests/` via normal collection without needing `testpaths` — it's the default behaviour. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849655002 test: update copyright year to 2026 in all test files All test files were written in 2026 and should reflect the current year. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2848930206 test(build): remove tests that only exercise test-local mocks Remove `test_discover_yields_source`, `test_builder_processes_source`, and `test_builder_context_manager` — these only tested `ConcreteDiscover`/`ConcreteBuilder` defined in the test file, not production code. Real discover/builder subclasses are already tested in `test_python_cst.py`, `test_files.py`, `test_listing.py`, etc. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2848966048 Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2848978892 test(files): use `chota.min.css` for compound extension tests Replace `archive.tar.gz` with `chota.min.css` — a file docc actually ships — making the compound extension examples more realistic. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2854420589 revert: restore original `templates/**` globs in setup.cfg The explicit glob patterns were an unnecessary change — the original `templates/**` works correctly for both sdist and wheel builds. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2849661856 test(source): remove tests that pin buggy negative-index behaviour Remove `TestTextSourceBoundary` which asserted that `line(0)` and `line(-1)` silently return wrong lines via Python negative indexing. This is a bug in production code, not behaviour to enforce in tests. Addresses: https://github.com/SamWilsn/docc/pull/37#discussion_r2947794277 Update conftest.py Update test_mistletoe.py --- README.md | 67 +++ pyproject.toml | 16 + setup.cfg | 3 + tests/conftest.py | 24 +- tests/test_build_discover.py | 82 ++++ tests/test_cli.py | 383 +++++++++++++++ tests/test_context.py | 161 +++++++ tests/test_debug.py | 115 +++++ tests/test_document.py | 529 ++++++++++++++++++++ tests/test_files.py | 227 +++++++++ tests/test_html_e2e.py | 611 ++++++++++++++++++++++++ tests/test_html_nodes.py | 334 +++++++++++++ tests/test_html_parser.py | 330 +++++++++++++ tests/test_html_plugin.py | 342 +++++++++++++ tests/test_html_render.py | 355 ++++++++++++++ tests/test_integration.py | 850 +++++++++++++++++++++++++++++++++ tests/test_listing.py | 295 ++++++++++++ tests/test_loader.py | 123 +++++ tests/test_mistletoe.py | 401 +++++++++++++++- tests/test_mistletoe_render.py | 539 +++++++++++++++++++++ tests/test_python_cst.py | 490 +++++++++++++++++++ tests/test_references.py | 378 +++++++++++++++ tests/test_resources.py | 155 ++++++ tests/test_search.py | 436 +++++++++++++++++ tests/test_settings.py | 290 +++++++++++ tests/test_source.py | 161 +++++++ tests/test_transform.py | 90 ++++ tests/test_verbatim.py | 576 ++++++++++++++++++++++ tox.ini | 2 + whitelist.txt | 27 ++ 30 files changed, 8377 insertions(+), 15 deletions(-) create mode 100644 tests/test_build_discover.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_context.py create mode 100644 tests/test_debug.py create mode 100644 tests/test_document.py create mode 100644 tests/test_files.py create mode 100644 tests/test_html_e2e.py create mode 100644 tests/test_html_nodes.py create mode 100644 tests/test_html_parser.py create mode 100644 tests/test_html_plugin.py create mode 100644 tests/test_html_render.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_listing.py create mode 100644 tests/test_loader.py create mode 100644 tests/test_mistletoe_render.py create mode 100644 tests/test_python_cst.py create mode 100644 tests/test_references.py create mode 100644 tests/test_resources.py create mode 100644 tests/test_search.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_source.py create mode 100644 tests/test_transform.py create mode 100644 tests/test_verbatim.py diff --git a/README.md b/README.md index 6a006c0..dfee7b2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,73 @@ Finally, to generate the documentation: docc ``` +## Development + +### Setting Up + +Clone the repository with submodules: + +```bash +git clone --recurse-submodules https://github.com/SamWilsn/docc.git +cd docc +``` + +If you already cloned without submodules: + +```bash +git submodule update --init --recursive +``` + +Install in development mode to run tests and lint: + +```bash +pip install -e ".[test,lint]" +``` + +### Code Style + +This project uses: + +- **black** for code formatting (line length: 79). +- **isort** for import sorting (black profile). +- **flake8** for linting. +- **pyre** for type checking. + +Format code before committing: + +```bash +black src tests +isort src tests +``` + +### Running Tests + +```bash +pytest +``` + +Tests require 80% code coverage to pass. For a detailed coverage report: + +```bash +pytest --cov-report=html +``` + +The HTML report will be generated in `htmlcov/`. + +### Using Tox + +Run the full test suite with linting: + +```bash +tox +``` + +Run only type checking: + +```bash +tox -e type +``` + [docs-badge]: https://github.com/SamWilsn/docc/actions/workflows/gh-pages.yaml/badge.svg?branch=master [docs]: https://samwilsn.github.io/docc/ [`pyproject.toml`]: https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#declaring-project-metadata diff --git a/pyproject.toml b/pyproject.toml index c491fcd..ab6fe7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,19 @@ paths = [ "src" ] [tool.docc.output] path = "docs" + +[tool.pytest.ini_options] +# term-missing shows uncovered line numbers in terminal output +addopts = "--cov=docc --cov-report=term-missing" + +[tool.coverage.run] +source = ["src/docc"] +branch = true + +[tool.coverage.report] +fail_under = 80 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] diff --git a/setup.cfg b/setup.cfg index 5158a89..017dac1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -112,6 +112,9 @@ lint = flake8-bugbear>=25.10.21,<26.0.0 flake8>=7.3,<8 pytest>=8.4.2,<9 +test = + pytest>=8.4.2,<9 + pytest-cov>=6.0,<7 [flake8] dictionaries=en_US,python,technical diff --git a/tests/conftest.py b/tests/conftest.py index 6dc80ff..21bc0a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Ethereum Foundation +# Copyright (C) 2025-2026 Ethereum Foundation # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,6 +21,7 @@ from docc.context import Context from docc.document import Document, Node, Visit, Visitor +from docc.plugins.references import Reference from docc.settings import PluginSettings, Settings @@ -55,7 +56,7 @@ def _assert_in( haystack = haystack.root visitor = ContainsVisitor(matcher) haystack.visit(visitor) - assert visitor.found + assert visitor.found, f"No matching node found in tree: {haystack!r}" def _assert_not_in( @@ -105,3 +106,22 @@ def _make_context(root: Node) -> Context: @pytest.fixture def make_context() -> Callable[[Node], Context]: return _make_context + + +class ReferenceChecker(Visitor): + """Helper visitor to check for Reference nodes in a tree.""" + + def __init__(self) -> None: + self.found = False + self.count = 0 + + @override + def enter(self, node: Node) -> Visit: + if isinstance(node, Reference): + self.found = True + self.count += 1 + return Visit.TraverseChildren + + @override + def exit(self, node: Node) -> None: + pass diff --git a/tests/test_build_discover.py b/tests/test_build_discover.py new file mode 100644 index 0000000..3d88700 --- /dev/null +++ b/tests/test_build_discover.py @@ -0,0 +1,82 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pathlib import Path + +from docc.build import Builder +from docc.build import load as load_builders +from docc.discover import Discover +from docc.discover import load as load_discovers +from docc.settings import Settings + + +class TestLoadDiscovers: + def test_load_empty_discovery_list(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + {"tool": {"docc": {"discovery": []}}}, + ) + + result = list(load_discovers(settings)) + assert result == [] + + def test_load_single_discover(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "discovery": ["docc.python.discover"], + "plugins": { + "docc.python.discover": {"paths": [str(tmp_path)]} + }, + } + } + }, + ) + + result = list(load_discovers(settings)) + assert len(result) == 1 + assert result[0][0] == "docc.python.discover" + assert isinstance(result[0][1], Discover) + + +class TestLoadBuilders: + def test_load_empty_builder_list(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + {"tool": {"docc": {"build": []}}}, + ) + + result = list(load_builders(settings)) + assert result == [] + + def test_load_single_builder(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "build": ["docc.python.build"], + "plugins": {"docc.python.build": {}}, + } + } + }, + ) + + result = list(load_builders(settings)) + assert len(result) == 1 + assert result[0][0] == "docc.python.build" + assert isinstance(result[0][1], Builder) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..705a809 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,383 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +from io import StringIO, TextIOBase +from pathlib import Path +from typing import Tuple + +import pytest + +from docc.cli import _OutputVisitor, main +from docc.context import Context +from docc.document import ( + BlankNode, + ListNode, + Node, + OutputNode, + Visit, +) + + +class MockOutputNode(OutputNode): + def __init__( + self, content: str = "output content", ext: str = ".html" + ) -> None: + self._content = content + self._ext = ext + + @property + def children(self) -> Tuple[()]: + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return self._ext + + def output(self, context: Context, destination: TextIOBase) -> None: + destination.write(self._content) + + +class TestOutputVisitor: + def test_enter_output_node_calls_output(self) -> None: + output_node = MockOutputNode("test output") + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + result = visitor.enter(output_node) + + assert result == Visit.SkipChildren + assert destination.getvalue() == "test output" + + def test_enter_non_output_node_traverses(self) -> None: + node = BlankNode() + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + result = visitor.enter(node) + + assert result == Visit.TraverseChildren + + def test_enter_list_node_traverses(self) -> None: + node = ListNode([BlankNode()]) + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + result = visitor.enter(node) + + assert result == Visit.TraverseChildren + + def test_exit_does_nothing(self) -> None: + node = BlankNode() + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + result = visitor.exit(node) + + assert result is None, "exit() should return None" + assert ( + destination.getvalue() == "" + ), "exit() should not write to destination" + + +class TestMainFunction: + def test_main_requires_output_path( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[tool.docc]\n") + + monkeypatch.chdir(tmp_path) + with caplog.at_level(logging.CRITICAL): + with pytest.raises(SystemExit) as exc_info: + main([]) + assert exc_info.value.code == 1 + assert any( + "Output path is required" in r.message for r in caplog.records + ) + + def test_main_with_output_flag( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + src_dir = tmp_path / "src" + src_dir.mkdir() + py_file = src_dir / "example.py" + py_file.write_text('"""Module docstring."""\n') + + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + f""" +[tool.docc] +discovery = ["docc.python.discover"] +build = ["docc.python.build"] +transform = [ + "docc.listing.transform", + "docc.python.transform", + "docc.html.transform", +] +context = ["docc.listing.context", "docc.html.context"] + +[tool.docc.plugins."docc.python.discover"] +paths = ["{src_dir}"] + +[tool.docc.output] +path = "docs" +""" + ) + + output_dir = tmp_path / "output" + + monkeypatch.chdir(tmp_path) + main(["--output", str(output_dir)]) + + assert output_dir.exists(), "Output directory should be created" + + def test_main_uses_settings_output_path( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + src_dir = tmp_path / "src" + src_dir.mkdir() + py_file = src_dir / "example.py" + py_file.write_text('"""Module docstring."""\n') + + output_dir = tmp_path / "docs" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + f""" +[tool.docc] +discovery = ["docc.python.discover"] +build = ["docc.python.build"] +transform = [ + "docc.listing.transform", + "docc.python.transform", + "docc.html.transform", +] +context = ["docc.listing.context", "docc.html.context"] + +[tool.docc.plugins."docc.python.discover"] +paths = ["{src_dir}"] + +[tool.docc.output] +path = "{output_dir}" +""" + ) + + monkeypatch.chdir(tmp_path) + main([]) + + assert ( + output_dir.exists() + ), "Output directory should be created from settings" + + +class TestOutputVisitorWithNestedNodes: + def test_nested_output_nodes(self) -> None: + inner = MockOutputNode("inner") + outer_content = ListNode([inner]) + + class ContainerNode(OutputNode): + @property + def children(self) -> Tuple[ListNode]: + return (outer_content,) + + def replace_child(self, old: Node, new: Node) -> None: + raise NotImplementedError + + @property + def extension(self) -> str: + return ".html" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + destination.write("outer") + + container = ContainerNode() + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + container.visit(visitor) + + assert destination.getvalue() == "outer" + + def test_multiple_output_nodes(self) -> None: + first_node = MockOutputNode("first") + second_node = MockOutputNode("second") + root = ListNode([first_node, second_node]) + + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + root.visit(visitor) + + assert destination.getvalue() == "firstsecond" + + +def test_main_processes_python_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + src_dir = tmp_path / "src" + src_dir.mkdir() + + py_file = src_dir / "example.py" + py_file.write_text('"""Module docstring."""\n\ndef hello():\n pass\n') + + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + f""" +[tool.docc] +discovery = ["docc.python.discover"] +build = ["docc.python.build"] +transform = [ + "docc.listing.transform", + "docc.python.transform", + "docc.verbatim.transform", + "docc.references.index", + "docc.html.transform", +] +context = [ + "docc.listing.context", + "docc.html.context", + "docc.references.context", +] + +[tool.docc.plugins."docc.python.discover"] +paths = ["{src_dir}"] + +[tool.docc.output] +path = "docs" +""" + ) + + output_dir = tmp_path / "docs" + + monkeypatch.chdir(tmp_path) + main(["--output", str(output_dir)]) + + assert output_dir.exists(), "Output directory should be created" + html_files = list(output_dir.rglob("*.html")) + assert len(html_files) >= 1, "Should produce at least one HTML file" + content = html_files[0].read_text() + assert "Module docstring" in content + + +def test_main_empty_project( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.docc] +discovery = [] +build = [] +transform = [] +context = [] + +[tool.docc.output] +path = "docs" +""" + ) + + output_dir = tmp_path / "docs" + + monkeypatch.chdir(tmp_path) + main(["--output", str(output_dir)]) + + assert not output_dir.exists(), "No output should be created" + + +def test_main_duplicate_context_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + When two context plugins provide the same type, main() raises + an Exception about the conflict. + """ + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.docc] +discovery = [] +build = [] +transform = [] +context = ["docc.references.context", "docc.references.context"] + +[tool.docc.output] +path = "docs" +""" + ) + + monkeypatch.chdir(tmp_path) + with pytest.raises(Exception, match="conflicts with"): + main(["--output", str(tmp_path / "docs")]) + + +def test_main_document_without_extension_skipped( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + When a document has no extension (no OutputNode), the write + phase logs an error and skips it. + """ + pyproject = tmp_path / "pyproject.toml" + src_dir = tmp_path / "src" + src_dir.mkdir() + py_file = src_dir / "example.py" + py_file.write_text('"""Module docstring."""\n') + + pyproject.write_text( + f""" +[tool.docc] +discovery = ["docc.python.discover"] +build = ["docc.python.build"] +transform = [] +context = [] + +[tool.docc.plugins."docc.python.discover"] +paths = ["{src_dir}"] + +[tool.docc.output] +path = "docs" +""" + ) + + output_dir = tmp_path / "docs" + + monkeypatch.chdir(tmp_path) + with caplog.at_level(logging.ERROR): + main(["--output", str(output_dir)]) + + assert any( + "does not specify a file extension" in r.message + for r in caplog.records + ) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..85f746b --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,161 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pathlib import Path + +import pytest + +from docc.context import Context, load +from docc.settings import Settings + + +class TestContext: + def test_init_empty(self) -> None: + ctx = Context() + assert str not in ctx + + def test_init_with_items(self) -> None: + ctx = Context({str: "hello", int: 42}) + assert ctx[str] == "hello" + assert ctx[int] == 42 + + def test_init_with_none(self) -> None: + ctx = Context(None) + assert str not in ctx + with pytest.raises(KeyError): + ctx[str] + + def test_getitem_returns_value(self) -> None: + ctx = Context({str: "test"}) + assert ctx[str] == "test" + + def test_getitem_missing_raises(self) -> None: + ctx = Context({}) + with pytest.raises(KeyError): + ctx[str] + + def test_contains_true(self) -> None: + ctx = Context({str: "hello"}) + assert str in ctx + + def test_contains_false(self) -> None: + ctx = Context({}) + assert str not in ctx + + def test_init_validates_types(self) -> None: + with pytest.raises(ValueError, match="is not an instance"): + Context({str: 123}) + + def test_repr(self) -> None: + ctx = Context({str: "hello"}) + result = repr(ctx) + assert "Context" in result + assert "str" in result + + def test_multiple_types(self) -> None: + class CustomA: + pass + + class CustomB: + pass + + a = CustomA() + b = CustomB() + + ctx = Context({CustomA: a, CustomB: b}) + assert ctx[CustomA] is a + assert ctx[CustomB] is b + + def test_subclass_types(self) -> None: + class Base: + pass + + class Derived(Base): + pass + + d = Derived() + ctx = Context({Derived: d}) + assert ctx[Derived] is d + + def test_base_and_derived_stored_separately(self) -> None: + class Base: + pass + + class Derived(Base): + pass + + b = Base() + d = Derived() + ctx = Context({Base: b, Derived: d}) + assert ctx[Base] is b + assert ctx[Derived] is d + + def test_derived_stored_base_lookup_not_found(self) -> None: + """ + When a Derived instance is stored under its Derived key, + looking up by Base raises KeyError because Context uses exact + type matching on the dict key, not isinstance checks. + """ + + class Base: + pass + + class Derived(Base): + pass + + d = Derived() + ctx = Context({Derived: d}) + assert Base not in ctx + with pytest.raises(KeyError): + ctx[Base] + + +class TestContextLoad: + def test_load_empty_context_list(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + {"tool": {"docc": {"context": []}}}, + ) + + result = list(load(settings)) + assert result == [] + + def test_load_single_context(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + {"tool": {"docc": {"context": ["docc.references.context"]}}}, + ) + + result = list(load(settings)) + assert len(result) == 1 + assert result[0][0] == "docc.references.context" + + def test_load_multiple_contexts(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "context": [ + "docc.references.context", + "docc.search.context", + ] + } + } + }, + ) + + result = list(load(settings)) + assert len(result) == 2 diff --git a/tests/test_debug.py b/tests/test_debug.py new file mode 100644 index 0000000..37b388e --- /dev/null +++ b/tests/test_debug.py @@ -0,0 +1,115 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from io import StringIO +from pathlib import Path + +import pytest + +from docc.context import Context +from docc.document import BlankNode, Document, ListNode +from docc.plugins.debug import DebugNode, DebugTransform +from docc.settings import PluginSettings, Settings + + +@pytest.fixture +def plugin_settings() -> PluginSettings: + settings = Settings(Path("."), {"tool": {"docc": {}}}) + return settings.for_plugin("docc.debug.transform") + + +class TestDebugNode: + def test_init(self) -> None: + child = BlankNode() + node = DebugNode(child) + assert node.child is child + + def test_children(self) -> None: + child = BlankNode() + node = DebugNode(child) + children = tuple(node.children) + assert children == (child,) + + def test_replace_child(self) -> None: + old_child = BlankNode() + new_child = BlankNode() + node = DebugNode(old_child) + + node.replace_child(old_child, new_child) + assert node.child is new_child + + def test_replace_child_no_match(self) -> None: + child = BlankNode() + other = BlankNode() + new_child = BlankNode() + node = DebugNode(child) + + node.replace_child(other, new_child) + assert node.child is child + + def test_extension(self) -> None: + node = DebugNode(BlankNode()) + assert node.extension == ".txt" + + def test_output(self) -> None: + child = BlankNode() + node = DebugNode(child) + context = Context({}) + destination = StringIO() + + node.output(context, destination) + + result = destination.getvalue() + assert "" in result + + def test_output_nested(self) -> None: + inner = BlankNode() + outer = ListNode([inner]) + node = DebugNode(outer) + context = Context({}) + destination = StringIO() + + node.output(context, destination) + + result = destination.getvalue() + assert "" in result + assert "" in result + + +class TestDebugTransform: + def test_transform(self, plugin_settings: PluginSettings) -> None: + root = BlankNode() + document = Document(root) + context = Context({Document: document}) + + transform = DebugTransform(plugin_settings) + transform.transform(context) + + assert isinstance(document.root, DebugNode) + assert document.root.child is root + + def test_transform_with_nested( + self, plugin_settings: PluginSettings + ) -> None: + inner = BlankNode() + root = ListNode([inner]) + document = Document(root) + context = Context({Document: document}) + + transform = DebugTransform(plugin_settings) + transform.transform(context) + + assert isinstance(document.root, DebugNode) + assert document.root.child is root diff --git a/tests/test_document.py b/tests/test_document.py new file mode 100644 index 0000000..55fdaa2 --- /dev/null +++ b/tests/test_document.py @@ -0,0 +1,529 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from io import StringIO +from typing import ClassVar, List, Literal, Tuple + +import pytest +from typing_extensions import override + +from docc.document import ( + BlankNode, + Document, + ListNode, + Node, + OutputNode, + Visit, + Visitor, + _ExtensionVisitor, + _StrVisitor, +) + +Event = Tuple[Literal["enter", "exit"], int] + + +@pytest.mark.parametrize( + "node, expected_repr", + [ + (BlankNode(), ""), + (ListNode(), ""), + ], + ids=["BlankNode", "ListNode"], +) +def test_node_children_iterable(node: Node, expected_repr: str) -> None: + assert hasattr(node.children, "__iter__") + + +@pytest.mark.parametrize( + "node, expected_repr", + [ + (BlankNode(), ""), + (ListNode(), ""), + ], + ids=["BlankNode", "ListNode"], +) +def test_node_repr(node: Node, expected_repr: str) -> None: + assert repr(node) == expected_repr + + +class TestBlankNode: + def test_children_returns_empty_tuple(self) -> None: + node = BlankNode() + assert tuple(node.children) == () + + def test_replace_child_raises_type_error(self) -> None: + node = BlankNode() + with pytest.raises(TypeError): + node.replace_child(BlankNode(), BlankNode()) + + def test_bool_is_false(self) -> None: + node = BlankNode() + assert bool(node) is False + + +class TestListNode: + def test_children_property_returns_list(self) -> None: + first_child, second_child = BlankNode(), BlankNode() + node = ListNode([first_child, second_child]) + assert list(node.children) == [first_child, second_child] + + def test_default_children_is_empty(self) -> None: + node = ListNode() + assert list(node.children) == [] + + def test_replace_child(self) -> None: + old_child = BlankNode() + new_child = BlankNode() + other_child = BlankNode() + node = ListNode([old_child, other_child]) + + node.replace_child(old_child, new_child) + + assert list(node.children) == [new_child, other_child] + + def test_replace_child_when_not_found(self) -> None: + old_child = BlankNode() + new_child = BlankNode() + other_child = BlankNode() + node = ListNode([other_child]) + + node.replace_child(old_child, new_child) + assert list(node.children) == [other_child] + + def test_bool_true_when_has_children(self) -> None: + node = ListNode([BlankNode()]) + assert bool(node) is True + + def test_bool_false_when_empty(self) -> None: + node = ListNode() + assert bool(node) is False + + def test_iter(self) -> None: + children: List[Node] = [BlankNode(), BlankNode()] + node = ListNode(children) + assert list(node.children) == children + + def test_len(self) -> None: + node = ListNode([BlankNode(), BlankNode(), BlankNode()]) + assert len(node) == 3 + + +class RecordingVisitor(Visitor): + returns: ClassVar[Visit] = Visit.TraverseChildren + events: List[Event] + + def __init__(self) -> None: + self.events = [] + + @override + def enter(self, node: Node) -> Visit: + self.events.append(("enter", id(node))) + return self.returns + + @override + def exit(self, node: Node) -> None: + self.events.append(("exit", id(node))) + + +class SkippingVisitor(RecordingVisitor): + returns: ClassVar[Visit] = Visit.SkipChildren + + +class TestNodeVisit: + def test_visit_single_node(self) -> None: + node = BlankNode() + visitor = RecordingVisitor() + node.visit(visitor) + + assert visitor.events == [ + ("enter", id(node)), + ("exit", id(node)), + ] + + def test_visit_with_children(self) -> None: + first_child = BlankNode() + second_child = BlankNode() + parent = ListNode([first_child, second_child]) + + visitor = RecordingVisitor() + parent.visit(visitor) + + assert visitor.events == [ + ("enter", id(parent)), + ("enter", id(first_child)), + ("exit", id(first_child)), + ("enter", id(second_child)), + ("exit", id(second_child)), + ("exit", id(parent)), + ] + + def test_visit_skip_children(self) -> None: + child = BlankNode() + parent = ListNode([child]) + + visitor = SkippingVisitor() + parent.visit(visitor) + + assert visitor.events == [ + ("enter", id(parent)), + ("exit", id(parent)), + ] + + def test_visit_nested_structure(self) -> None: + leaf = BlankNode() + inner = ListNode([leaf]) + outer = ListNode([inner]) + + visitor = RecordingVisitor() + outer.visit(visitor) + + assert visitor.events == [ + ("enter", id(outer)), + ("enter", id(inner)), + ("enter", id(leaf)), + ("exit", id(leaf)), + ("exit", id(inner)), + ("exit", id(outer)), + ] + + def test_visit_depth_first(self) -> None: + first_leaf = BlankNode() + second_leaf = BlankNode() + third_leaf = BlankNode() + first_branch = ListNode([first_leaf, second_leaf]) + second_branch = ListNode([third_leaf]) + root = ListNode([first_branch, second_branch]) + + visitor = RecordingVisitor() + root.visit(visitor) + + enter_events = [e for e in visitor.events if e[0] == "enter"] + assert enter_events[0] == ("enter", id(root)) + assert enter_events[1] == ("enter", id(first_branch)) + assert enter_events[2] == ("enter", id(first_leaf)) + + +class TestNodeDump: + def test_dump_to_stringio(self) -> None: + node = BlankNode() + output = StringIO() + node.dump(file=output) + result = output.getvalue() + assert "" in result + + def test_dumps_returns_string(self) -> None: + node = BlankNode() + result = node.dumps() + assert isinstance(result, str) + assert "" in result + + def test_dump_nested_structure(self) -> None: + child = BlankNode() + parent = ListNode([child]) + + result = parent.dumps() + assert "" in result + assert "" in result + + +class TestStrVisitor: + def test_builds_rich_tree(self) -> None: + node = BlankNode() + visitor = _StrVisitor() + node.visit(visitor) + + assert visitor.root is not None + assert "" in str(visitor.root.label) + + def test_nested_tree(self) -> None: + child = BlankNode() + parent = ListNode([child]) + + visitor = _StrVisitor() + parent.visit(visitor) + + assert visitor.root is not None + + +class TestDocument: + def test_init_with_root(self) -> None: + root = BlankNode() + doc = Document(root) + assert doc.root is root + + def test_extension_returns_none_when_no_output_nodes(self) -> None: + root = BlankNode() + doc = Document(root) + assert doc.extension() is None + + def test_extension_returns_extension_from_output_node(self) -> None: + from io import TextIOBase + + from docc.context import Context + + class TestOutputNode(OutputNode): + @property + def children(self): + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return ".test" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + pass + + root = TestOutputNode() + doc = Document(root) + assert doc.extension() == ".test" + + +class TestExtensionVisitor: + def test_finds_extension(self) -> None: + from io import TextIOBase + + from docc.context import Context + + class TestOutputNode(OutputNode): + @property + def children(self): + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return ".html" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + pass + + root = TestOutputNode() + visitor = _ExtensionVisitor() + root.visit(visitor) + + assert visitor.extension == ".html" + + def test_returns_none_when_no_output_nodes(self) -> None: + root = BlankNode() + visitor = _ExtensionVisitor() + root.visit(visitor) + + assert visitor.extension is None + + def test_conflicting_extensions_logs_warning( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """ + When two OutputNodes have different extensions, the visitor + logs a warning and keeps the first extension. + """ + import logging + from io import TextIOBase + + from docc.context import Context + + class HtmlOutputNode(OutputNode): + @property + def children(self): + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return ".html" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + pass + + class TxtOutputNode(OutputNode): + @property + def children(self): + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return ".txt" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + pass + + root = ListNode([HtmlOutputNode(), TxtOutputNode()]) + visitor = _ExtensionVisitor() + + with caplog.at_level(logging.WARNING): + root.visit(visitor) + + # The first extension is kept + assert visitor.extension == ".html" + # A warning was logged about the conflict + assert any("extension" in r.message for r in caplog.records) + + +class ConditionalSkipVisitor(RecordingVisitor): + skip_after_first: bool + + def __init__(self, skip_after_first: bool = False) -> None: + super().__init__() + self.skip_after_first = skip_after_first + self._first_seen = False + + @override + def enter(self, node: Node) -> Visit: + self.events.append(("enter", id(node))) + if self.skip_after_first and not self._first_seen: + self._first_seen = True + return Visit.TraverseChildren + elif self.skip_after_first: + return Visit.SkipChildren + return Visit.TraverseChildren + + +class TestVisitorEdgeCases: + def test_visit_empty_list_node(self) -> None: + node = ListNode([]) + visitor = RecordingVisitor() + node.visit(visitor) + + assert visitor.events == [ + ("enter", id(node)), + ("exit", id(node)), + ] + + def test_visit_deeply_nested(self) -> None: + node: Node = BlankNode() + for _ in range(10): + node = ListNode([node]) + + visitor = RecordingVisitor() + node.visit(visitor) + + enter_count = sum(1 for e in visitor.events if e[0] == "enter") + assert enter_count == 11 + + def test_visit_wide_tree(self) -> None: + children: List[Node] = [BlankNode() for _ in range(100)] + node = ListNode(children) + + visitor = RecordingVisitor() + node.visit(visitor) + + enter_count = sum( + 1 + for e in visitor.events + if e[0] == "enter" and e != ("enter", id(node)) + ) + assert enter_count == 100 + + def test_conditional_skip(self) -> None: + grandchild = BlankNode() + child = ListNode([grandchild]) + parent = ListNode([child]) + + visitor = ConditionalSkipVisitor(skip_after_first=True) + parent.visit(visitor) + + assert ("enter", id(grandchild)) not in visitor.events + + +class ModifyingVisitor(Visitor): + def __init__(self, old: Node, new: Node) -> None: + self.old = old + self.new = new + self.stack: List[Node] = [] + + @override + def enter(self, node: Node) -> Visit: + self.stack.append(node) + return Visit.TraverseChildren + + @override + def exit(self, node: Node) -> None: + self.stack.pop() + if node == self.old and self.stack: + self.stack[-1].replace_child(self.old, self.new) + + +class SkipSpecificChildVisitor(Visitor): + """Visitor that returns SkipChildren for a specific child node.""" + + enter_calls: List[Node] + exit_calls: List[Node] + skip_target: Node + + def __init__(self, skip_target: Node) -> None: + self.enter_calls = [] + self.exit_calls = [] + self.skip_target = skip_target + + @override + def enter(self, node: Node) -> Visit: + self.enter_calls.append(node) + if node is self.skip_target: + return Visit.SkipChildren + return Visit.TraverseChildren + + @override + def exit(self, node: Node) -> None: + self.exit_calls.append(node) + + +def test_visit_skip_children_calls_exit() -> None: + grandchild = BlankNode() + skipped_child = ListNode([grandchild]) + other_child = BlankNode() + parent = ListNode([skipped_child, other_child]) + + visitor = SkipSpecificChildVisitor(skip_target=skipped_child) + parent.visit(visitor) + + # exit must be called for the child that returned SkipChildren + assert skipped_child in visitor.exit_calls + + # The grandchild should NOT have been entered (children were skipped) + assert grandchild not in visitor.enter_calls + + # Both the skipped child and the other child should have exit called + assert other_child in visitor.exit_calls + assert parent in visitor.exit_calls + + +def test_visit_replace_during_exit() -> None: + old_child = BlankNode() + new_child = BlankNode() + parent = ListNode([old_child]) + + visitor = ModifyingVisitor(old_child, new_child) + parent.visit(visitor) + + assert old_child not in parent.children + assert new_child in parent.children diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..9ad7737 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,227 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from io import StringIO +from pathlib import Path, PurePath +from typing import Dict, Optional, Set + +import pytest + +from docc.context import Context +from docc.document import BlankNode, Document +from docc.plugins.files import ( + FileNode, + FilesBuilder, + FilesDiscover, + FileSource, +) +from docc.settings import PluginSettings, Settings +from docc.source import Source + + +@pytest.fixture +def plugin_settings(tmp_path: Path) -> PluginSettings: + settings = Settings(tmp_path, {"tool": {"docc": {}}}) + return settings.for_plugin("docc.files.discover") + + +class TestFileSource: + def test_init(self) -> None: + relative = PurePath("src/file.txt") + absolute = PurePath("/path/to/src/file.txt") + source = FileSource(relative, absolute) + + assert source.relative_path == relative + assert source.absolute_path == absolute + + def test_output_path_removes_suffix(self) -> None: + relative = PurePath("docs/readme.md") + absolute = PurePath("/path/docs/readme.md") + source = FileSource(relative, absolute) + + assert source.output_path == PurePath("docs/readme") + + def test_output_path_compound_extension(self) -> None: + relative = PurePath("static/chota.min.css") + absolute = PurePath("/path/static/chota.min.css") + source = FileSource(relative, absolute) + + assert source.output_path == PurePath("static/chota.min") + + def test_output_path_no_suffix(self) -> None: + relative = PurePath("docs/readme") + absolute = PurePath("/path/docs/readme") + source = FileSource(relative, absolute) + + assert source.output_path == PurePath("docs/readme") + + +class TestFileNode: + def test_init(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + node = FileNode(file_path) + assert node.path == file_path + + def test_children_empty(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + node = FileNode(file_path) + assert node.children == () + + def test_replace_child_raises(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + node = FileNode(file_path) + with pytest.raises(TypeError): + node.replace_child(BlankNode(), BlankNode()) + + def test_extension(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + node = FileNode(file_path) + assert node.extension == ".txt" + + def test_extension_multiple_suffixes(self, tmp_path: Path) -> None: + file_path = tmp_path / "chota.min.css" + file_path.write_text("content") + + node = FileNode(file_path) + assert node.extension == ".css" + + def test_output(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("file content here") + + node = FileNode(file_path) + context = Context({}) + destination = StringIO() + + node.output(context, destination) + + assert destination.getvalue() == "file content here" + + +class TestFilesBuilder: + def test_build_processes_file_sources( + self, tmp_path: Path, plugin_settings: PluginSettings + ) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + source = FileSource(PurePath("test.txt"), file_path) + unprocessed: Set[Source] = {source} + processed: Dict[Source, Document] = {} + + builder = FilesBuilder(plugin_settings) + builder.build(unprocessed, processed) + + assert source in processed + assert len(processed) == 1 + assert len(unprocessed) == 0 + assert isinstance(processed[source].root, FileNode) + + def test_build_ignores_non_file_sources( + self, plugin_settings: PluginSettings + ) -> None: + class OtherSource(Source): + @property + def relative_path(self) -> Optional[PurePath]: + return PurePath("other.py") + + @property + def output_path(self) -> PurePath: + return PurePath("other.py") + + source = OtherSource() + unprocessed: Set[Source] = {source} + processed: Dict[Source, Document] = {} + + builder = FilesBuilder(plugin_settings) + builder.build(unprocessed, processed) + + assert source in unprocessed + assert len(processed) == 0 + + +class TestFilesDiscover: + def test_init_no_files(self, tmp_path: Path) -> None: + settings = Settings(tmp_path, {"tool": {"docc": {}}}) + plugin_settings = settings.for_plugin("docc.files.discover") + + discover = FilesDiscover(plugin_settings) + assert discover.sources == [] + + def test_init_with_files(self, tmp_path: Path) -> None: + first_file = tmp_path / "file1.txt" + first_file.write_text("content1") + second_file = tmp_path / "file2.txt" + second_file.write_text("content2") + + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "plugins": { + "docc.files.discover": { + "files": ["file1.txt", "file2.txt"] + } + } + } + } + }, + ) + plugin_settings = settings.for_plugin("docc.files.discover") + + discover = FilesDiscover(plugin_settings) + assert len(discover.sources) == 2 + + def test_discover_yields_sources(self, tmp_path: Path) -> None: + first_file = tmp_path / "file1.txt" + first_file.write_text("content") + + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "plugins": { + "docc.files.discover": {"files": ["file1.txt"]} + } + } + } + }, + ) + plugin_settings = settings.for_plugin("docc.files.discover") + + discover = FilesDiscover(plugin_settings) + sources = list(discover.discover(frozenset())) + + assert len(sources) == 1 + assert isinstance(sources[0], FileSource) + + def test_discover_empty_when_no_files(self, tmp_path: Path) -> None: + settings = Settings(tmp_path, {"tool": {"docc": {}}}) + plugin_settings = settings.for_plugin("docc.files.discover") + + discover = FilesDiscover(plugin_settings) + sources = list(discover.discover(frozenset())) + + assert sources == [] diff --git a/tests/test_html_e2e.py b/tests/test_html_e2e.py new file mode 100644 index 0000000..e116e0c --- /dev/null +++ b/tests/test_html_e2e.py @@ -0,0 +1,611 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +End-to-end tests for the HTML rendering pipeline. + +These tests exercise the full docc pipeline path through HTML rendering: +discover -> build -> context -> transform -> HTML render -> file output. +""" + +from io import StringIO +from pathlib import Path, PurePath +from typing import Optional + +from docc.context import Context +from docc.document import BlankNode, Document, ListNode +from docc.plugins.html import ( + HTML, + HTMLRoot, + HTMLTag, + HTMLTransform, + TextNode, + blank_node, + html_tag, + list_node, + references_definition, + references_reference, + render_reference, + text_node, +) +from docc.plugins.references import ( + Definition, + Index, + IndexTransform, + Reference, +) +from docc.settings import PluginSettings, Settings +from docc.source import Source + +_EMPTY_PLUGIN_SETTINGS: PluginSettings = Settings( + Path("."), {"tool": {"docc": {}}} +).for_plugin("docc.html") + + +class _MockSource(Source): + """A minimal Source implementation for testing.""" + + _relative_path: Optional[PurePath] + _output_path: PurePath + + def __init__( + self, + output_path: Optional[PurePath] = None, + relative_path: Optional[PurePath] = None, + ) -> None: + self._output_path = ( + output_path if output_path is not None else PurePath("test.py") + ) + self._relative_path = ( + relative_path if relative_path is not None else self._output_path + ) + + @property + def relative_path(self) -> Optional[PurePath]: + return self._relative_path + + @property + def output_path(self) -> PurePath: + return self._output_path + + +class TestHTMLTransformProducesHTMLRoot: + """ + Test that HTMLTransform replaces a document tree with an HTMLRoot. + + This exercises the pipeline step where the document tree (containing + Definition, BlankNode, ListNode, etc.) is converted into an HTML tree + via HTMLVisitor and the entry-point-based renderer lookup. + """ + + def test_blank_node_becomes_html_root(self) -> None: + """A document containing a BlankNode becomes an HTMLRoot.""" + blank = BlankNode() + document = Document(blank) + context = Context({Document: document}) + + transform = HTMLTransform(_EMPTY_PLUGIN_SETTINGS) + transform.transform(context) + + assert isinstance(document.root, HTMLRoot) + + def test_list_with_blanks_becomes_html_root(self) -> None: + """A document with a ListNode of BlankNodes becomes an HTMLRoot.""" + tree = ListNode([BlankNode(), BlankNode(), BlankNode()]) + document = Document(tree) + context = Context({Document: document}) + + transform = HTMLTransform(_EMPTY_PLUGIN_SETTINGS) + transform.transform(context) + + assert isinstance(document.root, HTMLRoot) + + def test_definition_with_blank_becomes_html_root(self) -> None: + """ + A document containing a Definition wrapping a BlankNode is + transformed into an HTMLRoot. + + The Definition renderer (references_definition) internally creates + its own HTMLVisitor to render children, so this exercises the + nested visitor path through the entry point system. + """ + source = _MockSource(PurePath("docs/module.py")) + index = Index() + + definition = Definition( + identifier="my_module.MyClass", child=BlankNode() + ) + + document = Document(definition) + context = Context({Document: document, Source: source, Index: index}) + + # First apply IndexTransform to assign specifiers. + index_transform = IndexTransform(_EMPTY_PLUGIN_SETTINGS) + index_transform.transform(context) + assert definition.specifier == 0 + + # Then apply HTMLTransform. + html_transform = HTMLTransform(_EMPTY_PLUGIN_SETTINGS) + html_transform.transform(context) + + assert isinstance(document.root, HTMLRoot) + + def test_transform_skips_already_output_node(self) -> None: + """ + If the document root is already an OutputNode (e.g. HTMLRoot), + HTMLTransform leaves it unchanged. + """ + context = Context({}) + existing_root = HTMLRoot(context) + existing_root.append(HTMLTag("div")) + document = Document(existing_root) + context = Context({Document: document}) + + transform = HTMLTransform(_EMPTY_PLUGIN_SETTINGS) + transform.transform(context) + + assert document.root is existing_root + + +class TestHTMLRootOutputProducesValidHTML: + """ + Test that HTMLRoot.output() renders a full HTML document string + containing the expected structural elements from the Jinja2 template. + """ + + def test_basic_output_contains_html_structure(self) -> None: + """ + A manually constructed HTMLRoot with an HTMLTag child produces + a complete HTML document with DOCTYPE, head, and body. + """ + source = _MockSource(PurePath("docs/page.py")) + context = Context({Source: source}) + root = HTMLRoot(context) + + div = HTMLTag("div", {"class": "content"}) + div.append(TextNode("Hello from docc")) + root.append(div) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "" in output + assert "" in output + assert "" in output + assert "" in output + assert "" in output + assert " None: + """The rendered HTML includes links to chota and docc CSS.""" + source = _MockSource(PurePath("docs/page.py")) + context = Context({Source: source}) + root = HTMLRoot(context) + root.append(HTMLTag("p")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "chota.min.css" in output + assert "docc.css" in output + + def test_output_with_extra_css(self) -> None: + """Extra CSS files configured via HTML context appear in output.""" + source = _MockSource(PurePath("index.py")) + html_config = HTML(extra_css=["custom.css"], breadcrumbs=True) + context = Context({Source: source, HTML: html_config}) + root = HTMLRoot(context) + root.append(HTMLTag("p")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "custom.css" in output + + def test_output_renders_text_node_children(self) -> None: + """Render TextNode children directly into the body.""" + source = _MockSource(PurePath("page.py")) + context = Context({Source: source}) + root = HTMLRoot(context) + root.append(TextNode("Raw text content")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "Raw text content" in output + assert "" in output + + def test_output_breadcrumbs_for_nested_path(self) -> None: + """Breadcrumbs are rendered for documents in nested directories.""" + source = _MockSource(PurePath("a/b/c/page.py")) + context = Context({Source: source}) + root = HTMLRoot(context) + root.append(HTMLTag("section")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "breadcrumbs" in output + assert "page.py" in output + + def test_output_no_breadcrumbs_when_disabled(self) -> None: + """Breadcrumbs section is absent when breadcrumbs=False.""" + source = _MockSource(PurePath("a/b/page.py")) + html_config = HTML(extra_css=[], breadcrumbs=False) + context = Context({Source: source, HTML: html_config}) + root = HTMLRoot(context) + root.append(HTMLTag("div")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "breadcrumbs" not in output + + def test_output_nested_html_tags_produce_markup(self) -> None: + """Nested HTMLTag structures are correctly serialized.""" + source = _MockSource(PurePath("page.py")) + context = Context({Source: source}) + root = HTMLRoot(context) + + section = HTMLTag("section", {"id": "main"}) + heading = HTMLTag("h1") + heading.append(TextNode("Title")) + section.append(heading) + paragraph = HTMLTag("p") + paragraph.append(TextNode("Body text")) + section.append(paragraph) + root.append(section) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "" in output + assert "Title" in output + assert "

" in output + assert "Body text" in output + + +class TestDefinitionRendersWithId: + """ + Test that rendering a Definition node produces HTML with an id + attribute matching the definition's identifier and specifier. + """ + + def test_definition_blank_child_gets_id(self) -> None: + """ + Calling references_definition directly with a Definition whose + child is a BlankNode produces a span with the correct id. + """ + source = _MockSource(PurePath("docs/module.py")) + index = Index() + location = index.define(source, "my_func") + + definition = Definition( + identifier="my_func", + child=BlankNode(), + specifier=location.specifier, + ) + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + result = references_definition(context, parent, definition) + # references_definition appends to parent and returns None. + assert result is None + + children = list(parent.children) + assert len(children) >= 1 + + first = children[0] + assert isinstance(first, HTMLTag) + assert first.tag_name == "span" + assert first.attributes.get("id") == "my_func:0" + + def test_definition_with_list_child_gets_id(self) -> None: + """ + A Definition wrapping a ListNode containing an HTMLTag gets + the id applied to the first rendered child element. + """ + source = _MockSource(PurePath("docs/module.py")) + index = Index() + location = index.define(source, "MyClass") + + # The child is an HTMLTag so that after HTMLVisitor renders + # the ListNode's children, the first child is already an HTMLTag. + inner_tag = HTMLTag("div", {"class": "class-def"}) + inner_tag.append(TextNode("MyClass")) + child_list = ListNode([inner_tag]) + + definition = Definition( + identifier="MyClass", + child=child_list, + specifier=location.specifier, + ) + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + references_definition(context, parent, definition) + + children = list(parent.children) + assert len(children) >= 1 + + first = children[0] + assert isinstance(first, HTMLTag) + assert first.attributes.get("id") == "MyClass:0" + + def test_definition_text_child_wrapped_in_span(self) -> None: + """ + When the first rendered child is a TextNode, it gets wrapped + in a and the id is set on the span. + """ + source = _MockSource(PurePath("docs/module.py")) + index = Index() + location = index.define(source, "some_var") + + text = TextNode("some_var") + inner_tag = HTMLTag("p") + inner_tag.append(text) + child_list = ListNode([inner_tag]) + + definition = Definition( + identifier="some_var", + child=child_list, + specifier=location.specifier, + ) + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + references_definition(context, parent, definition) + + children = list(parent.children) + assert len(children) >= 1 + + first = children[0] + assert isinstance(first, HTMLTag) + assert first.attributes.get("id") == "some_var:0" + + +class TestReferenceRendersAsLink: + """ + Test that rendering a Reference produces an tag linking to the + definition's location. + """ + + def test_single_definition_produces_anchor(self) -> None: + """ + render_reference with a single definition produces an tag + whose href includes the definition path and fragment. + """ + source = _MockSource(PurePath("docs/caller.py")) + def_source = _MockSource(PurePath("docs/target.py")) + + index = Index() + index.define(def_source, "target_func") + + context = Context({Source: source, Index: index}) + ref = Reference(identifier="target_func") + + anchor = render_reference(context, ref) + + assert isinstance(anchor, HTMLTag) + assert anchor.tag_name == "a" + href = anchor.attributes.get("href", "") + assert href is not None + assert "target_func:0" in href + assert "target.py.html" in href + + def test_same_file_reference_has_fragment_only(self) -> None: + """ + When the reference and definition are in the same source file, + the href is just a fragment (no file path component). + """ + source = _MockSource(PurePath("docs/module.py")) + + index = Index() + index.define(source, "local_func") + + context = Context({Source: source, Index: index}) + ref = Reference(identifier="local_func") + + anchor = render_reference(context, ref) + + assert isinstance(anchor, HTMLTag) + href = anchor.attributes.get("href", "") + assert href is not None + assert "local_func:0" in href + + def test_multiple_definitions_produce_tooltip(self) -> None: + """ + When there are multiple definitions for one identifier, the + result is a tooltip div containing multiple anchor tags. + """ + source = _MockSource(PurePath("docs/caller.py")) + def_source_a = _MockSource(PurePath("docs/module_a.py")) + def_source_b = _MockSource(PurePath("docs/module_b.py")) + + index = Index() + index.define(def_source_a, "overloaded") + index.define(def_source_b, "overloaded") + + context = Context({Source: source, Index: index}) + ref = Reference(identifier="overloaded") + + container = render_reference(context, ref) + + assert isinstance(container, HTMLTag) + assert container.tag_name == "div" + assert container.attributes.get("class") == "tooltip" + + tooltip_content = list(container.children)[0] + assert isinstance(tooltip_content, HTMLTag) + assert tooltip_content.attributes.get("class") == "tooltip-content" + + anchors = list(tooltip_content.children) + assert len(anchors) == 2 + for a in anchors: + assert isinstance(a, HTMLTag) + assert a.tag_name == "a" + href = a.attributes.get("href", "") + assert href is not None + assert "overloaded" in href + + def test_references_reference_appends_anchor(self) -> None: + """ + The references_reference render function appends an anchor to the + parent and returns it for child traversal when a child is present. + """ + source = _MockSource(PurePath("docs/page.py")) + def_source = _MockSource(PurePath("docs/target.py")) + + index = Index() + index.define(def_source, "func") + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + child_node = HTMLTag("code") + child_node.append(TextNode("func")) + ref = Reference(identifier="func", child=child_node) + + result = references_reference(context, parent, ref) + + # When the reference has a child, the anchor is returned so + # the visitor can traverse into it. + assert isinstance(result, HTMLTag) + assert result.tag_name == "a" + assert result in parent.children + + def test_references_reference_no_child_appends_text(self) -> None: + """ + When a Reference has no meaningful child (BlankNode), the anchor + gets a TextNode with the identifier and returns None. + """ + source = _MockSource(PurePath("docs/page.py")) + def_source = _MockSource(PurePath("docs/target.py")) + + index = Index() + index.define(def_source, "some_id") + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + ref = Reference(identifier="some_id") + + result = references_reference(context, parent, ref) + + assert result is None + children = list(parent.children) + assert len(children) == 1 + anchor = children[0] + assert isinstance(anchor, HTMLTag) + assert anchor.tag_name == "a" + + anchor_children = list(anchor.children) + assert len(anchor_children) == 1 + text_child = anchor_children[0] + assert isinstance(text_child, TextNode) + assert text_child._value == "some_id" + + +class TestFullPipelineHTMLOutput: + """ + Integration test: build a document tree, apply transforms, render + to an HTML string, and verify the output contains all expected + structural and content elements. + """ + + def test_definition_to_html_output(self) -> None: + """ + Full pipeline: Definition with BlankNode -> IndexTransform -> + HTMLTransform -> HTMLRoot.output() -> HTML string. + """ + source = _MockSource(PurePath("docs/example.py")) + index = Index() + + definition = Definition( + identifier="example.hello", + child=BlankNode(), + ) + tree = ListNode([definition]) + document = Document(tree) + + context = Context({Document: document, Source: source, Index: index}) + + # Step 1: IndexTransform assigns specifiers. + index_transform = IndexTransform(_EMPTY_PLUGIN_SETTINGS) + index_transform.transform(context) + assert definition.specifier == 0 + + # Step 2: HTMLTransform converts the tree to HTML. + html_transform = HTMLTransform(_EMPTY_PLUGIN_SETTINGS) + html_transform.transform(context) + root = document.root + assert isinstance(root, HTMLRoot) + + # Step 3: Output the HTML to a string. + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + # Verify full HTML document structure. + assert "" in output + assert "" in output + assert "" in output + assert "" in output + assert "" in output + assert " None: + """ + Test the individual render helper functions (blank_node, + list_node, html_tag, text_node) that form the foundation of + the HTML rendering pipeline. + """ + context = Context({}) + root = HTMLRoot(context) + + # blank_node returns None and does not modify parent. + assert blank_node(context, root, BlankNode()) is None + assert len(list(root.children)) == 0 + + # list_node returns the parent (for child traversal). + ln = ListNode() + assert list_node(context, root, ln) is root + + # html_tag appends the tag and returns None. + tag = HTMLTag("div", {"class": "test"}) + assert html_tag(context, root, tag) is None + assert tag in root.children + + # text_node appends the text and returns None. + tn = TextNode("hello") + assert text_node(context, root, tn) is None + assert tn in root.children diff --git a/tests/test_html_nodes.py b/tests/test_html_nodes.py new file mode 100644 index 0000000..e8cd8bd --- /dev/null +++ b/tests/test_html_nodes.py @@ -0,0 +1,334 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for HTML node types: TextNode, HTMLTag, HTMLRoot.""" + +import pytest + +from docc.context import Context +from docc.document import BlankNode +from docc.plugins.html import HTMLRoot, HTMLTag, TextNode + +# --------------------------------------------------------------------------- +# TextNode +# --------------------------------------------------------------------------- + + +def test_text_node_init() -> None: + node = TextNode("hello") + assert node._value == "hello" + + +def test_text_node_children_empty() -> None: + node = TextNode("test") + assert tuple(node.children) == () + + +def test_text_node_replace_child_raises() -> None: + node = TextNode("test") + with pytest.raises(TypeError, match="text nodes have no children"): + node.replace_child(BlankNode(), BlankNode()) + + +def test_text_node_repr() -> None: + node = TextNode("hello world") + assert repr(node) == "'hello world'" + + +def test_text_node_repr_double_quote() -> None: + node = TextNode('say "hello"') + assert repr(node) == "'say \"hello\"'" + + +def test_text_node_repr_single_quote() -> None: + node = TextNode("it's") + assert repr(node) == '"it\'s"' + + +def test_text_node_html_entities() -> None: + text = TextNode("<script>") + assert text._value == "<script>" + + +def test_text_node_unicode() -> None: + text = TextNode("Hello \u00e9\u00e8\u00ea") + assert "\u00e9" in text._value + + +def test_text_node_special_characters() -> None: + text = TextNode("") + assert text._value == "" + + +# --------------------------------------------------------------------------- +# HTMLTag +# --------------------------------------------------------------------------- + + +def test_html_tag_init() -> None: + tag = HTMLTag("div") + assert tag.tag_name == "div" + assert tag.attributes == {} + assert list(tag.children) == [] + + +def test_html_tag_init_with_attributes() -> None: + tag = HTMLTag("a", {"href": "/test", "class": "link"}) + assert tag.attributes["href"] == "/test" + assert tag.attributes["class"] == "link" + + +def test_html_tag_append_child() -> None: + parent = HTMLTag("div") + existing = HTMLTag("p") + parent.append(existing) + child = HTMLTag("span") + parent.append(child) + children = list(parent.children) + assert children[-1] is child + + +def test_html_tag_append_text() -> None: + tag = HTMLTag("p") + existing = HTMLTag("span") + tag.append(existing) + text = TextNode("hello") + tag.append(text) + children = list(tag.children) + assert children[-1] is text + + +def test_html_tag_replace_child() -> None: + parent = HTMLTag("div") + before = HTMLTag("header") + old = HTMLTag("span") + after = HTMLTag("footer") + parent.append(before) + parent.append(old) + parent.append(after) + + parent.replace_child(old, new := HTMLTag("p")) + children = list(parent.children) + assert old not in children + assert children.index(new) == 1 + + +def test_html_tag_replace_child_duplicate() -> None: + parent = HTMLTag("div") + child = HTMLTag("a") + parent.append(child) + parent.append(child) + + new = HTMLTag("em") + parent.replace_child(child, new) + + children = list(parent.children) + assert child not in children + assert children.count(new) == 2 + + +def test_html_tag_multiple_children() -> None: + parent = HTMLTag("div") + first_child = HTMLTag("span") + second_child = HTMLTag("p") + text = TextNode("text") + + parent.append(first_child) + parent.append(second_child) + parent.append(text) + + assert len(list(parent.children)) == 3 + + +def test_html_tag_repr() -> None: + tag = HTMLTag("div") + assert repr(tag) == "

" + + +def test_html_tag_repr_with_attributes() -> None: + tag = HTMLTag("a", {"href": "/test"}) + result = repr(tag) + assert " None: + tag = HTMLTag("div", {"data-value": 'test"quote'}) + result = repr(tag) + assert """ in result + + +def test_html_tag_repr_none_attribute() -> None: + tag = HTMLTag("input", {"disabled": None}) + result = repr(tag) + assert "disabled" in result + + +def test_html_tag_repr_none_and_string_attributes() -> None: + tag = HTMLTag("input", {"disabled": None, "type": "text"}) + result = repr(tag) + assert "disabled" in result + assert 'type="text"' in result + + +def test_html_tag_repr_empty_attribute() -> None: + tag = HTMLTag("div", {"data-empty": ""}) + result = repr(tag) + assert 'data-empty=""' in result + + +def test_html_tag_repr_multiple_attributes() -> None: + tag = HTMLTag( + "div", + {"id": "main", "class": "container", "data-value": "test"}, + ) + result = repr(tag) + assert "id=" in result + assert "class=" in result + assert "data-value=" in result + + +# --------------------------------------------------------------------------- +# HTMLRoot +# --------------------------------------------------------------------------- + + +def test_html_root_init() -> None: + context = Context({}) + root = HTMLRoot(context) + assert list(root.children) == [] + + +def test_html_root_append() -> None: + context = Context({}) + root = HTMLRoot(context) + tag = HTMLTag("div") + root.append(tag) + assert tag in root.children + + +def test_html_root_replace_child() -> None: + context = Context({}) + root = HTMLRoot(context) + old = HTMLTag("div") + new = HTMLTag("span") + root.append(old) + + root.replace_child(old, new) + assert old not in root.children + assert new in root.children + + +def test_html_root_extension() -> None: + context = Context({}) + root = HTMLRoot(context) + assert root.extension == ".html" + + +def test_html_root_multiple_children() -> None: + context = Context({}) + root = HTMLRoot(context) + + first_tag = HTMLTag("div") + second_tag = HTMLTag("span") + text = TextNode("hello") + + root.append(first_tag) + root.append(second_tag) + root.append(text) + + children = list(root.children) + assert len(children) == 3 + assert children[0] is first_tag + assert children[1] is second_tag + assert children[2] is text + + +# --------------------------------------------------------------------------- +# HTMLTag._to_element +# --------------------------------------------------------------------------- + + +def test_html_tag_to_element_simple() -> None: + tag = HTMLTag("div") + element = tag._to_element() + assert element.tag == "div" + + +def test_html_tag_to_element_children() -> None: + parent = HTMLTag("div") + child = HTMLTag("span") + parent.append(child) + + element = parent._to_element() + assert element.tag == "div" + children = list(element) + assert len(children) == 1 + assert children[0].tag == "span" + + +def test_html_tag_to_element_text() -> None: + tag = HTMLTag("p") + tag.append(TextNode("hello")) + + element = tag._to_element() + assert element.tag == "p" + assert element.text == "hello" + + +def test_html_tag_to_element_nested_text() -> None: + parent = HTMLTag("p") + parent.append(TextNode("start ")) + child = HTMLTag("strong") + child.append(TextNode("bold")) + parent.append(child) + parent.append(TextNode(" end")) + + element = parent._to_element() + assert element.tag == "p" + assert element.text == "start " + children = list(element) + assert len(children) == 1 + assert children[0].tag == "strong" + assert children[0].text == "bold" + + +def test_html_tag_to_element_deep() -> None: + outer = HTMLTag("div") + middle = HTMLTag("section") + inner = HTMLTag("article") + inner.append(TextNode("content")) + middle.append(inner) + outer.append(middle) + + element = outer._to_element() + assert element.tag == "div" + section = list(element)[0] + assert section.tag == "section" + article = list(section)[0] + assert article.tag == "article" + assert article.text == "content" + + +def test_html_tag_to_element_with_attributes() -> None: + tag = HTMLTag("a", {"href": "/link", "class": "nav", "id": "main"}) + tag.append(TextNode("click")) + + element = tag._to_element() + assert element.tag == "a" + assert element.attrib["href"] == "/link" + assert element.attrib["class"] == "nav" + assert element.attrib["id"] == "main" + assert element.text == "click" diff --git a/tests/test_html_parser.py b/tests/test_html_parser.py new file mode 100644 index 0000000..e5dac4e --- /dev/null +++ b/tests/test_html_parser.py @@ -0,0 +1,330 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for HTMLParser, _ElementTreeVisitor, and _make_relative.""" + +from pathlib import PurePath + +import pytest + +from docc.context import Context +from docc.document import BlankNode +from docc.plugins.html import ( + HTMLParser, + HTMLTag, + TextNode, + _ElementTreeVisitor, + _make_relative, +) + +# --------------------------------------------------------------------------- +# HTMLParser +# --------------------------------------------------------------------------- + + +def test_parser_simple_tag() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("
hello
") + + children = list(parser.root.children) + assert len(children) == 1 + child = children[0] + assert isinstance(child, HTMLTag) + assert child.tag_name == "div" + + +def test_parser_nested_tags() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("
text
") + + children = list(parser.root.children) + assert len(children) == 1 + div = children[0] + assert isinstance(div, HTMLTag) + + span = list(div.children)[0] + assert isinstance(span, HTMLTag) + assert span.tag_name == "span" + + +def test_parser_with_attributes() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed('
click') + + children = list(parser.root.children) + anchor = children[0] + assert isinstance(anchor, HTMLTag) + assert anchor.attributes["href"] == "/test" + assert anchor.attributes["class"] == "link" + + +def test_parser_text_content() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("

hello world

") + + children = list(parser.root.children) + p = children[0] + assert isinstance(p, HTMLTag) + text_children = list(p.children) + assert len(text_children) == 1 + text_child = text_children[0] + assert isinstance(text_child, TextNode) + assert text_child._value == "hello world" + + +def test_parser_multiple_elements() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("

one

two

") + + children = list(parser.root.children) + assert len(children) == 2 + + +def test_parser_empty_string() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("") + + assert list(parser.root.children) == [] + + +def test_parser_self_closing_tag() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("
") + + children = list(parser.root.children) + assert len(children) == 1 + assert isinstance(children[0], HTMLTag) + + +def test_parser_self_closing_tag_with_slash() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("
") + + children = list(parser.root.children) + assert len(children) == 1 + child = children[0] + assert isinstance(child, HTMLTag) + assert child.tag_name == "br" + + +def test_parser_boolean_attribute() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed('') + + children = list(parser.root.children) + input_elem = children[0] + assert isinstance(input_elem, HTMLTag) + assert "disabled" in input_elem.attributes + + +def test_parser_mixed_content() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("

Text bold more text

") + + children = list(parser.root.children) + p = children[0] + assert isinstance(p, HTMLTag) + p_children = list(p.children) + assert len(p_children) == 3 + + +def test_parser_deeply_nested() -> None: + context = Context({}) + parser = HTMLParser(context) + html = "
deep
" + parser.feed(html) + + children = list(parser.root.children) + assert len(children) == 1 + + +def test_parser_special_characters() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("

<script>

") + + children = list(parser.root.children) + p = children[0] + assert isinstance(p, HTMLTag) + text_children = list(p.children) + text_child = text_children[0] + assert isinstance(text_child, TextNode) + assert "