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:
Zhengshou Lai
2026-07-18 11:45:40 +08:00
parent 9174708d10
commit 06704acf60
10 changed files with 458 additions and 1 deletions
View File
+11
View File
@@ -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-")
+41
View File
@@ -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
+82
View File
@@ -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"
+95
View File
@@ -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("![alt](figs/a.png)", tmp_path)
assert out == f"![alt]({tmp_path / 'figs' / 'a.png'})"
def test_missing_file_left_unchanged(self, tmp_path):
md = "![alt](figs/nope.png)"
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"![alt]({src})"
assert resolve_markdown_image_paths(md, tmp_path) == md
def test_absolute_path_untouched(self, tmp_path):
md = "![alt](/abs/path/a.png)"
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
+44
View File
@@ -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()