Coverage for tests/test_outline.py: 100%
80 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 outlines."""
3from __future__ import annotations
5import logging
7import pytest
9from coursebook import outline
11README_OUTLINE: list[str] = [
12 'I: Introduction',
13 'II: Literature Review',
14 'III: My Cool Idea',
15 'A: Interesting Premise 1',
16 '1: Underlying Facts',
17 '2: Supporting Work',
18 'B: Interesting Premise 2',
19 'IV: Conclusion']
22def _levels(project_outline: outline.Outline) -> list[tuple[str, int]]:
23 """Returns the label and level of each entry."""
24 return [(entry.label, entry.level) for entry in project_outline]
27def test_readme_outline() -> None:
28 """Tests the outline from the README."""
29 project_outline = outline.Outline.from_lines(README_OUTLINE)
30 assert _levels(project_outline) == [
31 ('I', 0), ('II', 0), ('III', 0), ('A', 1), ('1', 2), ('2', 2),
32 ('B', 1), ('IV', 0)]
33 assert len(project_outline) == 8
34 assert len(project_outline.entries) == 4
35 idea = project_outline.entries[2]
36 assert [child.title for child in idea.children] == [
37 'Interesting Premise 1', 'Interesting Premise 2']
38 assert idea.children[0].children[0].name == 'underlying_facts'
41def test_repeated_labels() -> None:
42 """Tests that labels may repeat under different parents."""
43 project_outline = outline.Outline.from_lines([
44 'I: One', 'A: First', 'B: Second', 'II: Two', 'A: Third', 'B: Fourth'])
45 assert _levels(project_outline) == [
46 ('I', 0), ('A', 1), ('B', 1), ('II', 0), ('A', 1), ('B', 1)]
47 assert [len(entry.children) for entry in project_outline.entries] == [2, 2]
50def test_deep_outline() -> None:
51 """Tests all five kinds of labels."""
52 project_outline = outline.Outline.from_lines([
53 'I: a', 'A: b', '1: c', 'a: d', 'i: e', 'ii: f', 'b: g', '2: h'])
54 assert [entry.level for entry in project_outline] == [
55 0, 1, 2, 3, 4, 4, 3, 2]
58def test_arabic_top_level() -> None:
59 """Tests a book outline that numbers chapters with Arabic numbers."""
60 project_outline = outline.Outline.from_lines([
61 '1: Chapter', 'A: Part', 'B: Part', '2: Chapter', '3: Chapter'])
62 assert [entry.level for entry in project_outline] == [0, 1, 1, 0, 0]
65def test_letter_i_after_h() -> None:
66 """Tests that 'i' after 'h' continues the letters."""
67 lines = ['I: x', 'A: x', '1: x'] + [
68 f'{letter}: x' for letter in 'abcdefghi']
69 levels = [entry.level for entry in outline.Outline.from_lines(lines)]
70 assert levels[-1] == 3
73def test_upper_i_after_h() -> None:
74 """Tests that 'I' after 'H' is a letter, not a Roman numeral."""
75 lines = ['I: x'] + [f'{letter}: x' for letter in 'ABCDEFGHI']
76 levels = [entry.level for entry in outline.Outline.from_lines(lines)]
77 assert levels[-1] == 1
80def test_label_punctuation() -> None:
81 """Tests labels with periods and parentheses."""
82 project_outline = outline.Outline.from_lines([
83 'I.: One', '(A): Part', 'B) = Part: With Colon'])
84 assert _levels(project_outline) == [('I', 0), ('A', 1), ('B', 1)]
85 assert project_outline.entries[0].children[1].title == 'Part: With Colon'
88def test_tuples_and_blank_lines() -> None:
89 """Tests label and title pairs and blank lines."""
90 project_outline = outline.Outline.from_lines(['', ('I.', ' One '), ' '])
91 assert [(entry.label, entry.title) for entry in project_outline] == [
92 ('I', 'One')]
95def test_skipped_label_warns(caplog: pytest.LogCaptureFixture) -> None:
96 """Tests that skipping labels warns but still places the entry."""
97 with caplog.at_level(logging.WARNING):
98 project_outline = outline.Outline.from_lines(['I: x', 'A: x', 'C: x'])
99 assert _levels(project_outline) == [('I', 0), ('A', 1), ('C', 1)]
100 assert 'skips labels' in caplog.text
103def test_double_letters() -> None:
104 """Tests that labels after 'Z' double the letter."""
105 lines = ['I: x'] + [f'{chr(65 + n)}: x' for n in range(26)] + ['AA: x']
106 levels = [entry.level for entry in outline.Outline.from_lines(lines)]
107 assert levels[-1] == 1
110@pytest.mark.parametrize('lines', [
111 ['A: x', 'A: y'],
112 ['I: x', 'Q7: y'],
113 ['B: x']])
114def test_invalid_labels(lines: list[str]) -> None:
115 """Tests labels that cannot be placed."""
116 with pytest.raises(ValueError, match = 'cannot place'):
117 outline.Outline.from_lines(lines)
120@pytest.mark.parametrize('line', ['Introduction', ': Title', 'I: '])
121def test_invalid_lines(line: str) -> None:
122 """Tests outline lines without a label or title."""
123 with pytest.raises(ValueError, match = 'label and a title'):
124 outline.parse_outline_line(line)
127def test_unnumbered_entries() -> None:
128 """Tests that unnumbered entries keep the numbering going."""
129 project_outline = outline.Outline.from_lines([
130 '-: Preface', '--: Thanks', 'I: Intro', 'A: Part', '-: Interlude',
131 'II: Two', 'A: x', '--: Aside', 'B: y', '-: Appendix', 'A: Data',
132 'III: Three'])
133 assert [(entry.label, entry.level) for entry in project_outline] == [
134 ('', 0), ('', 1), ('I', 0), ('A', 1), ('', 0), ('II', 0), ('A', 1),
135 ('', 1), ('B', 1), ('', 0), ('A', 1), ('III', 0)]
136 assert not project_outline.entries[0].numbered
137 assert project_outline.entries[1].numbered
140def test_unnumbered_entry_too_deep() -> None:
141 """Tests that an unnumbered entry cannot skip a level."""
142 with pytest.raises(ValueError, match = 'more than one level'):
143 outline.Outline.from_lines(['I: One', '---: Too Deep'])
146def test_entry_files() -> None:
147 """Tests outline entries that name their source files."""
148 project_outline = outline.Outline.from_lines([
149 'I: Introduction | chapters/Intro Chapter.md',
150 ('II', 'Law | Economics'),
151 ('III', 'Three', 'three.MD'),
152 'IV: Four | not a file'])
153 entries = list(project_outline)
154 assert entries[0].title == 'Introduction'
155 assert entries[0].file == 'chapters/Intro Chapter.md'
156 assert entries[0].name == 'chapters_intro_chapter'
157 assert entries[0].file_name == 'chapters/Intro Chapter.md'
158 assert entries[1].title == 'Law | Economics'
159 assert entries[1].file is None
160 assert entries[1].file_name == 'law_economics.md'
161 assert entries[2].file == 'three.MD'
162 assert entries[3].title == 'Four | not a file'
165def test_too_many_parts() -> None:
166 """Tests a tuple with too many parts."""
167 with pytest.raises(ValueError, match = 'label and a title'):
168 outline.Outline.from_lines([('I', 'One', 'one.md', 'extra')])