|
| 1 | +from io import BytesIO |
| 2 | + |
| 3 | +import param |
| 4 | +import pytest |
| 5 | + |
| 6 | +try: |
| 7 | + import lumen.ai # noqa |
| 8 | +except ModuleNotFoundError: |
| 9 | + pytest.skip("lumen.ai could not be imported, skipping tests.", allow_module_level=True) |
| 10 | + |
| 11 | +# python-docx is an optional dependency; skip the whole module without it. |
| 12 | +pytest.importorskip("docx") |
| 13 | + |
| 14 | +from docx import Document |
| 15 | +from panel.pane import Markdown |
| 16 | + |
| 17 | +from lumen.ai.report import Action, Report, Section |
| 18 | + |
| 19 | + |
| 20 | +class ChartAction(Action): |
| 21 | + """Emits a pipeline-backed LumenEditor so the story catalog has real views.""" |
| 22 | + |
| 23 | + source = param.Parameter() |
| 24 | + |
| 25 | + label = param.String(default="Chart") |
| 26 | + |
| 27 | + async def _execute(self, context, **kwargs): |
| 28 | + from lumen.ai.editors import LumenEditor |
| 29 | + from lumen.pipeline import Pipeline |
| 30 | + |
| 31 | + pipeline = Pipeline(source=self.source, table='tiny') |
| 32 | + return [LumenEditor(component=pipeline, title=self.label)], {'text': self.label} |
| 33 | + |
| 34 | + |
| 35 | +class A(Action): |
| 36 | + |
| 37 | + order = param.List() |
| 38 | + |
| 39 | + async def _execute(self, context, **kwargs): |
| 40 | + return [Markdown("A done")], {'text': 'A'} |
| 41 | + |
| 42 | + |
| 43 | +class B(Action): |
| 44 | + |
| 45 | + order = param.List() |
| 46 | + |
| 47 | + async def _execute(self, context, **kwargs): |
| 48 | + return [Markdown("B done")], {'text': 'B'} |
| 49 | + |
| 50 | + |
| 51 | +def _docx_text(data: bytes) -> str: |
| 52 | + doc = Document(BytesIO(data)) |
| 53 | + return "\n".join(p.text for p in doc.paragraphs) |
| 54 | + |
| 55 | + |
| 56 | +def test_docx_add_markdown_headings_bold_bullets(): |
| 57 | + from lumen.ai.export import docx_add_markdown |
| 58 | + |
| 59 | + doc = Document() |
| 60 | + docx_add_markdown(doc, "# Title\n\nSome **bold** text\n\n- item one\n- item two") |
| 61 | + |
| 62 | + text = "\n".join(p.text for p in doc.paragraphs) |
| 63 | + assert "Title" in text |
| 64 | + assert "bold" in text |
| 65 | + assert "item one" in text |
| 66 | + assert any(p.style.name.startswith("Heading") for p in doc.paragraphs if p.text == "Title") |
| 67 | + assert any(run.bold for p in doc.paragraphs for run in p.runs if run.text == "bold") |
| 68 | + |
| 69 | + |
| 70 | +def test_docx_add_table_from_dataframe(): |
| 71 | + import pandas as pd |
| 72 | + |
| 73 | + from lumen.ai.export import docx_add_table |
| 74 | + |
| 75 | + doc = Document() |
| 76 | + df = pd.DataFrame({"category": ["A", "B"], "value": [1, 2]}) |
| 77 | + docx_add_table(doc, df) |
| 78 | + |
| 79 | + assert len(doc.tables) == 1 |
| 80 | + table = doc.tables[0] |
| 81 | + assert [c.text for c in table.rows[0].cells] == ["category", "value"] |
| 82 | + assert len(table.rows) == 3 # header + 2 data rows |
| 83 | + |
| 84 | + |
| 85 | +def test_docx_add_chart_embeds_png(): |
| 86 | + from PIL import Image |
| 87 | + |
| 88 | + from lumen.ai.export import docx_add_chart |
| 89 | + |
| 90 | + class FakeEditor: |
| 91 | + export_formats = ("png",) |
| 92 | + |
| 93 | + def export(self, fmt): |
| 94 | + buf = BytesIO() |
| 95 | + Image.new("RGB", (4, 4), "white").save(buf, "PNG") |
| 96 | + buf.seek(0) |
| 97 | + return buf |
| 98 | + |
| 99 | + doc = Document() |
| 100 | + assert docx_add_chart(doc, FakeEditor()) is True |
| 101 | + assert len(doc.inline_shapes) == 1 |
| 102 | + |
| 103 | + |
| 104 | +async def test_report_to_docx_headings_and_text(): |
| 105 | + report = Report( |
| 106 | + Section(A(order=[]), title='Section A'), |
| 107 | + Section(B(order=[]), title='Section B'), |
| 108 | + title='My Report', |
| 109 | + ) |
| 110 | + await report.execute() |
| 111 | + |
| 112 | + text = _docx_text(report.to_docx()) |
| 113 | + assert "My Report" in text |
| 114 | + assert "Section A" in text |
| 115 | + assert "A done" in text |
| 116 | + assert "B done" in text |
| 117 | + |
| 118 | + |
| 119 | +async def test_report_to_docx_includes_story(llm, tiny_source): |
| 120 | + from lumen.ai.agents.story import Story, StoryBlock |
| 121 | + |
| 122 | + report = Report( |
| 123 | + Section(ChartAction(source=tiny_source, label='Chart A'), title='Section A'), |
| 124 | + title='My Report', llm=llm, |
| 125 | + ) |
| 126 | + await report.execute() |
| 127 | + llm.set_responses([Story(chain_of_thought="c", title="Big Picture", blocks=[ |
| 128 | + StoryBlock(prose="Overall narrative."), StoryBlock(view=1), |
| 129 | + ])]) |
| 130 | + await report._annotate_report() |
| 131 | + |
| 132 | + text = _docx_text(report.to_docx()) |
| 133 | + assert "Big Picture" in text |
| 134 | + assert "Overall narrative." in text |
| 135 | + |
| 136 | + |
| 137 | +def test_export_menu_has_word_option(): |
| 138 | + report = Report(Section(A(order=[]), title='S'), title='R') |
| 139 | + assert "docx" in [item.get("format") for item in report._export.items] |
| 140 | + assert "docx" in [item.get("format") for item in report._dial.items] |
| 141 | + |
| 142 | + |
| 143 | +async def test_export_report_docx_returns_bytesio(): |
| 144 | + import io |
| 145 | + |
| 146 | + report = Report(Section(A(order=[]), title='S'), title='My Report') |
| 147 | + await report.execute() |
| 148 | + |
| 149 | + result = await report._export_report({"format": "docx"}) |
| 150 | + |
| 151 | + assert isinstance(result, io.BytesIO) |
| 152 | + assert report._download.filename == "My Report.docx" |
| 153 | + Document(result) # the bytes parse as a valid .docx |
0 commit comments