@opslane/claude-code-game 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/dist/cli.d.ts +2 -0
  2. package/dist/cli.js +59 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/routes/auth.d.ts +1 -0
  5. package/dist/routes/auth.js +123 -0
  6. package/dist/routes/auth.js.map +1 -0
  7. package/dist/routes/levels.d.ts +44 -0
  8. package/dist/routes/levels.js +78 -0
  9. package/dist/routes/levels.js.map +1 -0
  10. package/dist/routes/sessions.d.ts +17 -0
  11. package/dist/routes/sessions.js +303 -0
  12. package/dist/routes/sessions.js.map +1 -0
  13. package/dist/server.d.ts +2 -0
  14. package/dist/server.js +58 -0
  15. package/dist/server.js.map +1 -0
  16. package/dist/terminal.d.ts +6 -0
  17. package/dist/terminal.js +23 -0
  18. package/dist/terminal.js.map +1 -0
  19. package/dist/verification.d.ts +31 -0
  20. package/dist/verification.js +239 -0
  21. package/dist/verification.js.map +1 -0
  22. package/frontend/assets/index-CNVEnbfs.css +1 -0
  23. package/frontend/assets/index-D70xl9zu.js +27 -0
  24. package/frontend/index.html +14 -0
  25. package/frontend/vite.svg +1 -0
  26. package/keys/v1.pem +9 -0
  27. package/levels/01-context-is-everything/exercise/README.md +152 -0
  28. package/levels/01-context-is-everything/exercise/data/expenses.db +0 -0
  29. package/levels/01-context-is-everything/exercise/database.py +171 -0
  30. package/levels/01-context-is-everything/exercise/docs/FIRECRAWL_QUICKSTART.md +212 -0
  31. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_01.json +2306 -0
  32. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_02.json +2394 -0
  33. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_03.json +2251 -0
  34. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_04.json +1987 -0
  35. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_05.json +2229 -0
  36. package/levels/01-context-is-everything/exercise/main.py +97 -0
  37. package/levels/01-context-is-everything/exercise/models.py +141 -0
  38. package/levels/01-context-is-everything/exercise/pyproject.toml +52 -0
  39. package/levels/01-context-is-everything/exercise/reports.py +138 -0
  40. package/levels/01-context-is-everything/exercise/seed_data.py +91 -0
  41. package/levels/01-context-is-everything/exercise/tests/__init__.py +1 -0
  42. package/levels/01-context-is-everything/exercise/tests/conftest.py +69 -0
  43. package/levels/01-context-is-everything/exercise/tests/test_database.py +244 -0
  44. package/levels/01-context-is-everything/exercise/tests/test_models.py +240 -0
  45. package/levels/01-context-is-everything/exercise/tests/test_reports.py +190 -0
  46. package/levels/01-context-is-everything/exercise/utils.py +163 -0
  47. package/levels/01-context-is-everything/lesson.yaml +82 -0
  48. package/levels/02-claude-md/exercise/README.md +152 -0
  49. package/levels/02-claude-md/exercise/data/expenses.db +0 -0
  50. package/levels/02-claude-md/exercise/database.py +171 -0
  51. package/levels/02-claude-md/exercise/main.py +97 -0
  52. package/levels/02-claude-md/exercise/models.py +141 -0
  53. package/levels/02-claude-md/exercise/pyproject.toml +52 -0
  54. package/levels/02-claude-md/exercise/reports.py +138 -0
  55. package/levels/02-claude-md/exercise/seed_data.py +91 -0
  56. package/levels/02-claude-md/exercise/tests/__init__.py +1 -0
  57. package/levels/02-claude-md/exercise/tests/conftest.py +69 -0
  58. package/levels/02-claude-md/exercise/tests/test_database.py +244 -0
  59. package/levels/02-claude-md/exercise/tests/test_models.py +240 -0
  60. package/levels/02-claude-md/exercise/tests/test_reports.py +190 -0
  61. package/levels/02-claude-md/exercise/utils.py +163 -0
  62. package/levels/02-claude-md/lesson.yaml +60 -0
  63. package/levels/03-read-edit-verify/exercise/CLAUDE.md +15 -0
  64. package/levels/03-read-edit-verify/exercise/README.md +152 -0
  65. package/levels/03-read-edit-verify/exercise/data/expenses.db +0 -0
  66. package/levels/03-read-edit-verify/exercise/database.py +171 -0
  67. package/levels/03-read-edit-verify/exercise/main.py +97 -0
  68. package/levels/03-read-edit-verify/exercise/models.py +141 -0
  69. package/levels/03-read-edit-verify/exercise/pyproject.toml +52 -0
  70. package/levels/03-read-edit-verify/exercise/reports.py +138 -0
  71. package/levels/03-read-edit-verify/exercise/seed_data.py +91 -0
  72. package/levels/03-read-edit-verify/exercise/tests/__init__.py +1 -0
  73. package/levels/03-read-edit-verify/exercise/tests/conftest.py +69 -0
  74. package/levels/03-read-edit-verify/exercise/tests/test_database.py +244 -0
  75. package/levels/03-read-edit-verify/exercise/tests/test_models.py +240 -0
  76. package/levels/03-read-edit-verify/exercise/tests/test_reports.py +190 -0
  77. package/levels/03-read-edit-verify/exercise/utils.py +163 -0
  78. package/levels/03-read-edit-verify/lesson.yaml +60 -0
  79. package/levels/04-planning-mode/exercise/README.md +152 -0
  80. package/levels/04-planning-mode/exercise/data/expenses.db +0 -0
  81. package/levels/04-planning-mode/exercise/database.py +171 -0
  82. package/levels/04-planning-mode/exercise/main.py +97 -0
  83. package/levels/04-planning-mode/exercise/models.py +116 -0
  84. package/levels/04-planning-mode/exercise/pyproject.toml +52 -0
  85. package/levels/04-planning-mode/exercise/reports.py +138 -0
  86. package/levels/04-planning-mode/exercise/seed_data.py +91 -0
  87. package/levels/04-planning-mode/exercise/tests/__init__.py +1 -0
  88. package/levels/04-planning-mode/exercise/tests/conftest.py +69 -0
  89. package/levels/04-planning-mode/exercise/tests/test_database.py +244 -0
  90. package/levels/04-planning-mode/exercise/tests/test_expenses.db +0 -0
  91. package/levels/04-planning-mode/exercise/tests/test_models.py +240 -0
  92. package/levels/04-planning-mode/exercise/tests/test_reports.py +190 -0
  93. package/levels/04-planning-mode/exercise/utils.py +163 -0
  94. package/levels/04-planning-mode/lesson.yaml +53 -0
  95. package/levels/05-spec-driven/exercise/README.md +152 -0
  96. package/levels/05-spec-driven/exercise/data/expenses.db +0 -0
  97. package/levels/05-spec-driven/exercise/database.py +171 -0
  98. package/levels/05-spec-driven/exercise/main.py +97 -0
  99. package/levels/05-spec-driven/exercise/models.py +116 -0
  100. package/levels/05-spec-driven/exercise/pyproject.toml +52 -0
  101. package/levels/05-spec-driven/exercise/reports.py +138 -0
  102. package/levels/05-spec-driven/exercise/seed_data.py +91 -0
  103. package/levels/05-spec-driven/exercise/tests/__init__.py +1 -0
  104. package/levels/05-spec-driven/exercise/tests/conftest.py +69 -0
  105. package/levels/05-spec-driven/exercise/tests/test_database.py +244 -0
  106. package/levels/05-spec-driven/exercise/tests/test_expenses.db +0 -0
  107. package/levels/05-spec-driven/exercise/tests/test_models.py +240 -0
  108. package/levels/05-spec-driven/exercise/tests/test_reports.py +190 -0
  109. package/levels/05-spec-driven/exercise/utils.py +163 -0
  110. package/levels/05-spec-driven/lesson.yaml +53 -0
  111. package/levels/06-sub-agents/exercise/README.md +152 -0
  112. package/levels/06-sub-agents/exercise/data/expenses.db +0 -0
  113. package/levels/06-sub-agents/exercise/database.py +171 -0
  114. package/levels/06-sub-agents/exercise/main.py +97 -0
  115. package/levels/06-sub-agents/exercise/models.py +116 -0
  116. package/levels/06-sub-agents/exercise/pyproject.toml +52 -0
  117. package/levels/06-sub-agents/exercise/reports.py +63 -0
  118. package/levels/06-sub-agents/exercise/seed_data.py +91 -0
  119. package/levels/06-sub-agents/exercise/tests/__init__.py +1 -0
  120. package/levels/06-sub-agents/exercise/tests/conftest.py +69 -0
  121. package/levels/06-sub-agents/exercise/tests/test_database.py +244 -0
  122. package/levels/06-sub-agents/exercise/tests/test_models.py +240 -0
  123. package/levels/06-sub-agents/exercise/tests/test_reports.py +190 -0
  124. package/levels/06-sub-agents/exercise/utils.py +163 -0
  125. package/levels/06-sub-agents/lesson.yaml +49 -0
  126. package/levels/07-skills/exercise/README.md +152 -0
  127. package/levels/07-skills/exercise/data/expenses.db +0 -0
  128. package/levels/07-skills/exercise/database.py +171 -0
  129. package/levels/07-skills/exercise/main.py +97 -0
  130. package/levels/07-skills/exercise/models.py +116 -0
  131. package/levels/07-skills/exercise/pyproject.toml +52 -0
  132. package/levels/07-skills/exercise/reports.py +63 -0
  133. package/levels/07-skills/exercise/seed_data.py +91 -0
  134. package/levels/07-skills/exercise/tests/__init__.py +1 -0
  135. package/levels/07-skills/exercise/tests/conftest.py +69 -0
  136. package/levels/07-skills/exercise/tests/test_database.py +244 -0
  137. package/levels/07-skills/exercise/tests/test_models.py +240 -0
  138. package/levels/07-skills/exercise/tests/test_reports.py +190 -0
  139. package/levels/07-skills/exercise/utils.py +163 -0
  140. package/levels/07-skills/lesson.yaml +49 -0
  141. package/levels/08-mcp-servers/exercise/README.md +152 -0
  142. package/levels/08-mcp-servers/exercise/data/expenses.db +0 -0
  143. package/levels/08-mcp-servers/exercise/database.py +171 -0
  144. package/levels/08-mcp-servers/exercise/main.py +97 -0
  145. package/levels/08-mcp-servers/exercise/models.py +116 -0
  146. package/levels/08-mcp-servers/exercise/pyproject.toml +52 -0
  147. package/levels/08-mcp-servers/exercise/reports.py +63 -0
  148. package/levels/08-mcp-servers/exercise/seed_data.py +91 -0
  149. package/levels/08-mcp-servers/exercise/tests/__init__.py +1 -0
  150. package/levels/08-mcp-servers/exercise/tests/conftest.py +69 -0
  151. package/levels/08-mcp-servers/exercise/tests/test_database.py +244 -0
  152. package/levels/08-mcp-servers/exercise/tests/test_models.py +240 -0
  153. package/levels/08-mcp-servers/exercise/tests/test_reports.py +190 -0
  154. package/levels/08-mcp-servers/exercise/utils.py +163 -0
  155. package/levels/08-mcp-servers/lesson.yaml +59 -0
  156. package/levels/09-plugins/exercise/README.md +152 -0
  157. package/levels/09-plugins/exercise/data/expenses.db +0 -0
  158. package/levels/09-plugins/exercise/database.py +171 -0
  159. package/levels/09-plugins/exercise/main.py +97 -0
  160. package/levels/09-plugins/exercise/models.py +116 -0
  161. package/levels/09-plugins/exercise/pyproject.toml +52 -0
  162. package/levels/09-plugins/exercise/reports.py +63 -0
  163. package/levels/09-plugins/exercise/seed_data.py +91 -0
  164. package/levels/09-plugins/exercise/tests/__init__.py +1 -0
  165. package/levels/09-plugins/exercise/tests/conftest.py +69 -0
  166. package/levels/09-plugins/exercise/tests/test_database.py +244 -0
  167. package/levels/09-plugins/exercise/tests/test_models.py +240 -0
  168. package/levels/09-plugins/exercise/tests/test_reports.py +190 -0
  169. package/levels/09-plugins/exercise/utils.py +163 -0
  170. package/levels/09-plugins/lesson.yaml +51 -0
  171. package/levels/10-hooks/exercise/README.md +152 -0
  172. package/levels/10-hooks/exercise/data/expenses.db +0 -0
  173. package/levels/10-hooks/exercise/database.py +171 -0
  174. package/levels/10-hooks/exercise/main.py +97 -0
  175. package/levels/10-hooks/exercise/models.py +116 -0
  176. package/levels/10-hooks/exercise/pyproject.toml +52 -0
  177. package/levels/10-hooks/exercise/reports.py +63 -0
  178. package/levels/10-hooks/exercise/seed_data.py +91 -0
  179. package/levels/10-hooks/exercise/tests/__init__.py +1 -0
  180. package/levels/10-hooks/exercise/tests/conftest.py +69 -0
  181. package/levels/10-hooks/exercise/tests/test_database.py +244 -0
  182. package/levels/10-hooks/exercise/tests/test_models.py +240 -0
  183. package/levels/10-hooks/exercise/tests/test_reports.py +190 -0
  184. package/levels/10-hooks/exercise/utils.py +163 -0
  185. package/levels/10-hooks/lesson.yaml +58 -0
  186. package/levels/11-worktrees/exercise/README.md +152 -0
  187. package/levels/11-worktrees/exercise/data/expenses.db +0 -0
  188. package/levels/11-worktrees/exercise/database.py +171 -0
  189. package/levels/11-worktrees/exercise/main.py +97 -0
  190. package/levels/11-worktrees/exercise/models.py +116 -0
  191. package/levels/11-worktrees/exercise/pyproject.toml +52 -0
  192. package/levels/11-worktrees/exercise/reports.py +63 -0
  193. package/levels/11-worktrees/exercise/seed_data.py +91 -0
  194. package/levels/11-worktrees/exercise/tests/__init__.py +1 -0
  195. package/levels/11-worktrees/exercise/tests/conftest.py +69 -0
  196. package/levels/11-worktrees/exercise/tests/test_database.py +244 -0
  197. package/levels/11-worktrees/exercise/tests/test_models.py +240 -0
  198. package/levels/11-worktrees/exercise/tests/test_reports.py +190 -0
  199. package/levels/11-worktrees/exercise/utils.py +163 -0
  200. package/levels/11-worktrees/lesson.yaml +68 -0
  201. package/package.json +38 -0
