Skip to content

Commit c03b638

Browse files
committed
test: cover the Word (.docx) report export
Add tests for the docx helpers and the report export, skipping the module when python-docx is unavailable, and add python-docx to the test environments so CI still exercises the export.
1 parent cff21bd commit c03b638

2 files changed

Lines changed: 158 additions & 2 deletions

File tree

lumen/tests/ai/test_docx.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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

pixi.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ install = 'python -m pip install --no-deps --disable-pip-version-check -e .'
1010
PYTHONIOENCODING = "utf-8"
1111

1212
[environments]
13-
test-312 = ["py312", "test-core", "test", "ai", "sql", "xarray"]
14-
test-313 = ["py313", "test-core", "test", "ai", "sql", "xarray"]
13+
test-312 = ["py312", "test-core", "test", "ai", "sql", "xarray", "docx"]
14+
test-313 = ["py313", "test-core", "test", "ai", "sql", "xarray", "docx"]
1515
test-core = ["py313", "test-core"]
1616
docs = ["py312", "doc", "ai", "sql"]
1717
build = ["py312", "build"]
@@ -69,6 +69,9 @@ duckdb = "*"
6969
intake-sql = "*"
7070
sqlalchemy = "*"
7171

72+
[feature.docx.dependencies]
73+
python-docx = "*"
74+
7275
[feature.xarray.dependencies]
7376
xarray = "*"
7477
netcdf4 = "*"

0 commit comments

Comments
 (0)