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"
def register_audience_page():
@Component
def specialised():
return ("HTML
", "REST API example", "MCP example")
@Component
def shared():
return ("HTML
", "Shared human instructions")
@Page("1", "Instructions")
def instructions():
return [specialised(), [shared(), "raw text"]]
@pytest.mark.parametrize("single", [True, False])
def test_fallback_is_selected_independently_for_each_component(tmp_path, single):
register_audience_page()
result = build(build_directory=tmp_path, generator=FilesGenerator([3, 2], "md", single))
content = next(iter(result.files.values()))
assert content.endswith("MCP example\nShared human instructions\nraw text")
assert "REST API example" not in content
def test_fallback_in_web_build_preview_and_run(tmp_path, monkeypatch):
import docslib.server as server
register_audience_page()
expected = "MCP example\nShared human instructions\nraw text"
result = build(build_directory=tmp_path, generator=DefaultGenerator([3, 2]))
assert result.manifest["pages"][0]["content"] == expected
client = TestClient(create_app(component_variant=[3, 2], live_preview=True))
assert client.get("/manifest.json").json()["pages"][0]["content"] == expected
served = []
monkeypatch.setattr(server.uvicorn, "run", lambda app, **kwargs: served.append(app))
server.run(component_variant=[3, 2])
assert served[0].state.docslib.result.manifest["pages"][0]["content"] == expected
def test_priority_order_empty_string_and_long_fallback_chain():
@Component
def alternatives():
return ("html", "human", "")
assert alternatives().select([2, 3]) == "human"
assert alternatives().select([3, 2]) == "" # Empty output is an explicit variant.
assert alternatives().select([10, 9, 2, 1]) == "human"
def test_no_available_fallback_fails_before_writing(tmp_path):
register_audience_page()
with pytest.raises(RuntimeError, match=r"Instructions.*shared.*requested variants \[3, 4\]"):
build(build_directory=tmp_path, generator=FilesGenerator([3, 4], "md"))
assert not list(tmp_path.iterdir())
@pytest.mark.parametrize("variants", [[], [0], [3, -1], [3, True], [3, "2"], [3, 1.5], [3, []]])
def test_invalid_fallback_lists_are_rejected_eagerly(variants):
for factory in (DefaultGenerator, lambda v: FilesGenerator(v, "md"),
lambda v: create_app(component_variant=v)):
with pytest.raises(ValueError, match="positive integer"):
factory(variants)
def test_fallback_configuration_is_copied():
variants = [3, 2]
generator = FilesGenerator(variants, "md")
app = create_app(component_variant=variants)
variants.clear()
assert generator.component_variant == (3, 2)
assert app.state.docslib.compiler.component_variant == (3, 2)