diff --git a/python/tests/integration/test_config.py b/python/tests/integration/test_config.py new file mode 100644 index 0000000..9f7be8c --- /dev/null +++ b/python/tests/integration/test_config.py @@ -0,0 +1,371 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# Integration tests for config loading: full round-trip via `zensical.build()`. +# +# Each test creates a minimal project in a fresh temp directory and invokes +# `zensical.build()`, which calls `Config::new()` on the Rust side. The +# assertions target the *side-effects* of config loading +# (e.g. theme loading: both config formats, success and error cases). + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +import zensical +import zensical.config as cfg_module +from zensical.config import ConfigurationError + +if TYPE_CHECKING: + from pathlib import Path + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +_BUILD_OPTS: dict[str, Any] = {"clean": False, "strict": False} + + +def _build(config_path: Path) -> None: + """Invoke `zensical.build()` with standard (non-strict) options.""" + zensical.build(str(config_path), _BUILD_OPTS) + + +def _make_toml_project( + tmp_path: Path, + *, + toml_extra: str = "", +) -> Path: + """Scaffold a minimal zensical.toml project and return the config path.""" + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "index.md").write_text("# Hello\n", encoding="utf-8") + + config = f'[project]\nsite_name = "Test"\n{toml_extra}\n' + config_path = tmp_path / "zensical.toml" + config_path.write_text(config, encoding="utf-8") + + return config_path + + +def _make_yml_project( + tmp_path: Path, + *, + yml_extra: str = "", +) -> Path: + """Scaffold a minimal mkdocs.yml project and return the config path.""" + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "index.md").write_text("# Hello\n", encoding="utf-8") + + lines = [ + 'site_name: "Test"', + "theme:", + " name: material", + ] + if yml_extra: + lines.append(yml_extra) + config_path = tmp_path / "mkdocs.yml" + config_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + return config_path + + +def _make_custom_dir( + tmp_path: Path, + *, + mkdocs_theme_yml: str | None = None, +) -> Path: + """Create an `overrides/` subdirectory for use as `custom_dir`.""" + custom = tmp_path / "overrides" + custom.mkdir() + if mkdocs_theme_yml is not None: + (custom / "mkdocs_theme.yml").write_text( + mkdocs_theme_yml, encoding="utf-8" + ) + return custom + + +# --------------------------------------------------------------------------- +# Theme loading: both zensical.toml and mkdocs.yml +# --------------------------------------------------------------------------- + + +class TestThemeLoadingToml: + """Theme resolution via zensical.toml (Rust TOML path).""" + + def test_default_theme_build_succeeds(self, tmp_path: Path) -> None: + """No theme.name and no custom_dir -> builtin theme used, build OK.""" + config_path = _make_toml_project(tmp_path) + _build(config_path) # must not raise + + def test_unknown_theme_name_raises(self, tmp_path: Path) -> None: + """theme.name set to an uninstalled theme -> config error raised.""" + config_path = _make_toml_project( + tmp_path, + toml_extra='[project.theme]\nname = "definitely-not-installed"', + ) + with pytest.raises(ConfigurationError): + _build(config_path) + + def test_custom_dir_no_mkdocs_theme_yml_build_succeeds( + self, tmp_path: Path + ) -> None: + """custom_dir, no mkdocs_theme.yml -> fallback to builtin, build OK.""" + _make_custom_dir(tmp_path) + config_path = _make_toml_project( + tmp_path, + toml_extra='[project.theme]\ncustom_dir = "overrides"\n', + ) + _build(config_path) # must not raise + + def test_custom_dir_mkdocs_theme_yml_no_extends_build_succeeds( + self, tmp_path: Path + ) -> None: + """custom_dir, mkdocs_theme.yml, no extends -> fallback to builtin.""" + _make_custom_dir(tmp_path, mkdocs_theme_yml="name: my-overrides\n") + config_path = _make_toml_project( + tmp_path, + toml_extra='[project.theme]\ncustom_dir = "overrides"\n', + ) + _build(config_path) # must not raise + + def test_custom_dir_extends_material_build_succeeds( + self, tmp_path: Path + ) -> None: + """custom_dir, extends: material -> follows chain through builtin.""" + _make_custom_dir(tmp_path, mkdocs_theme_yml="extends: material\n") + config_path = _make_toml_project( + tmp_path, + toml_extra='[project.theme]\ncustom_dir = "overrides"\n', + ) + _build(config_path) # must not raise + + def test_custom_dir_extends_zensical_build_succeeds( + self, tmp_path: Path + ) -> None: + """custom_dir, extends: zensical -> same builtin chain as material.""" + _make_custom_dir(tmp_path, mkdocs_theme_yml="extends: zensical\n") + config_path = _make_toml_project( + tmp_path, + toml_extra='[project.theme]\ncustom_dir = "overrides"\n', + ) + _build(config_path) # must not raise + + def test_custom_dir_extends_unknown_theme_raises( + self, tmp_path: Path + ) -> None: + """custom_dir extends unknown theme -> ConfigurationError raised.""" + _make_custom_dir( + tmp_path, + mkdocs_theme_yml="extends: definitely-not-installed-xyzzy\n", + ) + config_path = _make_toml_project( + tmp_path, + toml_extra='[project.theme]\ncustom_dir = "overrides"\n', + ) + with pytest.raises(ConfigurationError): + _build(config_path) + + def test_name_set_custom_dir_no_extends_name_used_as_base( + self, tmp_path: Path + ) -> None: + """theme.name + custom_dir, no extends -> name used as base, succeeds. + + Contrast with test_custom_dir_extends_*: when the custom_dir has an + explicit extends the name is ignored; when it doesn't, the name + determines the base. + """ + _make_custom_dir(tmp_path) + config_path = _make_toml_project( + tmp_path, + toml_extra=( + '[project.theme]\nname = "material"\ncustom_dir = "overrides"\n' + ), + ) + _build(config_path) # must not raise + + def test_name_set_custom_dir_extends_overrides_name( + self, tmp_path: Path + ) -> None: + """theme.name + custom_dir with extends -> chain followed, name ignored. + + The build must succeed regardless of what name is, because the chain + is driven entirely by the extends declaration in mkdocs_theme.yml. + """ + _make_custom_dir(tmp_path, mkdocs_theme_yml="extends: zensical\n") + config_path = _make_toml_project( + tmp_path, + toml_extra=( + "[project.theme]\n" + # name would point to the same builtin anyway, but the key + # assertion is that the extends chain is what is followed. + 'name = "material"\n' + 'custom_dir = "overrides"\n' + ), + ) + _build(config_path) # must not raise + + def test_custom_dir_extends_installed_theme_chain_applies_palette( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """custom_dir -> installed theme -> preserve inherited palette. + + Chain under test: + + - overrides/mkdocs_theme.yml: extends: demo-installed-theme + - demo-installed-theme/mkdocs_theme.yml: + - extends: zensical + - palette: + - scheme: slate + + The final rendered config must include the inherited `slate` scheme. + """ + installed_theme_name = "demo-installed-theme" + + # custom_dir extends an installed theme by name + _make_custom_dir( + tmp_path, + mkdocs_theme_yml=f"extends: {installed_theme_name}\n", + ) + + # fake installed theme extends zensical and sets a slate palette + installed_theme_dir = tmp_path / "demo_theme" + installed_theme_dir.mkdir() + (installed_theme_dir / "mkdocs_theme.yml").write_text( + "extends: zensical\npalette:\n- scheme: slate\n", + encoding="utf-8", + ) + + original_get_theme_dir = cfg_module.get_theme_dir + + def fake_get_theme_dir(name: str) -> str: + if name == installed_theme_name: + return str(installed_theme_dir) + return original_get_theme_dir(name) + + monkeypatch.setattr(cfg_module, "get_theme_dir", fake_get_theme_dir) + + config_path = _make_toml_project( + tmp_path, + toml_extra='[project.theme]\ncustom_dir = "overrides"\n', + ) + _build(config_path) + + html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") + assert 'data-md-color-scheme="slate"' in html + + +class TestThemeLoadingYml: + """Theme resolution via mkdocs.yml (Python/YAML path).""" + + def test_default_theme_build_succeeds(self, tmp_path: Path) -> None: + """theme.name = material (explicit) -> builtin theme, build OK.""" + config_path = _make_yml_project(tmp_path) + _build(config_path) # must not raise + + def test_unknown_theme_name_raises(self, tmp_path: Path) -> None: + """theme.name set to an uninstalled theme -> config error raised.""" + config_path = _make_yml_project( + tmp_path, + yml_extra="theme:\n name: definitely-not-installed-xyzzy", + ) + with pytest.raises(ConfigurationError): + _build(config_path) + + def test_custom_dir_no_mkdocs_theme_yml_build_succeeds( + self, tmp_path: Path + ) -> None: + """custom_dir, no mkdocs_theme.yml -> fallback to builtin, build OK.""" + _make_custom_dir(tmp_path) + config_path = _make_yml_project( + tmp_path, + yml_extra="theme:\n name: material\n custom_dir: overrides", + ) + _build(config_path) # must not raise + + def test_custom_dir_extends_material_build_succeeds( + self, tmp_path: Path + ) -> None: + """custom_dir, extends: material -> follows builtin chain, succeeds.""" + _make_custom_dir(tmp_path, mkdocs_theme_yml="extends: material\n") + config_path = _make_yml_project( + tmp_path, + yml_extra="theme:\n name: material\n custom_dir: overrides", + ) + _build(config_path) # must not raise + + def test_custom_dir_extends_installed_theme_chain_applies_palette( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """custom_dir -> installed theme -> preserve inherited palette.""" + installed_theme_name = "demo-installed-theme" + + # custom_dir extends an installed theme by name + _make_custom_dir( + tmp_path, + mkdocs_theme_yml=f"extends: {installed_theme_name}\n", + ) + + # fake installed theme extends zensical and sets a slate palette + installed_theme_dir = tmp_path / "demo_theme" + installed_theme_dir.mkdir() + (installed_theme_dir / "mkdocs_theme.yml").write_text( + "extends: zensical\npalette:\n- scheme: slate\n", + encoding="utf-8", + ) + + original_get_theme_dir = cfg_module.get_theme_dir + + def fake_get_theme_dir(name: str) -> str: + if name == installed_theme_name: + return str(installed_theme_dir) + return original_get_theme_dir(name) + + monkeypatch.setattr(cfg_module, "get_theme_dir", fake_get_theme_dir) + + config_path = _make_yml_project( + tmp_path, + yml_extra="theme:\n name: material\n custom_dir: overrides", + ) + _build(config_path) + + html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") + assert 'data-md-color-scheme="slate"' in html + + def test_custom_dir_extends_unknown_theme_raises( + self, tmp_path: Path + ) -> None: + """custom_dir extends unknown theme -> ConfigurationError raised.""" + _make_custom_dir( + tmp_path, + mkdocs_theme_yml="extends: definitely-not-installed-xyzzy\n", + ) + config_path = _make_yml_project( + tmp_path, + yml_extra="theme:\n name: material\n custom_dir: overrides", + ) + with pytest.raises(ConfigurationError): + _build(config_path) diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index 44993a2..3c278d5 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -23,96 +23,228 @@ from __future__ import annotations -from textwrap import dedent -from typing import TYPE_CHECKING +from pathlib import Path +from typing import TYPE_CHECKING, Any import pytest +import yaml -from zensical.config import ConfigurationError, parse_config +import zensical.config as cfg_module +from zensical.config import ( + ConfigurationError, + get_builtin_theme_dir, + get_theme_dir, + parse_config, + parse_mkdocs_config, +) +from zensical.extensions.autorefs import AutorefsExtension +from zensical.extensions.glightbox import GlightboxExtension +from zensical.extensions.macros import MacrosExtension +from zensical.extensions.mkdocstrings import MkdocstringsExtension if TYPE_CHECKING: - from pathlib import Path + from collections.abc import Generator + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_toml_config(tmp_path: Path, **project_keys: Any) -> Path: + """Write a minimal `zensical.toml` with the given project keys.""" + docs_dir = project_keys.get("docs_dir", "docs") + if docs_dir and not str(docs_dir).startswith(".."): + tmp_path.joinpath(docs_dir).mkdir(exist_ok=True) + + lines = ["[project]", 'site_name = "test"'] + for key, value in project_keys.items(): + if isinstance(value, bool): + formatted = "true" if value else "false" + elif isinstance(value, (int, float)): + formatted = str(value) + else: + formatted = f'"{value}"' + lines.append(f"{key} = {formatted}") + + config_file = tmp_path / "zensical.toml" + config_file.write_text("\n".join(lines) + "\n") + return config_file + + +def _minimal_yaml(**extra_keys: object) -> str: + """Build a minimal mkdocs.yml YAML string with the required scaffolding.""" + base: dict[str, Any] = { + "site_name": "Test Site", + "theme": {"name": "material"}, + "extra": {}, + "plugins": [], + "mdx_configs": {}, + } + base.update(extra_keys) + return yaml.dump(base) + + +def _write_mkdocs_config(tmp_path: Path, yaml_str: str) -> Path: + """Create the `docs` directory and write `mkdocs.yml` content.""" + tmp_path.joinpath("docs").mkdir() + config_file = tmp_path / "mkdocs.yml" + config_file.write_text(yaml_str) + return config_file + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_config() -> Generator[None, None, None]: + """Reset the global _CONFIG before and after each test.""" + cfg_module._CONFIG = None + yield + cfg_module._CONFIG = None def test_site_dir_cant_be_empty(tmp_path: Path) -> None: - tmp_path.joinpath("docs").mkdir() - config_file = tmp_path / "zensical.toml" - config_file.write_text( - dedent(""" - [project] - site_name = "test" - site_dir = "" - """) - ) + config_file = _write_toml_config(tmp_path, site_dir="") with pytest.raises(ConfigurationError, match="empty"): parse_config(str(config_file)) def test_site_dir_cant_go_up(tmp_path: Path) -> None: - tmp_path.joinpath("docs").mkdir() - config_file = tmp_path / "zensical.toml" - config_file.write_text( - dedent(""" - [project] - site_name = "test" - site_dir = "../site" - """) - ) + config_file = _write_toml_config(tmp_path, site_dir="../site") with pytest.raises(ConfigurationError, match="within"): parse_config(str(config_file)) def test_docs_dir_cant_be_empty(tmp_path: Path) -> None: - tmp_path.joinpath("docs").mkdir() - config_file = tmp_path / "zensical.toml" - config_file.write_text( - dedent(""" - [project] - site_name = "test" - docs_dir = "" - """) - ) + config_file = _write_toml_config(tmp_path, docs_dir="") with pytest.raises(ConfigurationError, match="empty"): parse_config(str(config_file)) def test_docs_dir_cant_go_up(tmp_path: Path) -> None: - tmp_path.joinpath("docs").mkdir() - config_file = tmp_path / "zensical.toml" - config_file.write_text( - dedent(""" - [project] - site_name = "test" - docs_dir = "../docs" - """) - ) + config_file = _write_toml_config(tmp_path, docs_dir="../docs") with pytest.raises(ConfigurationError, match="within"): parse_config(str(config_file)) def test_docs_dir_must_exist(tmp_path: Path) -> None: - config_file = tmp_path / "zensical.toml" - config_file.write_text( - dedent(""" - [project] - site_name = "test" - docs_dir = "docs" - """) - ) + config_file = _write_toml_config(tmp_path, docs_dir="docs") + # Remove the auto-created docs directory to trigger the existence check. + tmp_path.joinpath("docs").rmdir() with pytest.raises(ConfigurationError, match="does not exist"): parse_config(str(config_file)) def test_site_dir_docs_dir_cant_be_equal(tmp_path: Path) -> None: - tmp_path.joinpath("docs").mkdir() - config_file = tmp_path / "zensical.toml" - config_file.write_text( - dedent(""" - [project] - site_name = "test" - site_dir = "same" - docs_dir = "same" - """) - ) + config_file = _write_toml_config(tmp_path, site_dir="same", docs_dir="same") with pytest.raises(ConfigurationError, match="must be different"): parse_config(str(config_file)) + + +# --------------------------------------------------------------------------- +# Plugins to Markdown extensions +# --------------------------------------------------------------------------- + + +class TestPluginShimming: + """Test that MkDocs plugin entries are shimmed into Markdown extensions.""" + + def _parse_yaml( + self, tmp_path: Path, **yaml_overrides: object + ) -> dict[str, Any]: + """Write a minimal mkdocs.yml with overrides and return config.""" + yaml_str = _minimal_yaml(**yaml_overrides) + config_file = _write_mkdocs_config(tmp_path, yaml_str) + parse_mkdocs_config(str(config_file)) + config = cfg_module._CONFIG + assert config is not None + return config + + def test_plugins_as_yaml_list(self, tmp_path: Path) -> None: + config = self._parse_yaml(tmp_path, plugins=["glightbox"]) + assert GlightboxExtension.name in config["markdown_extensions"] + + def test_plugins_as_yaml_dict(self, tmp_path: Path) -> None: + config = self._parse_yaml(tmp_path, plugins={"glightbox": {}}) + assert GlightboxExtension.name in config["markdown_extensions"] + + def test_glightbox_adds_extension_and_forwards_config( + self, tmp_path: Path + ) -> None: + config = self._parse_yaml( + tmp_path, plugins={"glightbox": {"loop": True}} + ) + assert GlightboxExtension.name in config["markdown_extensions"] + assert config["mdx_configs"][GlightboxExtension.name] == {"loop": True} + + def test_macros_plugin_shimmed(self, tmp_path: Path) -> None: + config = self._parse_yaml(tmp_path, plugins={"macros": {}}) + assert MacrosExtension.name in config["markdown_extensions"] + + def test_autorefs_standalone(self, tmp_path: Path) -> None: + config = self._parse_yaml(tmp_path, plugins={"autorefs": {}}) + assert AutorefsExtension.name in config["markdown_extensions"] + + def test_autorefs_disabled_not_added( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setattr("zensical.config.find_spec", lambda _name: True) + config = self._parse_yaml( + tmp_path, + plugins={ + "autorefs": {"enabled": False}, + "mkdocstrings": {"enabled": True}, + }, + ) + assert AutorefsExtension.name not in config["markdown_extensions"] + + def test_mkdocstrings_disabled_neither_added(self, tmp_path: Path) -> None: + config = self._parse_yaml( + tmp_path, plugins={"mkdocstrings": {"enabled": False}} + ) + assert AutorefsExtension.name not in config["markdown_extensions"] + assert MkdocstringsExtension.name not in config["markdown_extensions"] + + def test_mkdocstrings_enabled_autorefs_also_added( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setattr("zensical.config.find_spec", lambda _name: True) + config = self._parse_yaml(tmp_path, plugins={"mkdocstrings": {}}) + assert AutorefsExtension.name in config["markdown_extensions"] + assert MkdocstringsExtension.name in config["markdown_extensions"] + + def test_mkdocstrings_not_installed_raises( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setattr("zensical.config.find_spec", lambda _name: None) + yaml_str = _minimal_yaml(plugins={"mkdocstrings": {}}) + config_file = _write_mkdocs_config(tmp_path, yaml_str) + with pytest.raises(ConfigurationError): + parse_mkdocs_config(str(config_file)) + + +# --------------------------------------------------------------------------- +# Getting theme information +# --------------------------------------------------------------------------- + + +class TestGetThemeDir: + """Test theme directory resolution.""" + + def test_builtin_theme_dir_exists(self) -> None: + assert Path(get_builtin_theme_dir()).exists() + + def test_get_theme_dir_material(self) -> None: + assert get_theme_dir("material") == get_builtin_theme_dir() + + def test_get_theme_dir_zensical(self) -> None: + assert get_theme_dir("zensical") == get_builtin_theme_dir() + + def test_get_theme_dir_unknown_raises(self) -> None: + with pytest.raises(ConfigurationError): + get_theme_dir("definitely-not-a-real-theme-xyzzy")