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

1"""Tests for outlines.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6 

7import pytest 

8 

9from coursebook import outline 

10 

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'] 

20 

21 

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] 

25 

26 

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' 

39 

40 

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] 

48 

49 

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] 

56 

57 

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] 

63 

64 

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 

71 

72 

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 

78 

79 

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' 

86 

87 

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')] 

93 

94 

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 

101 

102 

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 

108 

109 

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) 

118 

119 

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) 

125 

126 

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 

138 

139 

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']) 

144 

145 

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' 

163 

164 

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')])