@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.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/routes/auth.d.ts +1 -0
- package/dist/routes/auth.js +123 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/levels.d.ts +44 -0
- package/dist/routes/levels.js +78 -0
- package/dist/routes/levels.js.map +1 -0
- package/dist/routes/sessions.d.ts +17 -0
- package/dist/routes/sessions.js +303 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +58 -0
- package/dist/server.js.map +1 -0
- package/dist/terminal.d.ts +6 -0
- package/dist/terminal.js +23 -0
- package/dist/terminal.js.map +1 -0
- package/dist/verification.d.ts +31 -0
- package/dist/verification.js +239 -0
- package/dist/verification.js.map +1 -0
- package/frontend/assets/index-CNVEnbfs.css +1 -0
- package/frontend/assets/index-D70xl9zu.js +27 -0
- package/frontend/index.html +14 -0
- package/frontend/vite.svg +1 -0
- package/keys/v1.pem +9 -0
- package/levels/01-context-is-everything/exercise/README.md +152 -0
- package/levels/01-context-is-everything/exercise/data/expenses.db +0 -0
- package/levels/01-context-is-everything/exercise/database.py +171 -0
- package/levels/01-context-is-everything/exercise/docs/FIRECRAWL_QUICKSTART.md +212 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_01.json +2306 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_02.json +2394 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_03.json +2251 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_04.json +1987 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_05.json +2229 -0
- package/levels/01-context-is-everything/exercise/main.py +97 -0
- package/levels/01-context-is-everything/exercise/models.py +141 -0
- package/levels/01-context-is-everything/exercise/pyproject.toml +52 -0
- package/levels/01-context-is-everything/exercise/reports.py +138 -0
- package/levels/01-context-is-everything/exercise/seed_data.py +91 -0
- package/levels/01-context-is-everything/exercise/tests/__init__.py +1 -0
- package/levels/01-context-is-everything/exercise/tests/conftest.py +69 -0
- package/levels/01-context-is-everything/exercise/tests/test_database.py +244 -0
- package/levels/01-context-is-everything/exercise/tests/test_models.py +240 -0
- package/levels/01-context-is-everything/exercise/tests/test_reports.py +190 -0
- package/levels/01-context-is-everything/exercise/utils.py +163 -0
- package/levels/01-context-is-everything/lesson.yaml +82 -0
- package/levels/02-claude-md/exercise/README.md +152 -0
- package/levels/02-claude-md/exercise/data/expenses.db +0 -0
- package/levels/02-claude-md/exercise/database.py +171 -0
- package/levels/02-claude-md/exercise/main.py +97 -0
- package/levels/02-claude-md/exercise/models.py +141 -0
- package/levels/02-claude-md/exercise/pyproject.toml +52 -0
- package/levels/02-claude-md/exercise/reports.py +138 -0
- package/levels/02-claude-md/exercise/seed_data.py +91 -0
- package/levels/02-claude-md/exercise/tests/__init__.py +1 -0
- package/levels/02-claude-md/exercise/tests/conftest.py +69 -0
- package/levels/02-claude-md/exercise/tests/test_database.py +244 -0
- package/levels/02-claude-md/exercise/tests/test_models.py +240 -0
- package/levels/02-claude-md/exercise/tests/test_reports.py +190 -0
- package/levels/02-claude-md/exercise/utils.py +163 -0
- package/levels/02-claude-md/lesson.yaml +60 -0
- package/levels/03-read-edit-verify/exercise/CLAUDE.md +15 -0
- package/levels/03-read-edit-verify/exercise/README.md +152 -0
- package/levels/03-read-edit-verify/exercise/data/expenses.db +0 -0
- package/levels/03-read-edit-verify/exercise/database.py +171 -0
- package/levels/03-read-edit-verify/exercise/main.py +97 -0
- package/levels/03-read-edit-verify/exercise/models.py +141 -0
- package/levels/03-read-edit-verify/exercise/pyproject.toml +52 -0
- package/levels/03-read-edit-verify/exercise/reports.py +138 -0
- package/levels/03-read-edit-verify/exercise/seed_data.py +91 -0
- package/levels/03-read-edit-verify/exercise/tests/__init__.py +1 -0
- package/levels/03-read-edit-verify/exercise/tests/conftest.py +69 -0
- package/levels/03-read-edit-verify/exercise/tests/test_database.py +244 -0
- package/levels/03-read-edit-verify/exercise/tests/test_models.py +240 -0
- package/levels/03-read-edit-verify/exercise/tests/test_reports.py +190 -0
- package/levels/03-read-edit-verify/exercise/utils.py +163 -0
- package/levels/03-read-edit-verify/lesson.yaml +60 -0
- package/levels/04-planning-mode/exercise/README.md +152 -0
- package/levels/04-planning-mode/exercise/data/expenses.db +0 -0
- package/levels/04-planning-mode/exercise/database.py +171 -0
- package/levels/04-planning-mode/exercise/main.py +97 -0
- package/levels/04-planning-mode/exercise/models.py +116 -0
- package/levels/04-planning-mode/exercise/pyproject.toml +52 -0
- package/levels/04-planning-mode/exercise/reports.py +138 -0
- package/levels/04-planning-mode/exercise/seed_data.py +91 -0
- package/levels/04-planning-mode/exercise/tests/__init__.py +1 -0
- package/levels/04-planning-mode/exercise/tests/conftest.py +69 -0
- package/levels/04-planning-mode/exercise/tests/test_database.py +244 -0
- package/levels/04-planning-mode/exercise/tests/test_expenses.db +0 -0
- package/levels/04-planning-mode/exercise/tests/test_models.py +240 -0
- package/levels/04-planning-mode/exercise/tests/test_reports.py +190 -0
- package/levels/04-planning-mode/exercise/utils.py +163 -0
- package/levels/04-planning-mode/lesson.yaml +53 -0
- package/levels/05-spec-driven/exercise/README.md +152 -0
- package/levels/05-spec-driven/exercise/data/expenses.db +0 -0
- package/levels/05-spec-driven/exercise/database.py +171 -0
- package/levels/05-spec-driven/exercise/main.py +97 -0
- package/levels/05-spec-driven/exercise/models.py +116 -0
- package/levels/05-spec-driven/exercise/pyproject.toml +52 -0
- package/levels/05-spec-driven/exercise/reports.py +138 -0
- package/levels/05-spec-driven/exercise/seed_data.py +91 -0
- package/levels/05-spec-driven/exercise/tests/__init__.py +1 -0
- package/levels/05-spec-driven/exercise/tests/conftest.py +69 -0
- package/levels/05-spec-driven/exercise/tests/test_database.py +244 -0
- package/levels/05-spec-driven/exercise/tests/test_expenses.db +0 -0
- package/levels/05-spec-driven/exercise/tests/test_models.py +240 -0
- package/levels/05-spec-driven/exercise/tests/test_reports.py +190 -0
- package/levels/05-spec-driven/exercise/utils.py +163 -0
- package/levels/05-spec-driven/lesson.yaml +53 -0
- package/levels/06-sub-agents/exercise/README.md +152 -0
- package/levels/06-sub-agents/exercise/data/expenses.db +0 -0
- package/levels/06-sub-agents/exercise/database.py +171 -0
- package/levels/06-sub-agents/exercise/main.py +97 -0
- package/levels/06-sub-agents/exercise/models.py +116 -0
- package/levels/06-sub-agents/exercise/pyproject.toml +52 -0
- package/levels/06-sub-agents/exercise/reports.py +63 -0
- package/levels/06-sub-agents/exercise/seed_data.py +91 -0
- package/levels/06-sub-agents/exercise/tests/__init__.py +1 -0
- package/levels/06-sub-agents/exercise/tests/conftest.py +69 -0
- package/levels/06-sub-agents/exercise/tests/test_database.py +244 -0
- package/levels/06-sub-agents/exercise/tests/test_models.py +240 -0
- package/levels/06-sub-agents/exercise/tests/test_reports.py +190 -0
- package/levels/06-sub-agents/exercise/utils.py +163 -0
- package/levels/06-sub-agents/lesson.yaml +49 -0
- package/levels/07-skills/exercise/README.md +152 -0
- package/levels/07-skills/exercise/data/expenses.db +0 -0
- package/levels/07-skills/exercise/database.py +171 -0
- package/levels/07-skills/exercise/main.py +97 -0
- package/levels/07-skills/exercise/models.py +116 -0
- package/levels/07-skills/exercise/pyproject.toml +52 -0
- package/levels/07-skills/exercise/reports.py +63 -0
- package/levels/07-skills/exercise/seed_data.py +91 -0
- package/levels/07-skills/exercise/tests/__init__.py +1 -0
- package/levels/07-skills/exercise/tests/conftest.py +69 -0
- package/levels/07-skills/exercise/tests/test_database.py +244 -0
- package/levels/07-skills/exercise/tests/test_models.py +240 -0
- package/levels/07-skills/exercise/tests/test_reports.py +190 -0
- package/levels/07-skills/exercise/utils.py +163 -0
- package/levels/07-skills/lesson.yaml +49 -0
- package/levels/08-mcp-servers/exercise/README.md +152 -0
- package/levels/08-mcp-servers/exercise/data/expenses.db +0 -0
- package/levels/08-mcp-servers/exercise/database.py +171 -0
- package/levels/08-mcp-servers/exercise/main.py +97 -0
- package/levels/08-mcp-servers/exercise/models.py +116 -0
- package/levels/08-mcp-servers/exercise/pyproject.toml +52 -0
- package/levels/08-mcp-servers/exercise/reports.py +63 -0
- package/levels/08-mcp-servers/exercise/seed_data.py +91 -0
- package/levels/08-mcp-servers/exercise/tests/__init__.py +1 -0
- package/levels/08-mcp-servers/exercise/tests/conftest.py +69 -0
- package/levels/08-mcp-servers/exercise/tests/test_database.py +244 -0
- package/levels/08-mcp-servers/exercise/tests/test_models.py +240 -0
- package/levels/08-mcp-servers/exercise/tests/test_reports.py +190 -0
- package/levels/08-mcp-servers/exercise/utils.py +163 -0
- package/levels/08-mcp-servers/lesson.yaml +59 -0
- package/levels/09-plugins/exercise/README.md +152 -0
- package/levels/09-plugins/exercise/data/expenses.db +0 -0
- package/levels/09-plugins/exercise/database.py +171 -0
- package/levels/09-plugins/exercise/main.py +97 -0
- package/levels/09-plugins/exercise/models.py +116 -0
- package/levels/09-plugins/exercise/pyproject.toml +52 -0
- package/levels/09-plugins/exercise/reports.py +63 -0
- package/levels/09-plugins/exercise/seed_data.py +91 -0
- package/levels/09-plugins/exercise/tests/__init__.py +1 -0
- package/levels/09-plugins/exercise/tests/conftest.py +69 -0
- package/levels/09-plugins/exercise/tests/test_database.py +244 -0
- package/levels/09-plugins/exercise/tests/test_models.py +240 -0
- package/levels/09-plugins/exercise/tests/test_reports.py +190 -0
- package/levels/09-plugins/exercise/utils.py +163 -0
- package/levels/09-plugins/lesson.yaml +51 -0
- package/levels/10-hooks/exercise/README.md +152 -0
- package/levels/10-hooks/exercise/data/expenses.db +0 -0
- package/levels/10-hooks/exercise/database.py +171 -0
- package/levels/10-hooks/exercise/main.py +97 -0
- package/levels/10-hooks/exercise/models.py +116 -0
- package/levels/10-hooks/exercise/pyproject.toml +52 -0
- package/levels/10-hooks/exercise/reports.py +63 -0
- package/levels/10-hooks/exercise/seed_data.py +91 -0
- package/levels/10-hooks/exercise/tests/__init__.py +1 -0
- package/levels/10-hooks/exercise/tests/conftest.py +69 -0
- package/levels/10-hooks/exercise/tests/test_database.py +244 -0
- package/levels/10-hooks/exercise/tests/test_models.py +240 -0
- package/levels/10-hooks/exercise/tests/test_reports.py +190 -0
- package/levels/10-hooks/exercise/utils.py +163 -0
- package/levels/10-hooks/lesson.yaml +58 -0
- package/levels/11-worktrees/exercise/README.md +152 -0
- package/levels/11-worktrees/exercise/data/expenses.db +0 -0
- package/levels/11-worktrees/exercise/database.py +171 -0
- package/levels/11-worktrees/exercise/main.py +97 -0
- package/levels/11-worktrees/exercise/models.py +116 -0
- package/levels/11-worktrees/exercise/pyproject.toml +52 -0
- package/levels/11-worktrees/exercise/reports.py +63 -0
- package/levels/11-worktrees/exercise/seed_data.py +91 -0
- package/levels/11-worktrees/exercise/tests/__init__.py +1 -0
- package/levels/11-worktrees/exercise/tests/conftest.py +69 -0
- package/levels/11-worktrees/exercise/tests/test_database.py +244 -0
- package/levels/11-worktrees/exercise/tests/test_models.py +240 -0
- package/levels/11-worktrees/exercise/tests/test_reports.py +190 -0
- package/levels/11-worktrees/exercise/utils.py +163 -0
- package/levels/11-worktrees/lesson.yaml +68 -0
- 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
|