@@ -0,0 +1,244 @@
1
+ """Tests for database operations."""
2
+
3
+ import pytest
4
+ from datetime import datetime
5
+ from database import (
6
+ init_db,
7
+ save_expense,
8
+ load_expenses,
9
+ get_expense_by_id,
10
+ remove_expense,
11
+ clear_all_expenses,
12
+ get_expense_count
13
+ )
14
+ from models import Expense
15
+
16
+
17
+ class TestDatabaseInit:
18
+ """Tests for database initialization."""
19
+
20
+ def test_init_creates_table(self):
21
+ """Test that init_db creates the expenses table."""
22
+ init_db()
23
+ # If we get here without error, table was created
24
+ expenses = load_expenses()
25
+ assert isinstance(expenses, list)
26
+
27
+ def test_init_idempotent(self):
28
+ """Test that init_db can be called multiple times safely."""
29
+ init_db()
30
+ init_db()
31
+ init_db()
32
+ # Should not raise any errors
33
+
34
+
35
+ class TestSaveAndLoad:
36
+ """Tests for saving and loading expenses."""
37
+
38
+ def test_save_and_load_single(self):
39
+ """Test saving and loading a single expense."""
40
+ expense = Expense(
41
+ id="test123",
42
+ amount=50.0,
43
+ category="Food",
44
+ description="Test expense",
45
+ date=datetime(2026, 1, 15, 12, 0)
46
+ )
47
+ save_expense(expense)
48
+
49
+ expenses = load_expenses()
50
+ assert len(expenses) == 1
51
+ assert expenses[0].id == "test123"
52
+ assert expenses[0].amount == 50.0
53
+ assert expenses[0].category == "Food"
54
+
55
+ def test_save_multiple(self):
56
+ """Test saving multiple expenses."""
57
+ for i in range(5):
58
+ expense = Expense(
59
+ id=f"test{i}",
60
+ amount=float(i * 10),
61
+ category="Test",
62
+ description=f"Expense {i}",
63
+ date=datetime.now()
64
+ )
65
+ save_expense(expense)
66
+
67
+ expenses = load_expenses()
68
+ assert len(expenses) == 5
69
+
70
+ def test_load_preserves_data(self):
71
+ """Test that all expense fields are preserved on load."""
72
+ original = Expense(
73
+ id="preserve123",
74
+ amount=99.99,
75
+ category="Shopping",
76
+ description="Test all fields",
77
+ date=datetime(2026, 6, 15, 14, 30),
78
+ recurring=True,
79
+ recurring_frequency="monthly"
80
+ )
81
+ save_expense(original)
82
+
83
+ loaded = load_expenses()[0]
84
+ assert loaded.id == original.id
85
+ assert loaded.amount == original.amount
86
+ assert loaded.category == original.category
87
+ assert loaded.description == original.description
88
+ assert loaded.date == original.date
89
+ assert loaded.recurring == original.recurring
90
+ assert loaded.recurring_frequency == original.recurring_frequency
91
+
92
+ def test_load_ordered_by_date(self):
93
+ """Test that load_expenses returns results ordered by date descending."""
94
+ dates = [
95
+ datetime(2026, 1, 1),
96
+ datetime(2026, 1, 15),
97
+ datetime(2026, 1, 10),
98
+ ]
99
+ for i, date in enumerate(dates):
100
+ expense = Expense(
101
+ id=f"date{i}",
102
+ amount=10.0,
103
+ category="Test",
104
+ description=f"Expense {i}",
105
+ date=date
106
+ )
107
+ save_expense(expense)
108
+
109
+ expenses = load_expenses()
110
+ loaded_dates = [e.date for e in expenses]
111
+ assert loaded_dates == sorted(loaded_dates, reverse=True)
112
+
113
+
114
+ class TestGetExpenseById:
115
+ """Tests for getting expense by ID."""
116
+
117
+ def test_get_existing(self):
118
+ """Test retrieving an existing expense by ID."""
119
+ expense = Expense(
120
+ id="findme123",
121
+ amount=25.0,
122
+ category="Transport",
123
+ description="Bus fare",
124
+ date=datetime.now()
125
+ )
126
+ save_expense(expense)
127
+
128
+ result = get_expense_by_id("findme123")
129
+ assert result is not None
130
+ assert result.id == "findme123"
131
+ assert result.description == "Bus fare"
132
+
133
+ def test_get_nonexistent(self):
134
+ """Test that non-existent ID returns None."""
135
+ result = get_expense_by_id("does-not-exist")
136
+ assert result is None
137
+
138
+ def test_get_after_multiple_saves(self):
139
+ """Test getting specific expense after saving multiple."""
140
+ for i in range(10):
141
+ expense = Expense(
142
+ id=f"multi{i}",
143
+ amount=float(i),
144
+ category="Test",
145
+ description=f"Expense {i}",
146
+ date=datetime.now()
147
+ )
148
+ save_expense(expense)
149
+
150
+ result = get_expense_by_id("multi5")
151
+ assert result is not None
152
+ assert result.amount == 5.0
153
+
154
+
155
+ class TestRemoveExpense:
156
+ """Tests for removing expenses."""
157
+
158
+ def test_remove_existing(self):
159
+ """Test removing an existing expense."""
160
+ expense = Expense(
161
+ id="removeme",
162
+ amount=10.0,
163
+ category="Test",
164
+ description="To be removed",
165
+ date=datetime.now()
166
+ )
167
+ save_expense(expense)
168
+ assert get_expense_by_id("removeme") is not None
169
+
170
+ result = remove_expense("removeme")
171
+ assert result is True
172
+ assert get_expense_by_id("removeme") is None
173
+
174
+ def test_remove_nonexistent(self):
175
+ """Test removing non-existent expense returns False."""
176
+ result = remove_expense("fake-id")
177
+ assert result is False
178
+
179
+ def test_remove_only_target(self):
180
+ """Test that remove only deletes the target expense."""
181
+ for i in range(3):
182
+ expense = Expense(
183
+ id=f"keep{i}",
184
+ amount=float(i),
185
+ category="Test",
186
+ description=f"Expense {i}",
187
+ date=datetime.now()
188
+ )
189
+ save_expense(expense)
190
+
191
+ remove_expense("keep1")
192
+
193
+ expenses = load_expenses()
194
+ ids = [e.id for e in expenses]
195
+ assert "keep0" in ids
196
+ assert "keep1" not in ids
197
+ assert "keep2" in ids
198
+
199
+
200
+ class TestClearAll:
201
+ """Tests for clearing all expenses."""
202
+
203
+ def test_clear_removes_all(self):
204
+ """Test that clear_all_expenses removes everything."""
205
+ for i in range(5):
206
+ expense = Expense(
207
+ id=f"clear{i}",
208
+ amount=float(i),
209
+ category="Test",
210
+ description=f"Expense {i}",
211
+ date=datetime.now()
212
+ )
213
+ save_expense(expense)
214
+
215
+ assert len(load_expenses()) == 5
216
+
217
+ clear_all_expenses()
218
+ assert len(load_expenses()) == 0
219
+
220
+ def test_clear_empty_db(self):
221
+ """Test that clearing empty database doesn't error."""
222
+ clear_all_expenses() # Should not raise
223
+
224
+
225
+ class TestExpenseCount:
226
+ """Tests for getting expense count."""
227
+
228
+ def test_count_empty(self):
229
+ """Test count on empty database."""
230
+ assert get_expense_count() == 0
231
+
232
+ def test_count_with_expenses(self):
233
+ """Test count with expenses."""
234
+ for i in range(7):
235
+ expense = Expense(
236
+ id=f"count{i}",
237
+ amount=float(i),
238
+ category="Test",
239
+ description=f"Expense {i}",
240
+ date=datetime.now()
241
+ )
242
+ save_expense(expense)
243
+
244
+ assert get_expense_count() == 7
@@ -0,0 +1,240 @@
1
+ """Tests for expense models.
2
+
3
+ This test file includes intentionally failing tests that students fix
4
+ during the course:
5
+
6
+ - test_add_negative_amount_should_fail: Fails until Lesson 3 (validation bug)
7
+ - test_list_by_category_case_insensitive: Fails until a later lesson (case bug)
8
+ """
9
+
10
+ import pytest
11
+ from datetime import datetime
12
+ from models import (
13
+ add_expense,
14
+ list_expenses,
15
+ delete_expense,
16
+ get_expense,
17
+ get_total_by_category,
18
+ Expense
19
+ )
20
+
21
+
22
+ class TestAddExpense:
23
+ """Tests for the add_expense function."""
24
+
25
+ def test_add_basic_expense(self):
26
+ """Test adding a simple expense."""
27
+ expense = add_expense(50.0, "Food", "Lunch at cafe")
28
+
29
+ assert expense.amount == 50.0
30
+ assert expense.category == "Food"
31
+ assert expense.description == "Lunch at cafe"
32
+ assert expense.id is not None
33
+ assert len(expense.id) == 8 # UUID prefix
34
+
35
+ def test_add_expense_with_date(self):
36
+ """Test adding expense with specific date."""
37
+ date = datetime(2026, 1, 15, 12, 30)
38
+ expense = add_expense(25.0, "Transport", "Uber to meeting", date)
39
+
40
+ assert expense.date == date
41
+ assert expense.amount == 25.0
42
+
43
+ def test_add_expense_default_date(self):
44
+ """Test that expense gets current date if not specified."""
45
+ before = datetime.now()
46
+ expense = add_expense(10.0, "Food", "Snack")
47
+ after = datetime.now()
48
+
49
+ assert before <= expense.date <= after
50
+
51
+ def test_add_expense_persists(self):
52
+ """Test that added expense can be retrieved."""
53
+ expense = add_expense(75.0, "Shopping", "New book")
54
+
55
+ retrieved = get_expense(expense.id)
56
+ assert retrieved is not None
57
+ assert retrieved.amount == 75.0
58
+ assert retrieved.description == "New book"
59
+
60
+ # =========================================================
61
+ # BUG TESTS - These fail until students fix them in Lesson 3
62
+ # =========================================================
63
+
64
+ def test_add_negative_amount_should_fail(self):
65
+ """BUG TEST: Negative amounts should raise ValueError.
66
+
67
+ This test FAILS initially - students fix this in Lesson 3
68
+ by adding validation to add_expense().
69
+
70
+ Expected fix:
71
+ if amount <= 0:
72
+ raise ValueError("Amount must be greater than 0")
73
+ """
74
+ with pytest.raises(ValueError, match="[Aa]mount"):
75
+ add_expense(-50.0, "Food", "Invalid expense")
76
+
77
+ def test_add_zero_amount_should_fail(self):
78
+ """BUG TEST: Zero amount should raise ValueError.
79
+
80
+ This test FAILS initially - students fix this in Lesson 3.
81
+ """
82
+ with pytest.raises(ValueError, match="[Aa]mount"):
83
+ add_expense(0, "Food", "Free lunch doesn't exist")
84
+
85
+ def test_add_empty_category_should_fail(self):
86
+ """BUG TEST: Empty category should raise ValueError.
87
+
88
+ This test FAILS initially - students fix this in Lesson 3.
89
+ """
90
+ with pytest.raises(ValueError, match="[Cc]ategory"):
91
+ add_expense(50.0, "", "No category")
92
+
93
+ def test_add_whitespace_category_should_fail(self):
94
+ """BUG TEST: Whitespace-only category should raise ValueError."""
95
+ with pytest.raises(ValueError, match="[Cc]ategory"):
96
+ add_expense(50.0, " ", "Whitespace category")
97
+
98
+
99
+ class TestListExpenses:
100
+ """Tests for the list_expenses function."""
101
+
102
+ def test_list_all_expenses(self, sample_expenses):
103
+ """Test listing all expenses without filters."""
104
+ expenses = list_expenses()
105
+ assert len(expenses) == 5
106
+
107
+ def test_list_empty(self):
108
+ """Test listing when no expenses exist."""
109
+ expenses = list_expenses()
110
+ assert len(expenses) == 0
111
+
112
+ def test_list_by_category(self):
113
+ """Test filtering by category (exact match)."""
114
+ add_expense(50.0, "Food", "Lunch")
115
+ add_expense(30.0, "Transport", "Uber")
116
+ add_expense(25.0, "Food", "Coffee")
117
+
118
+ food = list_expenses(category="Food")
119
+ assert len(food) == 2
120
+ assert all(e.category == "Food" for e in food)
121
+
122
+ def test_list_by_month(self):
123
+ """Test filtering by month."""
124
+ add_expense(50.0, "Food", "Jan expense", datetime(2026, 1, 15))
125
+ add_expense(30.0, "Food", "Feb expense", datetime(2026, 2, 10))
126
+
127
+ jan = list_expenses(month="2026-01")
128
+ assert len(jan) == 1
129
+ assert jan[0].description == "Jan expense"
130
+
131
+ def test_list_with_limit(self):
132
+ """Test limiting number of results."""
133
+ for i in range(10):
134
+ add_expense(float(i + 1), "Food", f"Expense {i}")
135
+
136
+ limited = list_expenses(limit=5)
137
+ assert len(limited) == 5
138
+
139
+ def test_list_sorted_by_date(self):
140
+ """Test that results are sorted by date descending."""
141
+ add_expense(10.0, "Food", "Old", datetime(2026, 1, 1))
142
+ add_expense(20.0, "Food", "New", datetime(2026, 1, 15))
143
+ add_expense(30.0, "Food", "Middle", datetime(2026, 1, 10))
144
+
145
+ expenses = list_expenses()
146
+ dates = [e.date for e in expenses]
147
+ assert dates == sorted(dates, reverse=True)
148
+
149
+ # =========================================================
150
+ # BUG TESTS - These fail until students fix them in a later lesson
151
+ # =========================================================
152
+
153
+ def test_list_by_category_case_insensitive(self, mixed_case_expenses):
154
+ """BUG TEST: Category filter should be case-insensitive.
155
+
156
+ This test FAILS initially - students fix this in a later lesson.
157
+
158
+ The seed data includes lowercase "food" entries that won't
159
+ match "Food" until the bug is fixed.
160
+
161
+ Expected fix in list_expenses():
162
+ if category:
163
+ category_lower = category.lower()
164
+ expenses = [e for e in expenses if e.category.lower() == category_lower]
165
+ """
166
+ # mixed_case_expenses has: "Food", "food", "FOOD"
167
+ food = list_expenses(category="Food")
168
+ assert len(food) == 3, (
169
+ f"Expected 3 'Food' expenses (case-insensitive), got {len(food)}. "
170
+ "Fix: make category filtering case-insensitive."
171
+ )
172
+
173
+ def test_list_category_lowercase_query(self, mixed_case_expenses):
174
+ """BUG TEST: Lowercase category query should still find matches."""
175
+ food = list_expenses(category="food") # lowercase query
176
+ assert len(food) == 3, "Lowercase 'food' should match all Food variants"
177
+
178
+
179
+ class TestDeleteExpense:
180
+ """Tests for the delete_expense function."""
181
+
182
+ def test_delete_existing(self):
183
+ """Test deleting an existing expense."""
184
+ expense = add_expense(50.0, "Food", "To be deleted")
185
+
186
+ result = delete_expense(expense.id)
187
+ assert result is True
188
+
189
+ # Verify it's gone
190
+ assert get_expense(expense.id) is None
191
+
192
+ def test_delete_nonexistent(self):
193
+ """Test deleting non-existent expense returns False."""
194
+ result = delete_expense("fake-id-12345")
195
+ assert result is False
196
+
197
+ def test_delete_twice(self):
198
+ """Test that deleting same expense twice fails second time."""
199
+ expense = add_expense(50.0, "Food", "Delete me")
200
+
201
+ assert delete_expense(expense.id) is True
202
+ assert delete_expense(expense.id) is False
203
+
204
+
205
+ class TestGetExpense:
206
+ """Tests for the get_expense function."""
207
+
208
+ def test_get_existing(self):
209
+ """Test getting an existing expense."""
210
+ created = add_expense(99.99, "Shopping", "Test item")
211
+
212
+ retrieved = get_expense(created.id)
213
+ assert retrieved is not None
214
+ assert retrieved.id == created.id
215
+ assert retrieved.amount == 99.99
216
+
217
+ def test_get_nonexistent(self):
218
+ """Test getting non-existent expense returns None."""
219
+ result = get_expense("nonexistent-id")
220
+ assert result is None
221
+
222
+
223
+ class TestGetTotalByCategory:
224
+ """Tests for the get_total_by_category function."""
225
+
226
+ def test_total_single_category(self):
227
+ """Test total for a single category."""
228
+ add_expense(50.0, "Food", "Item 1")
229
+ add_expense(30.0, "Food", "Item 2")
230
+ add_expense(100.0, "Shopping", "Other category")
231
+
232
+ total = get_total_by_category("Food")
233
+ assert total == 80.0
234
+
235
+ def test_total_empty_category(self):
236
+ """Test total for category with no expenses."""
237
+ add_expense(50.0, "Food", "Food item")
238
+
239
+ total = get_total_by_category("Transport")
240
+ assert total == 0.0
@@ -0,0 +1,190 @@
1
+ """Tests for report generation.
2
+
3
+ Note: These tests will FAIL until students implement reports.py in Lesson 4.
4
+ The tests serve as a specification for what needs to be implemented.
5
+ """
6
+
7
+ import pytest
8
+ from datetime import datetime
9
+ from models import add_expense, Expense
10
+ from reports import (
11
+ generate_monthly_report,
12
+ get_category_breakdown,
13
+ get_top_expenses,
14
+ save_report
15
+ )
16
+
17
+
18
+ class TestGenerateMonthlyReport:
19
+ """Tests for the generate_monthly_report function."""
20
+
21
+ @pytest.mark.skip(reason="Implement in Lesson 4")
22
+ def test_report_for_empty_month(self):
23
+ """Test report generation for month with no expenses."""
24
+ report = generate_monthly_report("2020-01")
25
+ assert "No expenses" in report or "no expenses" in report.lower()
26
+
27
+ @pytest.mark.skip(reason="Implement in Lesson 4")
28
+ def test_report_includes_total(self):
29
+ """Test that report includes total amount."""
30
+ add_expense(100.0, "Food", "Groceries", datetime(2026, 1, 15))
31
+ add_expense(50.0, "Transport", "Uber", datetime(2026, 1, 15))
32
+
33
+ report = generate_monthly_report("2026-01")
34
+
35
+ # Should contain total of $150
36
+ assert "150" in report
37
+
38
+ @pytest.mark.skip(reason="Implement in Lesson 4")
39
+ def test_report_includes_categories(self):
40
+ """Test that report includes category breakdown."""
41
+ add_expense(100.0, "Food", "Groceries", datetime(2026, 1, 15))
42
+ add_expense(50.0, "Transport", "Uber", datetime(2026, 1, 15))
43
+
44
+ report = generate_monthly_report("2026-01")
45
+
46
+ assert "Food" in report
47
+ assert "Transport" in report
48
+
49
+ @pytest.mark.skip(reason="Implement in Lesson 4")
50
+ def test_report_defaults_to_current_month(self):
51
+ """Test that no month argument uses current month."""
52
+ # This test is tricky since it depends on current date
53
+ # Just verify it doesn't error
54
+ report = generate_monthly_report()
55
+ assert report is not None
56
+ assert isinstance(report, str)
57
+
58
+ @pytest.mark.skip(reason="Implement in Lesson 4")
59
+ def test_report_format_is_markdown(self):
60
+ """Test that report is formatted as markdown."""
61
+ add_expense(100.0, "Food", "Test", datetime(2026, 1, 15))
62
+ report = generate_monthly_report("2026-01")
63
+
64
+ # Should have markdown headers
65
+ assert "#" in report
66
+
67
+
68
+ class TestGetCategoryBreakdown:
69
+ """Tests for the get_category_breakdown function."""
70
+
71
+ @pytest.mark.skip(reason="Implement in Lesson 4")
72
+ def test_single_category(self):
73
+ """Test breakdown with single category."""
74
+ expenses = [
75
+ Expense("1", 50.0, "Food", "Lunch", datetime.now()),
76
+ Expense("2", 25.0, "Food", "Coffee", datetime.now()),
77
+ ]
78
+
79
+ breakdown = get_category_breakdown(expenses)
80
+
81
+ assert "Food" in breakdown
82
+ assert breakdown["Food"] == 75.0
83
+
84
+ @pytest.mark.skip(reason="Implement in Lesson 4")
85
+ def test_multiple_categories(self):
86
+ """Test breakdown with multiple categories."""
87
+ expenses = [
88
+ Expense("1", 50.0, "Food", "Lunch", datetime.now()),
89
+ Expense("2", 30.0, "Transport", "Bus", datetime.now()),
90
+ Expense("3", 20.0, "Food", "Snack", datetime.now()),
91
+ ]
92
+
93
+ breakdown = get_category_breakdown(expenses)
94
+
95
+ assert breakdown["Food"] == 70.0
96
+ assert breakdown["Transport"] == 30.0
97
+
98
+ @pytest.mark.skip(reason="Implement in Lesson 4")
99
+ def test_empty_list(self):
100
+ """Test breakdown with empty expense list."""
101
+ breakdown = get_category_breakdown([])
102
+ assert breakdown == {}
103
+
104
+
105
+ class TestGetTopExpenses:
106
+ """Tests for the get_top_expenses function."""
107
+
108
+ @pytest.mark.skip(reason="Implement in Lesson 4")
109
+ def test_top_expenses_ordering(self):
110
+ """Test that expenses are sorted by amount descending."""
111
+ expenses = [
112
+ Expense("1", 25.0, "Food", "Small", datetime.now()),
113
+ Expense("2", 100.0, "Bills", "Big", datetime.now()),
114
+ Expense("3", 50.0, "Shopping", "Medium", datetime.now()),
115
+ ]
116
+
117
+ top = get_top_expenses(expenses, 3)
118
+
119
+ assert top[0].amount == 100.0
120
+ assert top[1].amount == 50.0
121
+ assert top[2].amount == 25.0
122
+
123
+ @pytest.mark.skip(reason="Implement in Lesson 4")
124
+ def test_top_n_limit(self):
125
+ """Test limiting to N expenses."""
126
+ expenses = [
127
+ Expense(str(i), float(i * 10), "Cat", "Desc", datetime.now())
128
+ for i in range(1, 11)
129
+ ]
130
+
131
+ top = get_top_expenses(expenses, 3)
132
+ assert len(top) == 3
133
+
134
+ @pytest.mark.skip(reason="Implement in Lesson 4")
135
+ def test_top_with_fewer_expenses(self):
136
+ """Test when requesting more than available."""
137
+ expenses = [
138
+ Expense("1", 50.0, "Food", "Only one", datetime.now()),
139
+ ]
140
+
141
+ top = get_top_expenses(expenses, 5)
142
+ assert len(top) == 1
143
+
144
+ @pytest.mark.skip(reason="Implement in Lesson 4")
145
+ def test_default_n_is_5(self):
146
+ """Test that default n value is 5."""
147
+ expenses = [
148
+ Expense(str(i), float(i), "Cat", "Desc", datetime.now())
149
+ for i in range(10)
150
+ ]
151
+
152
+ top = get_top_expenses(expenses)
153
+ assert len(top) == 5
154
+
155
+
156
+ class TestSaveReport:
157
+ """Tests for the save_report function."""
158
+
159
+ @pytest.mark.skip(reason="Implement in Lesson 4")
160
+ def test_save_creates_file(self, tmp_path, monkeypatch):
161
+ """Test that save_report creates a file."""
162
+ import os
163
+ monkeypatch.chdir(tmp_path)
164
+
165
+ report_content = "# Test Report\n\nThis is a test."
166
+ path = save_report(report_content, "test-report.md")
167
+
168
+ assert path.exists()
169
+ assert path.read_text() == report_content
170
+
171
+ @pytest.mark.skip(reason="Implement in Lesson 4")
172
+ def test_save_creates_directory(self, tmp_path, monkeypatch):
173
+ """Test that save_report creates reports directory if needed."""
174
+ import os
175
+ monkeypatch.chdir(tmp_path)
176
+
177
+ save_report("# Test", "2026-01.md")
178
+
179
+ assert (tmp_path / "reports").is_dir()
180
+
181
+ @pytest.mark.skip(reason="Implement in Lesson 4")
182
+ def test_save_returns_path(self, tmp_path, monkeypatch):
183
+ """Test that save_report returns the file path."""
184
+ import os
185
+ monkeypatch.chdir(tmp_path)
186
+
187
+ path = save_report("# Test", "my-report.md")
188
+
189
+ assert path.name == "my-report.md"
190
+ assert "reports" in str(path)