import pytest
from fastapi.testclient import TestClient
from docslib import Component, DefaultGenerator, FilesGenerator, Page, build, create_app
from docslib.components import Json
@Component
def Heading(text):
return (f"
{text}
", f"# {text}", rf"\section{{{text}}}")
def register_pages():
@Page("10", "Last")
def last():
return Heading("Last")
@Page("2", "First")
def first():
return [Heading("First"), ["raw <&>\r\n", None, Heading("Nested")]]
def test_component_normalises_strings_lists_and_tuples():
@Component
def legacy():
return "legacy"
@Component
def alternatives():
return ["html", "md", "tex", "other"]
assert legacy() == ("legacy",)
assert alternatives() == ("html", "md", "tex", "other")
assert Heading("Test") == ("Test
", "# Test", r"\section{Test}")
assert "language-json" in Json({"a": 1})[0]
@pytest.mark.parametrize("result", [[], (), ["ok", 1], None, {"html": "x"}, iter(["x"])])
def test_invalid_component_outputs(result):
@Component
def invalid():
return result
with pytest.raises(TypeError, match="non-empty list/tuple of strings"):
invalid()
def test_default_build_selects_first_variant_and_preserves_page_blocks(tmp_path):
register_pages()
result = build(build_directory=tmp_path)
assert result.manifest["pages"][0]["content"] == (
"First
\nraw <&>\r\n\nNested
"
)
assert len(list(tmp_path.iterdir())) == 4
second = build(build_directory=tmp_path, generator=DefaultGenerator(component_variant=2))
assert second.manifest["pages"][1]["content"] == "# Last"
def test_single_file_has_sorted_pages_and_exact_raw_content(tmp_path):
register_pages()
result = build(
build_directory=tmp_path, title="My docs",
generator=FilesGenerator(2, ".md"),
# Raw export must not try to read HTML templates, custom JS or assets.
template_dir=tmp_path / "absent", custom_js=tmp_path / "absent.js",
)
expected = "--- 2. First ---\n# First\nraw <&>\r\n\n# Nested\n\n--- 10. Last ---\n# Last"
assert result.files == {"My docs.md": expected}
assert [p.name for p in tmp_path.iterdir()] == ["My docs.md"]
assert (tmp_path / "My docs.md").read_bytes() == expected.encode("utf-8")
def test_separate_files_and_unlimited_variant_formats(tmp_path):
register_pages()
result = build(build_directory=tmp_path, generator=FilesGenerator(3, "tex", False))
assert set(result.files) == {"2. First.tex", "10. Last.tex"}
assert (tmp_path / "10. Last.tex").read_text() == r"\section{Last}"
def test_preview_selects_variant():
register_pages()
client = TestClient(create_app(component_variant=3, live_preview=True))
assert client.get("/manifest.json").json()["pages"][1]["content"] == r"\section{Last}"
assert client.get("/live").status_code == 200
def test_missing_variant_names_component_and_page_before_writing(tmp_path):
register_pages()
with pytest.raises(RuntimeError, match=r"2.*First.*Heading.*requested variant 4"):
build(build_directory=tmp_path, generator=FilesGenerator(4, "txt"))
assert not list(tmp_path.iterdir())
@pytest.mark.parametrize("variant", [0, -1, True, 1.5, "2"])
def test_variant_validation(variant):
with pytest.raises(ValueError, match="positive integer"):
FilesGenerator(variant, "md")
with pytest.raises(ValueError, match="positive integer"):
DefaultGenerator(variant)
with pytest.raises(ValueError, match="positive integer"):
create_app(component_variant=variant)
@pytest.mark.parametrize("extension", ["", ".", "../md", "a/b", "a\\b", "md.", "md\n"])
def test_extension_validation(extension):
with pytest.raises(ValueError, match="content_extension"):
FilesGenerator(1, extension)
def test_export_sanitises_names_and_keeps_files_in_output(tmp_path):
@Page("1", "../Bad: title?")
def page():
return "untouched"
result = build(build_directory=tmp_path, generator=FilesGenerator(1, "txt", False))
assert result.files == {"1. .._Bad_ title_.txt": "untouched"}
result = build(build_directory=tmp_path, title="CON", generator=FilesGenerator(1, "txt"))
assert "_CON.txt" in result.files
def test_single_variant_components_do_not_silently_fall_back_to_html(tmp_path):
@Component
def legacy():
return "HTML only
"
@Page("1", "Legacy")
def page():
return legacy()
with pytest.raises(RuntimeError, match="legacy has 1 variant"):
build(build_directory=tmp_path, generator=FilesGenerator(2, "md"))
assert not list(tmp_path.iterdir())
def test_filename_collisions_are_reported_before_writing(tmp_path):
@Page("1/1", "Same")
def first():
return "one"
@Page("1:1", "Same")
def second():
return "two"
with pytest.raises(ValueError, match="filename collision"):
build(build_directory=tmp_path, generator=FilesGenerator(1, "txt", False))
assert not list(tmp_path.iterdir())
def test_run_passes_variant_to_preview(monkeypatch):
import docslib.server as server
register_pages()
served = []
monkeypatch.setattr(server.uvicorn, "run", lambda app, **kwargs: served.append(app))
server.run(component_variant=2)
client = TestClient(served[0])
assert client.get("/manifest.json").json()["pages"][1]["content"] == "# Last"