test: add pytest suite — CLI smoke + config/templates/md_to_pdf units
- tests/conftest.py redirects MYTOOLKIT_HOME to a temp dir before imports so the module-level Config singleton never touches the real ~/.mytoolkit - CLI smoke: every registered command answers --help with exit code 0 - unit tests: Config get/set/migrate, templates_registry override/fallback, md_to_pdf pure helpers (pandoc-dependent cases skipif-guarded) - pyproject: dev dependency group (pytest) + pytest config; Makefile: make test
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""Pytest bootstrap: isolate tests from the real ~/.mytoolkit.
|
||||
|
||||
config.py and templates_registry.py resolve MYTOOLKIT_HOME at import time
|
||||
and instantiate a module-level singleton, so the env var must be set before
|
||||
any mytoolkit module is imported.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ["MYTOOLKIT_HOME"] = tempfile.mkdtemp(prefix="mytoolkit-test-home-")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""CLI smoke tests: import chain, command registration, --help exit codes."""
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mytoolkit.cli import cli
|
||||
|
||||
|
||||
def test_top_level_help():
|
||||
result = CliRunner().invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "convert" in result.output
|
||||
|
||||
|
||||
def test_every_registered_command_help():
|
||||
"""Every top-level command/group must respond to --help cleanly.
|
||||
|
||||
Catches broken imports and registration mistakes — the most common
|
||||
ways a CLI dies.
|
||||
"""
|
||||
assert len(cli.commands) >= 15, "suspiciously few commands registered"
|
||||
for name in sorted(cli.commands):
|
||||
result = CliRunner().invoke(cli, [name, "--help"])
|
||||
assert result.exit_code == 0, f"{name} --help failed:\n{result.output}"
|
||||
|
||||
|
||||
def test_info_paths():
|
||||
result = CliRunner().invoke(cli, ["info", "paths"])
|
||||
assert result.exit_code == 0
|
||||
for key in ("package:", "templates:", "references:", "scaffolds:"):
|
||||
assert key in result.output
|
||||
|
||||
|
||||
def test_info_docs_convert():
|
||||
result = CliRunner().invoke(cli, ["info", "docs", "convert"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip(), "info docs convert printed nothing"
|
||||
|
||||
|
||||
def test_info_docs_missing_name_fails():
|
||||
result = CliRunner().invoke(cli, ["info", "docs", "no-such-doc"])
|
||||
assert result.exit_code != 0
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for mytoolkit.config.Config (get/set/remove/export + legacy migration).
|
||||
|
||||
MYTOOLKIT_HOME is pointed at a temp dir by conftest.py before imports, so
|
||||
every Config() instance here operates on throwaway files.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mytoolkit import config as config_mod
|
||||
from mytoolkit.config import Config
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fresh_home(tmp_path, monkeypatch):
|
||||
"""Give each test its own empty MYTOOLKIT_HOME."""
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", home / "config.json")
|
||||
monkeypatch.setattr(config_mod, "_LEGACY_ENV_PATH", home / "env.json")
|
||||
monkeypatch.setattr(config_mod, "_LEGACY_MODULE_PATH", home / "module-config.json")
|
||||
return home
|
||||
|
||||
|
||||
def test_set_get_remove_roundtrip(fresh_home):
|
||||
c = Config()
|
||||
assert c.get("apikey_ark") is None
|
||||
c.set("apikey_ark", "sk-test")
|
||||
assert c.get("apikey_ark") == "sk-test"
|
||||
# Persisted: a fresh instance sees the same value.
|
||||
assert Config().get("apikey_ark") == "sk-test"
|
||||
assert c.remove("apikey_ark") is True
|
||||
assert c.remove("apikey_ark") is False
|
||||
assert c.get("apikey_ark") is None
|
||||
|
||||
|
||||
def test_get_all_returns_copy(fresh_home):
|
||||
c = Config()
|
||||
c.set("k", "v")
|
||||
snapshot = c.get_all()
|
||||
snapshot["k"] = "mutated"
|
||||
assert c.get("k") == "v"
|
||||
|
||||
|
||||
def test_export_prefix(fresh_home):
|
||||
c = Config()
|
||||
c.set("volc_appid", "123")
|
||||
assert c.export() == {"MYCLI_VOLC_APPID": "123"}
|
||||
|
||||
|
||||
def test_corrupt_config_falls_back_to_empty(fresh_home):
|
||||
fresh_home.mkdir(parents=True)
|
||||
(fresh_home / "config.json").write_text("{not json")
|
||||
c = Config()
|
||||
assert c.get_all() == {}
|
||||
|
||||
|
||||
def test_legacy_env_json_migrated(fresh_home):
|
||||
fresh_home.mkdir(parents=True)
|
||||
(fresh_home / "env.json").write_text(
|
||||
json.dumps({"vars": {"apikey_ark": "sk-old", "path_study": "/tmp/x"}})
|
||||
)
|
||||
c = Config()
|
||||
assert c.get("apikey_ark") == "sk-old"
|
||||
assert c.get("path_study") == "/tmp/x"
|
||||
# env.json renamed out of the way so migration does not re-run.
|
||||
assert not (fresh_home / "env.json").exists()
|
||||
assert (fresh_home / "env.json.migrated").exists()
|
||||
|
||||
|
||||
def test_legacy_env_merges_without_clobbering_existing_keys(fresh_home):
|
||||
fresh_home.mkdir(parents=True)
|
||||
(fresh_home / "config.json").write_text(
|
||||
json.dumps({"keys": {"apikey_ark": "sk-new"}})
|
||||
)
|
||||
(fresh_home / "env.json").write_text(
|
||||
json.dumps({"vars": {"apikey_ark": "sk-old", "extra": "e"}})
|
||||
)
|
||||
c = Config()
|
||||
# Legacy vars are merged in (documented behaviour: legacy wins on conflict).
|
||||
assert c.get("extra") == "e"
|
||||
assert c.get("apikey_ark") == "sk-old"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tests for pure helpers in mytoolkit.md_to_pdf (no pandoc/typst needed)."""
|
||||
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from mytoolkit.md_to_pdf import (
|
||||
_consume_balanced,
|
||||
_longest_backtick_run,
|
||||
_odd_fenced_code_before,
|
||||
_typst_raw_fence,
|
||||
preprocess_typst_callouts,
|
||||
resolve_markdown_image_paths,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveMarkdownImagePaths:
|
||||
def test_relative_path_resolved_when_file_exists(self, tmp_path):
|
||||
(tmp_path / "figs").mkdir()
|
||||
(tmp_path / "figs" / "a.png").write_bytes(b"png")
|
||||
out = resolve_markdown_image_paths("", tmp_path)
|
||||
assert out == f""
|
||||
|
||||
def test_missing_file_left_unchanged(self, tmp_path):
|
||||
md = ""
|
||||
assert resolve_markdown_image_paths(md, tmp_path) == md
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"src",
|
||||
["https://x.com/a.png", "http://x.com/a.png", "data:image/png;base64,AA", "#frag"],
|
||||
)
|
||||
def test_urls_and_anchors_untouched(self, tmp_path, src):
|
||||
md = f""
|
||||
assert resolve_markdown_image_paths(md, tmp_path) == md
|
||||
|
||||
def test_absolute_path_untouched(self, tmp_path):
|
||||
md = ""
|
||||
assert resolve_markdown_image_paths(md, tmp_path) == md
|
||||
|
||||
def test_html_img_tag_resolved(self, tmp_path):
|
||||
(tmp_path / "a.png").write_bytes(b"png")
|
||||
out = resolve_markdown_image_paths('<img src="a.png" width="50%">', tmp_path)
|
||||
assert out == f'<img src="{tmp_path / "a.png"}" width="50%">'
|
||||
|
||||
|
||||
class TestBacktickFenceHelpers:
|
||||
@pytest.mark.parametrize(
|
||||
("s", "expected"),
|
||||
[("", 0), ("a`b", 1), ("``x```y", 3), ("````", 4)],
|
||||
)
|
||||
def test_longest_backtick_run(self, s, expected):
|
||||
assert _longest_backtick_run(s) == expected
|
||||
|
||||
def test_raw_fence_at_least_three(self):
|
||||
assert _typst_raw_fence("plain") == "```"
|
||||
|
||||
def test_raw_fence_exceeds_inner_run(self):
|
||||
assert _typst_raw_fence("has ``` inside") == "````"
|
||||
|
||||
|
||||
class TestBalancedConsume:
|
||||
def test_simple(self):
|
||||
assert _consume_balanced("(a(b)c)", 0, "(", ")") == 7
|
||||
|
||||
def test_not_open_char(self):
|
||||
assert _consume_balanced("x()", 0, "(", ")") is None
|
||||
|
||||
def test_unbalanced(self):
|
||||
assert _consume_balanced("(a(b)", 0, "(", ")") is None
|
||||
|
||||
def test_quoted_chars_ignored(self):
|
||||
assert _consume_balanced('(")")x', 0, "(", ")") == 5
|
||||
|
||||
|
||||
class TestOddFencedCode:
|
||||
def test_inside_fence(self):
|
||||
md = "```\ncode\n"
|
||||
assert _odd_fenced_code_before(md, len(md)) is True
|
||||
|
||||
def test_outside_fence(self):
|
||||
md = "```\ncode\n```\n"
|
||||
assert _odd_fenced_code_before(md, len(md)) is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("pandoc"), reason="pandoc not installed")
|
||||
class TestPreprocessTypstCallouts:
|
||||
def test_info_box_wrapped_in_raw_typst(self):
|
||||
md = "#info-box[\nHello **world**\n]\n"
|
||||
out = preprocess_typst_callouts(md)
|
||||
assert "{=typst}" in out
|
||||
assert "#info-box[" in out
|
||||
|
||||
def test_callout_inside_code_fence_untouched(self):
|
||||
md = "```\n#info-box[\nx\n]\n```\n"
|
||||
assert preprocess_typst_callouts(md) == md
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for mytoolkit.templates_registry (external root override + fallback)."""
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from mytoolkit import templates_registry as tr
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry(tmp_path, monkeypatch):
|
||||
"""Point the registry config file at a temp location for every test."""
|
||||
monkeypatch.setattr(tr, "CONFIG_PATH", tmp_path / "templates.json")
|
||||
yield
|
||||
|
||||
|
||||
def test_default_root_is_bundled_package():
|
||||
root = tr.get_root()
|
||||
assert (root / "md-to-pdf").is_dir()
|
||||
assert (root / "md-to-docx").is_dir()
|
||||
|
||||
|
||||
def test_set_root_overrides(tmp_path):
|
||||
ext = tmp_path / "ext-templates"
|
||||
(ext / "md-to-pdf").mkdir(parents=True)
|
||||
(ext / "md-to-docx").mkdir()
|
||||
assert tr.set_root(ext) == ext.resolve()
|
||||
assert tr.get_root() == ext.resolve()
|
||||
assert tr.get_md_to_pdf_root() == ext.resolve() / "md-to-pdf"
|
||||
assert tr.get_md_to_docx_root() == ext.resolve() / "md-to-docx"
|
||||
|
||||
|
||||
def test_invalid_registered_dir_falls_back_to_bundled(tmp_path):
|
||||
tr.set_root(tmp_path / "does-not-exist")
|
||||
root = tr.get_root()
|
||||
assert (root / "md-to-pdf").is_dir()
|
||||
assert root != tmp_path / "does-not-exist"
|
||||
|
||||
|
||||
def test_missing_subdir_raises_usage_error(tmp_path):
|
||||
ext = tmp_path / "ext-templates"
|
||||
ext.mkdir() # no md-to-pdf inside
|
||||
tr.set_root(ext)
|
||||
with pytest.raises(click.UsageError):
|
||||
tr.get_md_to_pdf_root()
|
||||
Reference in New Issue
Block a user