229 lines
8.1 KiB
Python
229 lines
8.1 KiB
Python
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"<h1>{text}</h1>", 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") == ("<h1>Test</h1>", "# 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"] == (
|
|
"<h1>First</h1>\nraw <&>\r\n\n<h1>Nested</h1>"
|
|
)
|
|
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 "<p>HTML only</p>"
|
|
|
|
@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 ("<p>HTML</p>", "REST API example", "MCP example")
|
|
|
|
@Component
|
|
def shared():
|
|
return ("<p>HTML</p>", "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)
|