@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,116 @@
1
+ """Expense data models and core operations."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Optional
6
+ import uuid
7
+ from database import save_expense, load_expenses, remove_expense, get_expense_by_id
8
+
9
+
10
+ @dataclass
11
+ class Expense:
12
+ """Represents a single expense entry."""
13
+ id: str
14
+ amount: float
15
+ category: str
16
+ description: str
17
+ date: datetime
18
+ recurring: bool = False
19
+ recurring_frequency: Optional[str] = None # 'daily', 'weekly', 'monthly'
20
+
21
+
22
+ def generate_id() -> str:
23
+ """Generate a unique expense ID."""
24
+ return str(uuid.uuid4())[:8]
25
+
26
+
27
+ def add_expense(
28
+ amount: float,
29
+ category: str,
30
+ description: str,
31
+ date: Optional[datetime] = None
32
+ ) -> Expense:
33
+ """Add a new expense with validation."""
34
+ if amount <= 0:
35
+ raise ValueError("Amount must be greater than 0")
36
+
37
+ if not category or not category.strip():
38
+ raise ValueError("Category cannot be empty")
39
+
40
+ expense = Expense(
41
+ id=generate_id(),
42
+ amount=amount,
43
+ category=category.strip(),
44
+ description=description,
45
+ date=date or datetime.now()
46
+ )
47
+ save_expense(expense)
48
+ return expense
49
+
50
+
51
+ def list_expenses(
52
+ category: Optional[str] = None,
53
+ month: Optional[str] = None,
54
+ limit: int = 100
55
+ ) -> list[Expense]:
56
+ """List expenses with optional filters."""
57
+ expenses = load_expenses()
58
+
59
+ if category:
60
+ category_lower = category.lower()
61
+ expenses = [e for e in expenses if e.category.lower() == category_lower]
62
+
63
+ if month:
64
+ expenses = [e for e in expenses if e.date.strftime('%Y-%m') == month]
65
+
66
+ expenses.sort(key=lambda x: x.date, reverse=True)
67
+ return expenses[:limit]
68
+
69
+
70
+ def get_expense(expense_id: str) -> Optional[Expense]:
71
+ """Get a single expense by ID.
72
+
73
+ Args:
74
+ expense_id: The unique expense ID
75
+
76
+ Returns:
77
+ The Expense if found, None otherwise
78
+ """
79
+ return get_expense_by_id(expense_id)
80
+
81
+
82
+ def delete_expense(expense_id: str) -> bool:
83
+ """Delete an expense by ID.
84
+
85
+ Args:
86
+ expense_id: The unique expense ID
87
+
88
+ Returns:
89
+ True if deleted, False if not found
90
+ """
91
+ return remove_expense(expense_id)
92
+
93
+
94
+ def get_total_by_category(category: str) -> float:
95
+ """Get total spent in a category.
96
+
97
+ Args:
98
+ category: The category name
99
+
100
+ Returns:
101
+ Total amount spent in that category
102
+
103
+ Note: Also case-sensitive - shares bug with list_expenses.
104
+ """
105
+ expenses = list_expenses(category=category)
106
+ return sum(e.amount for e in expenses)
107
+
108
+
109
+ def get_categories() -> list[str]:
110
+ """Get all unique categories from expenses.
111
+
112
+ Returns:
113
+ List of category names
114
+ """
115
+ expenses = load_expenses()
116
+ return list(set(e.category for e in expenses))
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "expense-tracker"
3
+ version = "0.1.0"
4
+ description = "A simple CLI expense tracker for the Claude Code course"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+
9
+ # No runtime dependencies - uses only stdlib
10
+ dependencies = []
11
+
12
+ [project.optional-dependencies]
13
+ dev = [
14
+ "pytest>=7.0",
15
+ "ruff>=0.1.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ expense = "main:main"
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
23
+ python_files = ["test_*.py"]
24
+ python_functions = ["test_*"]
25
+ addopts = "-v --tb=short"
26
+ filterwarnings = [
27
+ "ignore::DeprecationWarning",
28
+ ]
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+ target-version = "py310"
33
+
34
+ [tool.ruff.lint]
35
+ select = [
36
+ "E", # pycodestyle errors
37
+ "F", # pyflakes
38
+ "I", # isort
39
+ "N", # pep8-naming
40
+ "W", # pycodestyle warnings
41
+ "UP", # pyupgrade
42
+ ]
43
+ ignore = [
44
+ "E501", # Line too long (handled by formatter)
45
+ ]
46
+
47
+ [tool.ruff.lint.isort]
48
+ known-first-party = ["models", "database", "utils", "reports"]
49
+
50
+ [build-system]
51
+ requires = ["setuptools>=61.0"]
52
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,63 @@
1
+ """Report generation for expense tracker."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Optional, TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from models import Expense
9
+
10
+
11
+ def generate_monthly_report(month: Optional[str] = None) -> str:
12
+ """Generate a monthly expense report."""
13
+ from models import list_expenses
14
+ from utils import format_currency
15
+
16
+ if month is None:
17
+ month = datetime.now().strftime("%Y-%m")
18
+
19
+ expenses = list_expenses(month=month)
20
+
21
+ if not expenses:
22
+ return f"# Expense Report: {month}\n\nNo expenses found."
23
+
24
+ total = sum(e.amount for e in expenses)
25
+ breakdown = get_category_breakdown(expenses)
26
+ top = get_top_expenses(expenses, 5)
27
+
28
+ report = f"# Expense Report: {month}\n\n"
29
+ report += f"## Summary\n"
30
+ report += f"- **Total Spent:** {format_currency(total)}\n"
31
+ report += f"- **Number of Expenses:** {len(expenses)}\n\n"
32
+
33
+ report += "## By Category\n"
34
+ for cat, amount in sorted(breakdown.items(), key=lambda x: x[1], reverse=True):
35
+ pct = (amount / total) * 100
36
+ report += f"- {cat}: {format_currency(amount)} ({pct:.1f}%)\n"
37
+
38
+ report += "\n## Top 5 Expenses\n"
39
+ for i, exp in enumerate(top, 1):
40
+ report += f"{i}. {format_currency(exp.amount)} - {exp.description} ({exp.category})\n"
41
+
42
+ return report
43
+
44
+
45
+ def get_category_breakdown(expenses: list["Expense"]) -> dict[str, float]:
46
+ """Get spending breakdown by category."""
47
+ breakdown: dict[str, float] = {}
48
+ for exp in expenses:
49
+ breakdown[exp.category] = breakdown.get(exp.category, 0) + exp.amount
50
+ return breakdown
51
+
52
+
53
+ def get_top_expenses(expenses: list["Expense"], n: int = 5) -> list["Expense"]:
54
+ """Get the top N expenses by amount."""
55
+ return sorted(expenses, key=lambda x: x.amount, reverse=True)[:n]
56
+
57
+
58
+ def save_report(report: str, filename: str) -> Path:
59
+ """Save report to a file."""
60
+ path = Path("reports") / filename
61
+ path.parent.mkdir(exist_ok=True)
62
+ path.write_text(report)
63
+ return path
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ """Seed the database with sample expenses for learning.
3
+
4
+ Run this script to populate the database with example data:
5
+ python seed_data.py
6
+
7
+ This creates a realistic set of expenses for students to work with.
8
+ """
9
+
10
+ from datetime import datetime
11
+ from database import init_db, clear_all_expenses
12
+ from models import add_expense
13
+
14
+ # Sample expenses for the course
15
+ # Note: One entry has lowercase "food" to trigger the case-sensitivity bug
16
+ SAMPLE_EXPENSES = [
17
+ # January 2026 - Primary month for exercises
18
+ (45.50, "Food", "Grocery shopping at Whole Foods", "2026-01-15"),
19
+ (12.00, "Transport", "Uber to airport", "2026-01-14"),
20
+ (89.99, "Shopping", "Running shoes from Nike", "2026-01-10"),
21
+ (150.00, "Bills", "Electric bill", "2026-01-01"),
22
+ (8.50, "food", "Coffee at Starbucks", "2026-01-20"), # lowercase - triggers bug!
23
+ (35.00, "Entertainment", "Movie tickets for two", "2026-01-18"),
24
+ (22.00, "Food", "Lunch with team", "2026-01-19"),
25
+ (65.00, "Health", "Gym membership monthly", "2026-01-01"),
26
+ (15.75, "Food", "Thai takeout dinner", "2026-01-17"),
27
+ (42.00, "Transport", "Weekly metro pass", "2026-01-13"),
28
+ (28.99, "Entertainment", "Netflix + Spotify", "2026-01-05"),
29
+ (120.00, "Shopping", "Winter jacket on sale", "2026-01-08"),
30
+
31
+ # December 2025 - For month filtering tests
32
+ (200.00, "Shopping", "Holiday gifts for family", "2025-12-20"),
33
+ (55.00, "Food", "Holiday dinner groceries", "2025-12-25"),
34
+ (75.00, "Entertainment", "Concert tickets", "2025-12-15"),
35
+ (30.00, "Transport", "Airport parking", "2025-12-23"),
36
+
37
+ # November 2025 - Additional historical data
38
+ (95.00, "Bills", "Internet bill", "2025-11-15"),
39
+ (180.00, "Health", "Doctor visit copay", "2025-11-10"),
40
+ (45.00, "Food", "Birthday dinner", "2025-11-22"),
41
+ ]
42
+
43
+
44
+ def seed_database(verbose: bool = True) -> int:
45
+ """Initialize and seed the database with sample data.
46
+
47
+ Args:
48
+ verbose: Whether to print progress messages
49
+
50
+ Returns:
51
+ Number of expenses added
52
+ """
53
+ if verbose:
54
+ print("Initializing database...")
55
+ init_db()
56
+
57
+ if verbose:
58
+ print("Clearing existing data...")
59
+ clear_all_expenses()
60
+
61
+ if verbose:
62
+ print("Adding sample expenses...")
63
+
64
+ count = 0
65
+ for amount, category, desc, date_str in SAMPLE_EXPENSES:
66
+ date = datetime.strptime(date_str, "%Y-%m-%d")
67
+ expense = add_expense(amount, category, desc, date)
68
+ count += 1
69
+ if verbose:
70
+ print(f" Added: {expense.id} - ${amount:.2f} - {desc[:40]}")
71
+
72
+ if verbose:
73
+ print(f"\n{'='*50}")
74
+ print(f"Seeded {count} expenses successfully!")
75
+ print(f"{'='*50}")
76
+ print("\nTry these commands:")
77
+ print(" python main.py list")
78
+ print(" python main.py list --category Food")
79
+ print(" python main.py summary")
80
+ print(" pytest -v")
81
+
82
+ return count
83
+
84
+
85
+ def main():
86
+ """Entry point for seeding script."""
87
+ seed_database()
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
@@ -0,0 +1 @@
1
+ """Test package for expense tracker."""
@@ -0,0 +1,69 @@
1
+ """Pytest configuration and shared fixtures."""
2
+
3
+ import os
4
+ import sys
5
+ import pytest
6
+ from pathlib import Path
7
+
8
+ # Add parent directory to path so we can import our modules
9
+ sys.path.insert(0, str(Path(__file__).parent.parent))
10
+
11
+ # Use a separate test database
12
+ TEST_DB_PATH = Path(__file__).parent / "test_expenses.db"
13
+ os.environ["EXPENSE_DB_PATH"] = str(TEST_DB_PATH)
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def clean_test_db():
18
+ """Ensure clean database for each test.
19
+
20
+ This fixture runs automatically before and after each test.
21
+ It initializes the database and cleans up after.
22
+ """
23
+ from database import init_db, clear_all_expenses
24
+
25
+ # Setup: initialize fresh database
26
+ init_db()
27
+ clear_all_expenses()
28
+
29
+ yield # Run the test
30
+
31
+ # Teardown: clean up
32
+ clear_all_expenses()
33
+
34
+
35
+ @pytest.fixture
36
+ def sample_expenses():
37
+ """Create a set of sample expenses for testing.
38
+
39
+ Returns:
40
+ List of created Expense objects
41
+ """
42
+ from datetime import datetime
43
+ from models import add_expense
44
+
45
+ expenses = [
46
+ add_expense(50.00, "Food", "Groceries", datetime(2026, 1, 15)),
47
+ add_expense(30.00, "Transport", "Uber ride", datetime(2026, 1, 14)),
48
+ add_expense(25.00, "Food", "Lunch", datetime(2026, 1, 16)),
49
+ add_expense(100.00, "Shopping", "Clothes", datetime(2026, 1, 10)),
50
+ add_expense(15.00, "Entertainment", "Movie", datetime(2026, 1, 12)),
51
+ ]
52
+ return expenses
53
+
54
+
55
+ @pytest.fixture
56
+ def mixed_case_expenses():
57
+ """Create expenses with mixed case categories for bug testing.
58
+
59
+ This fixture specifically tests the case-sensitivity bug.
60
+ """
61
+ from datetime import datetime
62
+ from models import add_expense
63
+
64
+ expenses = [
65
+ add_expense(50.00, "Food", "Groceries", datetime(2026, 1, 15)),
66
+ add_expense(25.00, "food", "Coffee", datetime(2026, 1, 16)), # lowercase
67
+ add_expense(30.00, "FOOD", "Dinner", datetime(2026, 1, 17)), # uppercase
68
+ ]
69
+ return expenses
@@ -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