feat: Habit Tracker for Echo Dashboard #2
126
dashboard/api.py
126
dashboard/api.py
@@ -11,16 +11,22 @@ import sys
|
|||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
import uuid
|
||||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Import habits helpers
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
import habits_helpers
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
TOOLS_DIR = BASE_DIR / 'tools'
|
TOOLS_DIR = BASE_DIR / 'tools'
|
||||||
NOTES_DIR = BASE_DIR / 'kb' / 'youtube'
|
NOTES_DIR = BASE_DIR / 'kb' / 'youtube'
|
||||||
KANBAN_DIR = BASE_DIR / 'dashboard'
|
KANBAN_DIR = BASE_DIR / 'dashboard'
|
||||||
WORKSPACE_DIR = Path('/home/moltbot/workspace')
|
WORKSPACE_DIR = Path('/home/moltbot/workspace')
|
||||||
|
HABITS_FILE = KANBAN_DIR / 'habits.json'
|
||||||
|
|
||||||
# Load .env file if present
|
# Load .env file if present
|
||||||
_env_file = Path(__file__).parent / '.env'
|
_env_file = Path(__file__).parent / '.env'
|
||||||
@@ -48,6 +54,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
self.handle_git_commit()
|
self.handle_git_commit()
|
||||||
elif self.path == '/api/pdf':
|
elif self.path == '/api/pdf':
|
||||||
self.handle_pdf_post()
|
self.handle_pdf_post()
|
||||||
|
elif self.path == '/api/habits':
|
||||||
|
self.handle_habits_post()
|
||||||
elif self.path == '/api/workspace/run':
|
elif self.path == '/api/workspace/run':
|
||||||
self.handle_workspace_run()
|
self.handle_workspace_run()
|
||||||
elif self.path == '/api/workspace/stop':
|
elif self.path == '/api/workspace/stop':
|
||||||
@@ -251,6 +259,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
self.handle_cron_status()
|
self.handle_cron_status()
|
||||||
elif self.path == '/api/activity' or self.path.startswith('/api/activity?'):
|
elif self.path == '/api/activity' or self.path.startswith('/api/activity?'):
|
||||||
self.handle_activity()
|
self.handle_activity()
|
||||||
|
elif self.path == '/api/habits':
|
||||||
|
self.handle_habits_get()
|
||||||
elif self.path.startswith('/api/files'):
|
elif self.path.startswith('/api/files'):
|
||||||
self.handle_files_get()
|
self.handle_files_get()
|
||||||
elif self.path.startswith('/api/diff'):
|
elif self.path.startswith('/api/diff'):
|
||||||
@@ -1381,6 +1391,122 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({'error': str(e)}, 500)
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def handle_habits_get(self):
|
||||||
|
"""Get all habits with enriched stats."""
|
||||||
|
try:
|
||||||
|
# Read habits file
|
||||||
|
if not HABITS_FILE.exists():
|
||||||
|
self.send_json([])
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
habits = data.get('habits', [])
|
||||||
|
|
||||||
|
# Enrich each habit with calculated stats
|
||||||
|
enriched_habits = []
|
||||||
|
for habit in habits:
|
||||||
|
# Calculate stats using helpers
|
||||||
|
current_streak = habits_helpers.calculate_streak(habit)
|
||||||
|
best_streak = habit.get('streak', {}).get('best', 0)
|
||||||
|
completion_rate = habits_helpers.get_completion_rate(habit, days=30)
|
||||||
|
weekly_summary = habits_helpers.get_weekly_summary(habit)
|
||||||
|
|
||||||
|
# Add stats to habit
|
||||||
|
enriched = habit.copy()
|
||||||
|
enriched['current_streak'] = current_streak
|
||||||
|
enriched['best_streak'] = best_streak
|
||||||
|
enriched['completion_rate_30d'] = completion_rate
|
||||||
|
enriched['weekly_summary'] = weekly_summary
|
||||||
|
|
||||||
|
enriched_habits.append(enriched)
|
||||||
|
|
||||||
|
# Sort by priority ascending (lower number = higher priority)
|
||||||
|
enriched_habits.sort(key=lambda h: h.get('priority', 999))
|
||||||
|
|
||||||
|
self.send_json(enriched_habits)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def handle_habits_post(self):
|
||||||
|
"""Create a new habit."""
|
||||||
|
try:
|
||||||
|
# Read request body
|
||||||
|
content_length = int(self.headers['Content-Length'])
|
||||||
|
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||||
|
data = json.loads(post_data)
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
self.send_json({'error': 'name is required'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(name) > 100:
|
||||||
|
self.send_json({'error': 'name must be max 100 characters'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate color (hex format)
|
||||||
|
color = data.get('color', '#3b82f6')
|
||||||
|
if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color):
|
||||||
|
self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate frequency type
|
||||||
|
frequency_type = data.get('frequency', {}).get('type', 'daily')
|
||||||
|
valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom']
|
||||||
|
if frequency_type not in valid_types:
|
||||||
|
self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create new habit
|
||||||
|
habit_id = str(uuid.uuid4())
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
new_habit = {
|
||||||
|
'id': habit_id,
|
||||||
|
'name': name,
|
||||||
|
'category': data.get('category', 'other'),
|
||||||
|
'color': color,
|
||||||
|
'icon': data.get('icon', 'check-circle'),
|
||||||
|
'priority': data.get('priority', 5),
|
||||||
|
'notes': data.get('notes', ''),
|
||||||
|
'reminderTime': data.get('reminderTime', ''),
|
||||||
|
'frequency': data.get('frequency', {'type': 'daily'}),
|
||||||
|
'streak': {
|
||||||
|
'current': 0,
|
||||||
|
'best': 0,
|
||||||
|
'lastCheckIn': None
|
||||||
|
},
|
||||||
|
'lives': 3,
|
||||||
|
'completions': [],
|
||||||
|
'createdAt': now,
|
||||||
|
'updatedAt': now
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read existing habits
|
||||||
|
if HABITS_FILE.exists():
|
||||||
|
with open(HABITS_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
habits_data = json.load(f)
|
||||||
|
else:
|
||||||
|
habits_data = {'lastUpdated': '', 'habits': []}
|
||||||
|
|
||||||
|
# Add new habit
|
||||||
|
habits_data['habits'].append(new_habit)
|
||||||
|
habits_data['lastUpdated'] = now
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
with open(HABITS_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(habits_data, f, indent=2)
|
||||||
|
|
||||||
|
# Return created habit with 201 status
|
||||||
|
self.send_json(new_habit, 201)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_json({'error': 'Invalid JSON'}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
def send_json(self, data, code=200):
|
def send_json(self, data, code=200):
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header('Content-Type', 'application/json')
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
|||||||
299
dashboard/tests/test_habits_api.py
Normal file
299
dashboard/tests/test_habits_api.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for habits API endpoints (GET and POST)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from http.server import HTTPServer
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
# Add parent directory to path so we can import api module
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
# Mock the habits file to a temp location for testing
|
||||||
|
import api
|
||||||
|
original_habits_file = api.HABITS_FILE
|
||||||
|
|
||||||
|
def setup_test_env():
|
||||||
|
"""Set up temporary test environment."""
|
||||||
|
temp_dir = Path(tempfile.mkdtemp())
|
||||||
|
api.HABITS_FILE = temp_dir / 'habits.json'
|
||||||
|
|
||||||
|
# Create empty habits file
|
||||||
|
api.HABITS_FILE.write_text(json.dumps({
|
||||||
|
'lastUpdated': '',
|
||||||
|
'habits': []
|
||||||
|
}))
|
||||||
|
|
||||||
|
return temp_dir
|
||||||
|
|
||||||
|
def cleanup_test_env(temp_dir):
|
||||||
|
"""Clean up temporary test environment."""
|
||||||
|
api.HABITS_FILE = original_habits_file
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
def start_test_server(port=8765):
|
||||||
|
"""Start test server in background thread."""
|
||||||
|
server = HTTPServer(('localhost', port), api.TaskBoardHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
time.sleep(0.5) # Give server time to start
|
||||||
|
return server
|
||||||
|
|
||||||
|
def http_get(path, port=8765):
|
||||||
|
"""Make HTTP GET request."""
|
||||||
|
url = f'http://localhost:{port}{path}'
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url) as response:
|
||||||
|
return response.status, json.loads(response.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {}
|
||||||
|
|
||||||
|
def http_post(path, data, port=8765):
|
||||||
|
"""Make HTTP POST request."""
|
||||||
|
url = f'http://localhost:{port}{path}'
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=json.dumps(data).encode(),
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
return response.status, json.loads(response.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {}
|
||||||
|
|
||||||
|
# Test 1: GET /api/habits returns empty array when no habits
|
||||||
|
def test_get_habits_empty():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, data = http_get('/api/habits')
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
assert data == [], f"Expected empty array, got {data}"
|
||||||
|
print("✓ Test 1: GET /api/habits returns empty array")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 2: POST /api/habits creates new habit with valid input
|
||||||
|
def test_post_habit_valid():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
habit_data = {
|
||||||
|
'name': 'Morning Exercise',
|
||||||
|
'category': 'health',
|
||||||
|
'color': '#10b981',
|
||||||
|
'icon': 'dumbbell',
|
||||||
|
'priority': 1,
|
||||||
|
'notes': 'Start with 10 push-ups',
|
||||||
|
'reminderTime': '07:00',
|
||||||
|
'frequency': {
|
||||||
|
'type': 'daily'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status, data = http_post('/api/habits', habit_data)
|
||||||
|
assert status == 201, f"Expected 201, got {status}"
|
||||||
|
assert 'id' in data, "Response should include habit id"
|
||||||
|
assert data['name'] == 'Morning Exercise', f"Name mismatch: {data['name']}"
|
||||||
|
assert data['category'] == 'health', f"Category mismatch: {data['category']}"
|
||||||
|
assert data['streak']['current'] == 0, "Initial streak should be 0"
|
||||||
|
assert data['lives'] == 3, "Initial lives should be 3"
|
||||||
|
assert data['completions'] == [], "Initial completions should be empty"
|
||||||
|
print("✓ Test 2: POST /api/habits creates habit with 201")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 3: POST validates name is required
|
||||||
|
def test_post_habit_missing_name():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, data = http_post('/api/habits', {})
|
||||||
|
assert status == 400, f"Expected 400, got {status}"
|
||||||
|
assert 'error' in data, "Response should include error"
|
||||||
|
assert 'name' in data['error'].lower(), f"Error should mention name: {data['error']}"
|
||||||
|
print("✓ Test 3: POST validates name is required")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 4: POST validates name max 100 chars
|
||||||
|
def test_post_habit_name_too_long():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, data = http_post('/api/habits', {'name': 'x' * 101})
|
||||||
|
assert status == 400, f"Expected 400, got {status}"
|
||||||
|
assert 'error' in data, "Response should include error"
|
||||||
|
assert '100' in data['error'], f"Error should mention max length: {data['error']}"
|
||||||
|
print("✓ Test 4: POST validates name max 100 chars")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 5: POST validates color hex format
|
||||||
|
def test_post_habit_invalid_color():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, data = http_post('/api/habits', {
|
||||||
|
'name': 'Test',
|
||||||
|
'color': 'not-a-hex-color'
|
||||||
|
})
|
||||||
|
assert status == 400, f"Expected 400, got {status}"
|
||||||
|
assert 'error' in data, "Response should include error"
|
||||||
|
assert 'color' in data['error'].lower(), f"Error should mention color: {data['error']}"
|
||||||
|
print("✓ Test 5: POST validates color hex format")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 6: POST validates frequency type
|
||||||
|
def test_post_habit_invalid_frequency():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, data = http_post('/api/habits', {
|
||||||
|
'name': 'Test',
|
||||||
|
'frequency': {'type': 'invalid_type'}
|
||||||
|
})
|
||||||
|
assert status == 400, f"Expected 400, got {status}"
|
||||||
|
assert 'error' in data, "Response should include error"
|
||||||
|
assert 'frequency' in data['error'].lower(), f"Error should mention frequency: {data['error']}"
|
||||||
|
print("✓ Test 6: POST validates frequency type")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 7: GET /api/habits returns habits with stats enriched
|
||||||
|
def test_get_habits_with_stats():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a habit first
|
||||||
|
habit_data = {'name': 'Daily Reading', 'frequency': {'type': 'daily'}}
|
||||||
|
http_post('/api/habits', habit_data)
|
||||||
|
|
||||||
|
# Get habits
|
||||||
|
status, data = http_get('/api/habits')
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
assert len(data) == 1, f"Expected 1 habit, got {len(data)}"
|
||||||
|
|
||||||
|
habit = data[0]
|
||||||
|
assert 'current_streak' in habit, "Should include current_streak"
|
||||||
|
assert 'best_streak' in habit, "Should include best_streak"
|
||||||
|
assert 'completion_rate_30d' in habit, "Should include completion_rate_30d"
|
||||||
|
assert 'weekly_summary' in habit, "Should include weekly_summary"
|
||||||
|
print("✓ Test 7: GET returns habits with stats enriched")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 8: GET /api/habits sorts by priority ascending
|
||||||
|
def test_get_habits_sorted_by_priority():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create habits with different priorities
|
||||||
|
http_post('/api/habits', {'name': 'Low Priority', 'priority': 10})
|
||||||
|
http_post('/api/habits', {'name': 'High Priority', 'priority': 1})
|
||||||
|
http_post('/api/habits', {'name': 'Medium Priority', 'priority': 5})
|
||||||
|
|
||||||
|
# Get habits
|
||||||
|
status, data = http_get('/api/habits')
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
assert len(data) == 3, f"Expected 3 habits, got {len(data)}"
|
||||||
|
|
||||||
|
# Check sorting
|
||||||
|
assert data[0]['priority'] == 1, "First should be priority 1"
|
||||||
|
assert data[1]['priority'] == 5, "Second should be priority 5"
|
||||||
|
assert data[2]['priority'] == 10, "Third should be priority 10"
|
||||||
|
print("✓ Test 8: GET sorts habits by priority ascending")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 9: POST returns 400 for invalid JSON
|
||||||
|
def test_post_habit_invalid_json():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f'http://localhost:8765/api/habits'
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=b'invalid json{',
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
assert False, "Should have raised HTTPError"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 400, f"Expected 400, got {e.code}"
|
||||||
|
print("✓ Test 9: POST returns 400 for invalid JSON")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 10: POST initializes streak.current=0
|
||||||
|
def test_post_habit_initial_streak():
|
||||||
|
temp_dir = setup_test_env()
|
||||||
|
server = start_test_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, data = http_post('/api/habits', {'name': 'Test Habit'})
|
||||||
|
assert status == 201, f"Expected 201, got {status}"
|
||||||
|
assert data['streak']['current'] == 0, "Initial streak.current should be 0"
|
||||||
|
assert data['streak']['best'] == 0, "Initial streak.best should be 0"
|
||||||
|
assert data['streak']['lastCheckIn'] is None, "Initial lastCheckIn should be None"
|
||||||
|
print("✓ Test 10: POST initializes streak correctly")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
cleanup_test_env(temp_dir)
|
||||||
|
|
||||||
|
# Test 11: Typecheck passes
|
||||||
|
def test_typecheck():
|
||||||
|
result = subprocess.run(
|
||||||
|
['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')],
|
||||||
|
capture_output=True
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Typecheck failed: {result.stderr.decode()}"
|
||||||
|
print("✓ Test 11: Typecheck passes")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
print("\n=== Running Habits API Tests ===\n")
|
||||||
|
|
||||||
|
test_get_habits_empty()
|
||||||
|
test_post_habit_valid()
|
||||||
|
test_post_habit_missing_name()
|
||||||
|
test_post_habit_name_too_long()
|
||||||
|
test_post_habit_invalid_color()
|
||||||
|
test_post_habit_invalid_frequency()
|
||||||
|
test_get_habits_with_stats()
|
||||||
|
test_get_habits_sorted_by_priority()
|
||||||
|
test_post_habit_invalid_json()
|
||||||
|
test_post_habit_initial_streak()
|
||||||
|
test_typecheck()
|
||||||
|
|
||||||
|
print("\n✅ All 11 tests passed!\n")
|
||||||
Reference in New Issue
Block a user