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\n

Nested

" ) 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)