Coverage for tests/test_project.py: 100%
209 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 22:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 22:06 +0000
1"""Tests for building projects."""
3from __future__ import annotations
5import logging
6import pathlib
7import sys
8import zipfile
10import docx
11import pptx
12import pytest
14import coursebook
15from coursebook import cli
18def _headings(path: pathlib.Path) -> list[tuple[str, str]]:
19 """Returns the style and text of each heading in a Word file."""
20 document = docx.Document(str(path))
21 return [
22 (paragraph.style.name, paragraph.text)
23 for paragraph in document.paragraphs
24 if paragraph.style.name.startswith('Heading')]
27def _slides(path: pathlib.Path) -> list[tuple[str, str]]:
28 """Returns the layout and title of each slide in a PowerPoint file."""
29 presentation = pptx.Presentation(str(path))
30 slides = []
31 for slide in presentation.slides:
32 title = slide.shapes.title
33 slides.append((
34 slide.slide_layout.name, title.text if title is not None else ''))
35 return slides
38def _part(path: pathlib.Path, name: str) -> str:
39 """Returns the text of a part of an Office file."""
40 with zipfile.ZipFile(path) as package:
41 return package.read(name).decode('utf-8')
44def test_project_from_folder(coursebook_project: pathlib.Path) -> None:
45 """Tests loading the coursebook test project."""
46 project = coursebook.Project.from_folder(coursebook_project)
47 assert isinstance(project.style, coursebook.Coursebook)
48 assert project.include == ['text', 'slides', 'notes']
49 assert project.missing == []
50 assert project.unused == []
51 assert project.output_folder == coursebook_project / 'output'
54def test_assemble_text(coursebook_project: pathlib.Path) -> None:
55 """Tests the combined markdown of the text."""
56 text = coursebook.Project.from_folder(coursebook_project).assemble('text')
57 assert text.startswith('# Introduction\n\nThis coursebook')
58 assert '## Why It Matters' in text
59 assert '[^introduction-1]' in text
60 assert '[^literature_review-1]' in text
61 assert '### The Underlying Facts' in text
62 assert '# This is code, not a slide' in text
63 assert '#### ' not in text
66def test_assemble_slides(coursebook_project: pathlib.Path) -> None:
67 """Tests the combined markdown of the slides."""
68 slides = coursebook.Project.from_folder(coursebook_project).assemble(
69 'slides')
70 assert slides.startswith('# Introduction\n\n## Welcome')
71 assert '# Literature Review\n\n## Literature Review\n\n- Three' in slides
72 assert '# Supporting Work' not in slides
73 assert '# My Cool Idea\n\n# Interesting Premise 1' in slides
76def test_assemble_notes(coursebook_project: pathlib.Path) -> None:
77 """Tests that the notes skip empty entries and copy visuals."""
78 notes = coursebook.Project.from_folder(coursebook_project).assemble(
79 'notes')
80 assert '{width=50%}' in notes
81 assert 'Literature Review' not in notes
82 assert '# My Cool Idea' in notes
85def test_assemble_unknown_section(coursebook_project: pathlib.Path) -> None:
86 """Tests that an unknown section raises an error."""
87 project = coursebook.Project.from_folder(coursebook_project)
88 with pytest.raises(ValueError, match = 'unknown section'):
89 project.assemble('handouts')
92def test_build_coursebook(coursebook_project: pathlib.Path) -> None:
93 """Tests building the text, slides, and notes of a coursebook."""
94 paths = coursebook.build(coursebook_project)
95 output = coursebook_project / 'output'
96 assert paths == [
97 output / 'crazy_idea.docx',
98 output / 'crazy_idea_slides.pptx',
99 output / 'crazy_idea_notes.docx']
100 assert _headings(paths[0]) == [
101 ('Heading 1', 'Introduction'),
102 ('Heading 2', 'Why It Matters'),
103 ('Heading 1', 'Literature Review'),
104 ('Heading 1', 'My Cool Idea'),
105 ('Heading 2', 'Interesting Premise 1'),
106 ('Heading 3', 'The Underlying Facts'),
107 ('Heading 3', 'Supporting Work'),
108 ('Heading 2', 'Interesting Premise 2'),
109 ('Heading 1', 'Conclusion')]
110 text = docx.Document(str(paths[0]))
111 assert text.styles['Normal'].font.name == 'Georgia'
112 assert any(name.startswith('word/media/') for name in zipfile.ZipFile(
113 paths[0]).namelist())
114 footnotes = _part(paths[0], 'word/footnotes.xml')
115 assert 'crazy only in the best sense' in footnotes
116 assert 'collected in the appendix' in footnotes
117 # The table of contents is a field that Word fills in.
118 assert 'TOC \\o' in _part(paths[0], 'word/document.xml')
121def test_build_slides(coursebook_project: pathlib.Path) -> None:
122 """Tests the slide deck of a coursebook."""
123 path = coursebook.build(coursebook_project, include = ['slides'])[0]
124 slides = _slides(path)
125 assert slides[0] == ('Title Slide', 'Crazy Idea')
126 assert slides[1:4] == [
127 ('Section Header', 'Introduction'),
128 ('Title and Content', 'Welcome'),
129 ('Title and Content', '')]
130 assert ('Title and Content', 'Literature Review') in slides
131 assert ('Title and Content', 'The Facts') in slides
132 presentation = pptx.Presentation(str(path))
133 last = presentation.slides[len(presentation.slides) - 1]
134 assert last.notes_slide.notes_text_frame.text == 'Leave time for questions.'
135 assert 'chalkboard' in _part(path, 'ppt/theme/theme1.xml')
138def test_build_notes(coursebook_project: pathlib.Path) -> None:
139 """Tests the notes of a coursebook."""
140 path = coursebook.build(coursebook_project, include = ['notes'])[0]
141 assert path.name == 'crazy_idea_notes.docx'
142 assert [text for _, text in _headings(path)] == [
143 'Introduction', 'My Cool Idea', 'Conclusion']
144 document = docx.Document(str(path))
145 paragraphs = [paragraph.text for paragraph in document.paragraphs]
146 assert 'A Coursebook: Notes' in paragraphs
149def test_build_law_review(
150 law_review_project: pathlib.Path,
151 caplog: pytest.LogCaptureFixture) -> None:
152 """Tests a law review with repeated labels, footnotes, and citations."""
153 with caplog.at_level(logging.WARNING):
154 text, slides = coursebook.build(law_review_project)
155 assert 'purpose.md' in caplog.text
156 assert text.name == 'a_modest_proposal_for_statutory_interpretation.docx'
157 assert _headings(text) == [
158 ('Heading 1', 'I. Introduction'),
159 ('Heading 1', 'II. Background'),
160 ('Heading 2', 'A. History'),
161 ('Heading 2', 'B. Current Law'),
162 ('Heading 1', 'III. Argument'),
163 ('Heading 2', 'A. Text'),
164 ('Heading 2', 'B. Purpose'),
165 ('Heading 1', 'IV. Conclusion')]
166 footnotes = _part(text, 'word/footnotes.xml')
167 assert 'First footnote in the introduction' in footnotes
168 assert 'First footnote in the history section' in footnotes
169 # The Bluebook citation style turns citations into footnotes.
170 assert 'Antonin Scalia' in footnotes
171 assert 'A Matter of Interpretation' in footnotes
172 paragraphs = [p.text for p in docx.Document(str(text)).paragraphs]
173 assert not any('Scalia' in paragraph for paragraph in paragraphs)
174 document = docx.Document(str(text))
175 assert document.styles['Normal'].font.name == 'Times New Roman'
176 assert _slides(slides)[1:] == [
177 ('Section Header', 'III. Argument'),
178 ('Title and Content', 'The Argument')]
181def test_word_template_from_project(law_review_project: pathlib.Path) -> None:
182 """Tests that a Word template in the project folder is used."""
183 template = law_review_project / 'house.docx'
184 house = docx.Document()
185 house.styles['Normal'].font.name = 'Courier New'
186 house.save(str(template))
187 project_file = law_review_project / 'project.toml'
188 project_file.write_text(
189 project_file.read_text(encoding = 'utf-8').replace(
190 '[project]', '[project]\ntext_template = "house"'),
191 encoding = 'utf-8')
192 path = coursebook.build(law_review_project, include = ['text'])[0]
193 assert docx.Document(str(path)).styles['Normal'].font.name == 'Courier New'
196def test_output_folder_override(
197 coursebook_project: pathlib.Path,
198 tmp_path: pathlib.Path) -> None:
199 """Tests building into another folder."""
200 paths = coursebook.build(
201 coursebook_project, include = ['text'],
202 output_folder = tmp_path / 'elsewhere')
203 assert paths == [tmp_path / 'elsewhere' / 'crazy_idea.docx']
204 assert paths[0].is_file()
207def test_project_without_outline(tmp_path: pathlib.Path) -> None:
208 """Tests that a project needs outline entries."""
209 (tmp_path / 'project.ini').write_text(
210 '[Project]\nName: Empty\n', encoding = 'utf-8')
211 with pytest.raises(ValueError, match = 'no outline entries'):
212 coursebook.Project.from_folder(tmp_path)
215def test_missing_bibliography(law_review_project: pathlib.Path) -> None:
216 """Tests that a missing bibliography raises an error."""
217 (law_review_project / 'references.bib').unlink()
218 with pytest.raises(FileNotFoundError, match = 'references.bib'):
219 coursebook.build(law_review_project)
222def test_cli(
223 coursebook_project: pathlib.Path,
224 capsys: pytest.CaptureFixture[str]) -> None:
225 """Tests building from the command line."""
226 (coursebook_project / 'extra.md').write_text('Unused', encoding = 'utf-8')
227 status = cli.main([
228 str(coursebook_project), '--include', 'manuscript', '-v'])
229 assert status == 0
230 output = capsys.readouterr().out
231 assert 'crazy_idea.docx' in output
232 assert 'crazy_idea_slides' not in output
235def test_cli_error(
236 tmp_path: pathlib.Path,
237 capsys: pytest.CaptureFixture[str]) -> None:
238 """Tests that errors are reported without a traceback."""
239 assert cli.main([str(tmp_path)]) == 1
240 assert 'no project file' in capsys.readouterr().err
243def _set_option(project: pathlib.Path, old: str, new: str) -> None:
244 """Replaces text in the project file of a test project."""
245 path = next(project.glob('project.*'))
246 text = path.read_text(encoding = 'utf-8')
247 assert old in text
248 path.write_text(text.replace(old, new, 1), encoding = 'utf-8')
251def test_build_pdf_and_html(law_review_project: pathlib.Path) -> None:
252 """Tests exporting the text to Word, PDF, and HTML at once."""
253 _set_option(
254 law_review_project, '[project]',
255 '[project]\ntext_format = ["word", "pdf", "html"]')
256 paths = coursebook.build(law_review_project, include = ['text'])
257 assert [path.suffix for path in paths] == ['.docx', '.pdf', '.html']
258 assert paths[1].read_bytes().startswith(b'%PDF')
259 html = paths[2].read_text(encoding = 'utf-8')
260 assert 'I. Introduction' in html
261 assert 'First footnote in the history section' in html
262 assert 'Antonin Scalia' in html
263 assert 'text-transform: uppercase' in html
266def test_html_embeds_images(coursebook_project: pathlib.Path) -> None:
267 """Tests that HTML files include their images."""
268 _set_option(
269 coursebook_project, 'Output Folder', 'Text Format: html\nOutput Folder')
270 path = coursebook.build(coursebook_project, include = ['text'])[0]
271 html = path.read_text(encoding = 'utf-8')
272 assert 'src="data:image/png;base64,' in html
273 assert 'id="TOC"' in html
276def test_slides_cannot_be_pdf(coursebook_project: pathlib.Path) -> None:
277 """Tests that formats that cannot hold slides raise an error."""
278 _set_option(
279 coursebook_project, 'Output Folder',
280 'Slides Format: pdf\nOutput Folder')
281 with pytest.raises(ValueError, match = 'use powerpoint'):
282 coursebook.build(coursebook_project, include = ['slides'])
285def test_pdf_needs_typst(
286 law_review_project: pathlib.Path,
287 monkeypatch: pytest.MonkeyPatch) -> None:
288 """Tests the error when the `typst` package is not installed."""
289 monkeypatch.setitem(sys.modules, 'typst', None)
290 _set_option(
291 law_review_project, '[project]', '[project]\ntext_format = "pdf"')
292 with pytest.raises(ImportError, match = 'coursebook\\[pdf\\]'):
293 coursebook.build(law_review_project, include = ['text'])
296def test_named_source_files(
297 coursebook_project: pathlib.Path,
298 caplog: pytest.LogCaptureFixture) -> None:
299 """Tests outline entries with their own files and shared files."""
300 chapters = coursebook_project / 'chapters'
301 chapters.mkdir()
302 (chapters / 'idea.md').write_text('From a subfolder.', encoding = 'utf-8')
303 _set_option(
304 coursebook_project, 'III: My Cool Idea',
305 '-: Preface\nIII: My Cool Idea | chapters/idea.md')
306 _set_option(
307 coursebook_project, 'IV: Conclusion',
308 'IV: Conclusion\nA: Introduction')
309 with caplog.at_level(logging.WARNING):
310 project = coursebook.Project.from_folder(coursebook_project)
311 assert 'both use "introduction.md"' in caplog.text
312 text = project.assemble('text')
313 assert '# My Cool Idea\n\nFrom a subfolder.' in text
314 assert '# Preface' in text
315 assert [entry.file_name for entry in project.missing] == ['preface.md']
316 assert [source.name for source in project.unused] == ['my_cool_idea']
319def test_csl_from_settings(law_review_project: pathlib.Path) -> None:
320 """Tests choosing an included citation style by name."""
321 _set_option(
322 law_review_project, '[project]',
323 '[project]\ncsl = "bluebook-law-review"')
324 _set_option(law_review_project, 'style = "law review"', 'style = "book"')
325 path = coursebook.build(law_review_project, include = ['text'])[0]
326 assert 'Antonin Scalia' in _part(path, 'word/footnotes.xml')
329def test_csl_is_ignored_without_bibliography(
330 coursebook_project: pathlib.Path) -> None:
331 """Tests that a missing CSL file is not an error without citations."""
332 _set_option(
333 coursebook_project, 'Output Folder', 'CSL: missing.csl\nOutput Folder')
334 assert coursebook.build(coursebook_project, include = ['text'])
337def test_missing_csl(law_review_project: pathlib.Path) -> None:
338 """Tests that a missing CSL file raises an error with citations."""
339 _set_option(
340 law_review_project, '[project]', '[project]\ncsl = "missing"')
341 with pytest.raises(FileNotFoundError, match = 'bluebook-law-review'):
342 coursebook.build(law_review_project, include = ['text'])
345def test_cli_new_and_template(
346 tmp_path: pathlib.Path,
347 capsys: pytest.CaptureFixture[str]) -> None:
348 """Tests creating a project and a template from the command line."""
349 outline_file = tmp_path / 'outline.txt'
350 outline_file.write_text(
351 '# My outline\nI: Introduction\n\nII: Duty\n', encoding = 'utf-8')
352 folder = tmp_path / 'torts'
353 status = cli.main([
354 'new', str(folder), '--name', 'Torts', '--style', 'book',
355 '--format', 'toml', '--outline', str(outline_file),
356 '--entry', '-: Appendix'])
357 assert status == 0
358 assert 'project.toml' in capsys.readouterr().out
359 project = coursebook.Project.from_folder(folder)
360 assert [entry.title for entry in project.outline] == [
361 'Introduction', 'Duty', 'Appendix']
362 assert cli.main(['build', str(folder), '-i', 'text']) == 0
363 assert (folder / 'output' / 'torts.docx').is_file()
364 assert cli.main(['new', str(folder)]) == 1
365 assert 'already has a project file' in capsys.readouterr().err
366 template = tmp_path / 'house.docx'
367 assert cli.main(['template', 'law review', str(template)]) == 0
368 assert template.is_file()
369 assert cli.main(['template', 'memo', str(tmp_path / 'x.docx')]) == 1
372def test_cli_default_project(
373 tmp_path: pathlib.Path,
374 monkeypatch: pytest.MonkeyPatch,
375 capsys: pytest.CaptureFixture[str]) -> None:
376 """Tests building the project in the current folder."""
377 cli.main(['new', str(tmp_path), '-e', 'I: Only'])
378 monkeypatch.chdir(tmp_path)
379 assert cli.main([]) == 0
380 assert 'slides' in capsys.readouterr().out