"""Tests for the chunked PdfCreateAgent pipeline. Coverage: 1. Section model validation (each section type round-trips correctly) 2. Jinja rendering (_render produces valid HTML for each section type) 3. _safe_filename produces clean slugs 4. _make_chunks groups sections correctly by token budget 5. orchestrate() produces the correct EditPlanResponse via planner + writer mocks 6. orchestrate() returns EditCannotDoResponse when meta planner signals cannot_do 7. orchestrate() returns EditCannotDoResponse when sections planner returns empty list """ from __future__ import annotations import pytest from conftest import build_app_settings from pydantic_ai.models.test import TestModel from pydantic_ai.profiles import ModelProfile from stirling.agents.pdf_create.agent import ( PdfCreateAgent, _make_chunks, _safe_filename, ) from stirling.contracts import ( EditCannotDoResponse, EditPlanResponse, OrchestratorRequest, ) from stirling.contracts.pdf_create import ( BulletListSection, DocumentMeta, DocumentSections, DocumentStyle, GeneratedDocument, KeyValueSection, LineItemsSection, PlannedSection, SectionDepth, SectionType, SignatureSection, TextSection, WrittenSections, ) from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams from stirling.services import build_runtime from stirling.services.runtime import AppRuntime _NATIVE_PROFILE = ModelProfile(supports_json_schema_output=True) # ── Fixtures ────────────────────────────────────────────────────────────────────────────────────── @pytest.fixture def runtime() -> AppRuntime: return build_runtime(build_app_settings()) @pytest.fixture def agent(runtime: AppRuntime) -> PdfCreateAgent: return PdfCreateAgent(runtime) # ── Helpers ─────────────────────────────────────────────────────────────────────────────────────── def _invoice_doc() -> GeneratedDocument: return GeneratedDocument( title="Invoice", subtitle="Acme Corp", reference_number="Invoice #INV-001", sections=[ KeyValueSection( heading="Details", pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06"), ("Currency", "USD")], ), LineItemsSection( heading="Line Items", columns=["Description", "Qty", "Unit Price", "Total"], rows=[ ["Consulting services", "10", "$500.00", "$5,000.00"], ["Expenses", "1", "$200.00", "$200.00"], ], total_row=["Total", "", "", "$5,200.00"], ), TextSection( heading="Payment Terms", body="Payment is due within 30 days.\n\nPlease reference the invoice number.", ), SignatureSection( heading="Authorised By", signatories=["Jane Smith, CEO", "Bob Jones, CFO"], ), ], ) def _simple_meta() -> DocumentMeta: return DocumentMeta( title="Invoice", subtitle="Acme Corp", tone_brief="Professional business tone.", shared_terms={"the Client": "Acme Corp"}, ) def _simple_sections() -> DocumentSections: return DocumentSections( sections=[ PlannedSection( heading="Details", type=SectionType.KEY_VALUE, depth=SectionDepth.BRIEF, key_points=["Date: 2026-05-06", "Due: 2026-06-06"], ), PlannedSection( heading="Line Items", type=SectionType.LINE_ITEMS, depth=SectionDepth.STANDARD, key_points=["Consulting services, 10h, $500/h", "Expenses, $200"], ), ] ) def _written_sections() -> WrittenSections: return WrittenSections( sections=[ KeyValueSection( heading="Details", pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06")], ), LineItemsSection( heading="Line Items", columns=["Description", "Qty", "Unit Price", "Total"], rows=[["Consulting services", "10", "$500.00", "$5,000.00"]], total_row=["Total", "", "", "$5,000.00"], ), ] ) def _orchestrator_request(message: str = "Create an invoice for Acme Corp") -> OrchestratorRequest: return OrchestratorRequest( user_message=message, files=[], conversation_history=[], artifacts=[], enabled_endpoints=[], ) # ── Section model validation ────────────────────────────────────────────────────────────────────── def test_text_section_round_trips() -> None: s = TextSection(heading="Summary", body="Hello\n\nWorld") assert s.type == "text" assert s.heading == "Summary" assert "World" in s.body def test_key_value_section_round_trips() -> None: s = KeyValueSection(pairs=[("Name", "Alice"), ("Role", "Engineer")]) assert s.type == "key_value" assert s.pairs[0] == ("Name", "Alice") def test_line_items_section_with_total_row() -> None: s = LineItemsSection( columns=["Item", "Amount"], rows=[["Widget", "$10"]], total_row=["Total", "$10"], ) assert s.total_row is not None assert s.total_row[1] == "$10" def test_line_items_section_optional_total_row() -> None: s = LineItemsSection(columns=["Item", "Amount"], rows=[["Widget", "$10"]]) assert s.total_row is None def test_bullet_list_section_round_trips() -> None: s = BulletListSection(items=["Alpha", "Beta", "Gamma"]) assert s.type == "bullet_list" assert len(s.items) == 3 def test_signature_section_round_trips() -> None: s = SignatureSection(signatories=["Alice", "Bob"]) assert s.type == "signature" assert "Alice" in s.signatories def test_generated_document_optional_fields() -> None: doc = GeneratedDocument(title="Simple Doc", sections=[TextSection(body="Hello")]) assert doc.subtitle is None assert doc.reference_number is None # ── Jinja rendering ─────────────────────────────────────────────────────────────────────────────── def test_render_produces_html(agent: PdfCreateAgent) -> None: doc = _invoice_doc() html = agent._render(doc) assert "" in html assert "Invoice" in html assert "INV-001" in html def test_render_includes_all_section_types(agent: PdfCreateAgent) -> None: doc = GeneratedDocument( title="All Sections", sections=[ TextSection(body="Some prose text."), KeyValueSection(pairs=[("Key", "Value")]), LineItemsSection(columns=["A", "B"], rows=[["1", "2"]]), BulletListSection(items=["item one"]), SignatureSection(signatories=["Alice"]), ], ) html = agent._render(doc) assert "Some prose text." in html assert "Key" in html and "Value" in html assert "" in html assert "item one" in html assert "Alice" in html def test_render_escapes_html_in_content(agent: PdfCreateAgent) -> None: doc = GeneratedDocument( title="XSS Test", sections=[TextSection(body="")], ) html = agent._render(doc) assert "