@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,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)
@@ -0,0 +1,163 @@
1
+ """Utility functions for the expense tracker."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from typing import Optional
5
+ import locale
6
+
7
+
8
+ def format_currency(amount: float) -> str:
9
+ """Format amount as currency.
10
+
11
+ Args:
12
+ amount: The numeric amount
13
+
14
+ Returns:
15
+ Formatted string like '$1,234.56'
16
+
17
+ Example:
18
+ >>> format_currency(1234.5)
19
+ '$1,234.50'
20
+ """
21
+ try:
22
+ locale.setlocale(locale.LC_ALL, '')
23
+ return locale.currency(amount, grouping=True)
24
+ except (locale.Error, ValueError):
25
+ # Fallback if locale not available
26
+ return f"${amount:,.2f}"
27
+
28
+
29
+ def parse_date(date_str: Optional[str]) -> Optional[datetime]:
30
+ """Parse a date string in various formats.
31
+
32
+ Supports:
33
+ - YYYY-MM-DD (2026-01-15)
34
+ - MM/DD/YYYY (01/15/2026)
35
+ - 'today'
36
+ - 'yesterday'
37
+
38
+ Args:
39
+ date_str: The date string to parse
40
+
41
+ Returns:
42
+ datetime object or None if input is None
43
+
44
+ Raises:
45
+ ValueError: If date format is not recognized
46
+
47
+ Example:
48
+ >>> parse_date('2026-01-15')
49
+ datetime(2026, 1, 15, 0, 0, 0)
50
+ >>> parse_date('today')
51
+ datetime(...) # Today at midnight
52
+ """
53
+ if not date_str:
54
+ return None
55
+
56
+ date_str = date_str.lower().strip()
57
+
58
+ # Handle relative dates
59
+ if date_str == 'today':
60
+ return datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
61
+
62
+ if date_str == 'yesterday':
63
+ return (datetime.now() - timedelta(days=1)).replace(
64
+ hour=0, minute=0, second=0, microsecond=0
65
+ )
66
+
67
+ # Try YYYY-MM-DD (ISO format)
68
+ try:
69
+ return datetime.strptime(date_str, '%Y-%m-%d')
70
+ except ValueError:
71
+ pass
72
+
73
+ # Try MM/DD/YYYY (US format)
74
+ try:
75
+ return datetime.strptime(date_str, '%m/%d/%Y')
76
+ except ValueError:
77
+ pass
78
+
79
+ # Try DD/MM/YYYY (European format)
80
+ try:
81
+ return datetime.strptime(date_str, '%d/%m/%Y')
82
+ except ValueError:
83
+ pass
84
+
85
+ raise ValueError(f"Could not parse date: {date_str}. Use YYYY-MM-DD format.")
86
+
87
+
88
+ def validate_category(category: str) -> str:
89
+ """Normalize category name.
90
+
91
+ Capitalizes first letter, strips whitespace.
92
+
93
+ Args:
94
+ category: Raw category input
95
+
96
+ Returns:
97
+ Normalized category name
98
+
99
+ Example:
100
+ >>> validate_category(' food ')
101
+ 'Food'
102
+ """
103
+ return category.strip().capitalize()
104
+
105
+
106
+ def format_date(dt: datetime) -> str:
107
+ """Format a datetime for display.
108
+
109
+ Args:
110
+ dt: The datetime to format
111
+
112
+ Returns:
113
+ Formatted string like 'Jan 15, 2026'
114
+ """
115
+ return dt.strftime('%b %d, %Y')
116
+
117
+
118
+ def format_month(month_str: str) -> str:
119
+ """Format a YYYY-MM string for display.
120
+
121
+ Args:
122
+ month_str: Month in YYYY-MM format
123
+
124
+ Returns:
125
+ Formatted string like 'January 2026'
126
+
127
+ Example:
128
+ >>> format_month('2026-01')
129
+ 'January 2026'
130
+ """
131
+ dt = datetime.strptime(month_str, '%Y-%m')
132
+ return dt.strftime('%B %Y')
133
+
134
+
135
+ # Valid categories for validation/suggestions
136
+ VALID_CATEGORIES = [
137
+ "Food",
138
+ "Transport",
139
+ "Entertainment",
140
+ "Shopping",
141
+ "Bills",
142
+ "Health",
143
+ "Education",
144
+ "Travel",
145
+ "Other"
146
+ ]
147
+
148
+
149
+ def suggest_category(partial: str) -> list[str]:
150
+ """Suggest categories matching a partial input.
151
+
152
+ Args:
153
+ partial: Partial category name
154
+
155
+ Returns:
156
+ List of matching categories
157
+
158
+ Example:
159
+ >>> suggest_category('fo')
160
+ ['Food']
161
+ """
162
+ partial_lower = partial.lower()
163
+ return [cat for cat in VALID_CATEGORIES if cat.lower().startswith(partial_lower)]
@@ -0,0 +1,49 @@
1
+ id: "sub-agents"
2
+ number: 6
3
+ module: "Building Blocks"
4
+ title: "Sub-Agents for Context Isolation"
5
+
6
+ video:
7
+ url: "https://youtu.be/jB_p_Pphb_E"
8
+ duration_seconds: 300
9
+
10
+ exercise:
11
+ intro: |
12
+ Use a sub-agent to explore the codebase without polluting your main context.
13
+ objective: "Use sub-agents to keep your main context clean"
14
+
15
+ intro: |
16
+ Sub-agents = fresh context window. Parent gets a clean summary.
17
+
18
+ Remember Lesson 1? Context is finite. Sub-agents solve that.
19
+
20
+ You want to understand how the reporting module works, but don't
21
+ want all those details filling your main context.
22
+
23
+ Type: claude
24
+
25
+ First, run /context to see your current context usage.
26
+
27
+ Say: "Use a sub-agent to analyze reports.py and summarize how it
28
+ generates reports. I just want a summary, not all the details."
29
+
30
+ After getting the summary, run /context again.
31
+ Your main context should barely have moved.
32
+
33
+ verification:
34
+ - type: message_exists
35
+ - type: tool_called
36
+ tool_name: Task
37
+
38
+ success: |
39
+ You used sub-agents for context isolation!
40
+
41
+ The sub-agent explored reports.py in its own context.
42
+ You got a clean summary without the noise.
43
+
44
+ Sub-agents are for research and exploration.
45
+ Keep your main session clean for actual work.
46
+
47
+ limits:
48
+ max_duration_minutes: 15
49
+ max_claude_messages: 20