diff --git a/dashboard/habits.html b/dashboard/habits.html index a25884b..98d9aa5 100644 --- a/dashboard/habits.html +++ b/dashboard/habits.html @@ -83,6 +83,121 @@ flex-direction: column; gap: var(--space-3); } + + /* Modal */ + .modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; + } + + .modal-overlay.active { + display: flex; + } + + .modal { + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-5); + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + } + + .modal-title { + font-size: var(--text-lg); + font-weight: 600; + margin-bottom: var(--space-4); + } + + .form-group { + margin-bottom: var(--space-4); + } + + .form-label { + display: block; + font-size: var(--text-sm); + font-weight: 500; + margin-bottom: var(--space-1); + color: var(--text-secondary); + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-5); + } + + /* Radio group */ + .radio-group { + display: flex; + gap: var(--space-3); + } + + .radio-option { + flex: 1; + } + + .radio-option input[type="radio"] { + display: none; + } + + .radio-label { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-secondary); + } + + .radio-option input[type="radio"]:checked + .radio-label { + background: var(--accent); + color: white; + border-color: var(--accent); + } + + .radio-label:hover { + border-color: var(--accent); + } + + /* Toast */ + .toast { + position: fixed; + bottom: var(--space-5); + left: 50%; + transform: translateX(-50%) translateY(100px); + background: var(--bg-elevated); + border: 1px solid var(--border); + padding: var(--space-3) var(--space-5); + border-radius: var(--radius-md); + font-size: var(--text-sm); + opacity: 0; + transition: all var(--transition-base); + z-index: 1001; + } + + .toast.show { + transform: translateX(-50%) translateY(0); + opacity: 1; + }
@@ -143,6 +258,36 @@ + + + + + diff --git a/dashboard/test_habits_modal.py b/dashboard/test_habits_modal.py new file mode 100644 index 0000000..874cbd7 --- /dev/null +++ b/dashboard/test_habits_modal.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +Tests for habit creation modal in habits.html +Validates modal structure, form elements, buttons, and styling +""" + +import os +import sys +from html.parser import HTMLParser + +HABITS_HTML_PATH = 'dashboard/habits.html' + +class ModalParser(HTMLParser): + """Parser to extract modal elements from HTML""" + def __init__(self): + super().__init__() + self.in_modal = False + self.in_modal_title = False + self.in_form_label = False + self.in_button = False + self.modal_title = None + self.form_labels = [] + self.name_input_attrs = None + self.radio_buttons = [] + self.buttons = [] + self.radio_labels = [] + self.in_radio_label = False + self.current_radio_label = None + self.modal_overlay_found = False + self.modal_div_found = False + self.toast_found = False + + def handle_starttag(self, tag, attrs): + attrs_dict = dict(attrs) + + # Check for modal overlay + if tag == 'div' and attrs_dict.get('id') == 'habitModal': + self.modal_overlay_found = True + if 'modal-overlay' in attrs_dict.get('class', ''): + self.in_modal = True + + # Check for modal div + if self.in_modal and tag == 'div' and 'modal' in attrs_dict.get('class', ''): + self.modal_div_found = True + + # Check for modal title + if self.in_modal and tag == 'h2' and 'modal-title' in attrs_dict.get('class', ''): + self.in_modal_title = True + + # Check for form labels + if self.in_modal and tag == 'label' and 'form-label' in attrs_dict.get('class', ''): + self.in_form_label = True + + # Check for name input + if self.in_modal and tag == 'input' and attrs_dict.get('id') == 'habitName': + self.name_input_attrs = attrs_dict + + # Check for radio buttons + if self.in_modal and tag == 'input' and attrs_dict.get('type') == 'radio': + self.radio_buttons.append(attrs_dict) + + # Check for radio labels + if self.in_modal and tag == 'label' and 'radio-label' in attrs_dict.get('class', ''): + self.in_radio_label = True + self.current_radio_label = attrs_dict.get('for', '') + + # Check for buttons + if self.in_modal and tag == 'button': + self.buttons.append(attrs_dict) + self.in_button = True + + # Check for toast + if tag == 'div' and attrs_dict.get('id') == 'toast': + self.toast_found = True + + def handle_endtag(self, tag): + if tag == 'h2': + self.in_modal_title = False + if tag == 'label': + self.in_form_label = False + self.in_radio_label = False + if tag == 'button': + self.in_button = False + if tag == 'div' and self.in_modal: + # Don't close modal state until we're sure we've left it + pass + + def handle_data(self, data): + if self.in_modal_title and not self.modal_title: + self.modal_title = data.strip() + if self.in_form_label: + self.form_labels.append(data.strip()) + if self.in_radio_label: + self.radio_labels.append({'for': self.current_radio_label, 'text': data.strip()}) + +def test_modal_structure(): + """Test modal HTML structure exists""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Check modal overlay exists + assert parser.modal_overlay_found, "Modal overlay with id='habitModal' not found" + + # Check modal container exists + assert parser.modal_div_found, "Modal div with class='modal' not found" + + # Check modal title + assert parser.modal_title is not None, "Modal title not found" + assert 'nou' in parser.modal_title.lower(), f"Modal title should mention 'nou', got: {parser.modal_title}" + + print("✓ Modal structure exists") + +def test_name_input_field(): + """Test habit name input field exists and is required""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Find name input + assert parser.name_input_attrs is not None, "Name input field with id='habitName' not found" + + # Check it's a text input + assert parser.name_input_attrs.get('type') == 'text', "Name input should be type='text'" + + # Check it has class 'input' + assert 'input' in parser.name_input_attrs.get('class', ''), "Name input should have class='input'" + + # Check it has placeholder + assert parser.name_input_attrs.get('placeholder'), "Name input should have placeholder" + + # Check label exists and mentions required (*) + found_required_label = any('*' in label for label in parser.form_labels) + assert found_required_label, "Should have a form label with * indicating required field" + + print("✓ Name input field exists with required indicator") + +def test_frequency_radio_buttons(): + """Test frequency radio buttons exist with daily and weekly options""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Check we have radio buttons + assert len(parser.radio_buttons) >= 2, f"Should have at least 2 radio buttons, found {len(parser.radio_buttons)}" + + # Find daily radio button + daily_radio = next((r for r in parser.radio_buttons if r.get('value') == 'daily'), None) + assert daily_radio is not None, "Daily radio button with value='daily' not found" + assert daily_radio.get('name') == 'frequency', "Daily radio should have name='frequency'" + # Check for 'checked' attribute - it may be None or empty string when present + assert 'checked' in daily_radio, "Daily radio should be checked by default" + + # Find weekly radio button + weekly_radio = next((r for r in parser.radio_buttons if r.get('value') == 'weekly'), None) + assert weekly_radio is not None, "Weekly radio button with value='weekly' not found" + assert weekly_radio.get('name') == 'frequency', "Weekly radio should have name='frequency'" + + # Check labels exist with Romanian text + daily_label = next((l for l in parser.radio_labels if 'zilnic' in l['text'].lower()), None) + assert daily_label is not None, "Daily label with 'Zilnic' text not found" + + weekly_label = next((l for l in parser.radio_labels if 'săptămânal' in l['text'].lower()), None) + assert weekly_label is not None, "Weekly label with 'Săptămânal' text not found" + + print("✓ Frequency radio buttons exist with daily (default) and weekly options") + +def test_modal_buttons(): + """Test modal has Cancel and Create buttons""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Check we have 2 buttons + assert len(parser.buttons) >= 2, f"Should have at least 2 buttons, found {len(parser.buttons)}" + + # Check Cancel button + cancel_btn = next((b for b in parser.buttons if 'btn-secondary' in b.get('class', '')), None) + assert cancel_btn is not None, "Cancel button with class='btn-secondary' not found" + assert 'hideHabitModal' in cancel_btn.get('onclick', ''), "Cancel should call hideHabitModal" + + # Check Create button + create_btn = next((b for b in parser.buttons if b.get('id') == 'habitCreateBtn'), None) + assert create_btn is not None, "Create button with id='habitCreateBtn' not found" + assert 'btn-primary' in create_btn.get('class', ''), "Create button should have class='btn-primary'" + assert 'createHabit' in create_btn.get('onclick', ''), "Create should call createHabit" + # Check for 'disabled' attribute - it may be None or empty string when present + assert 'disabled' in create_btn, "Create button should start disabled" + + # Check button text in content + assert 'anulează' in content.lower(), "Cancel button should say 'Anulează'" + assert 'creează' in content.lower(), "Create button should say 'Creează'" + + print("✓ Modal has Cancel and Create buttons with correct attributes") + +def test_add_button_triggers_modal(): + """Test that add habit button calls showAddHabitModal""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Find add habit button + assert 'class="add-habit-btn"' in content, "Add habit button not found" + assert 'showAddHabitModal()' in content, "Add button should call showAddHabitModal()" + + print("✓ Add habit button calls showAddHabitModal()") + +def test_modal_styling(): + """Test modal uses dashboard modal styling patterns""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Check key modal classes exist in CSS + required_styles = [ + '.modal-overlay', + '.modal-overlay.active', + '.modal {', + '.modal-title', + '.modal-actions', + '.form-group', + '.form-label', + '.radio-group', + ] + + for style in required_styles: + assert style in content, f"Modal style '{style}' not found" + + # Check modal uses CSS variables (dashboard pattern) + assert 'var(--bg-base)' in content, "Modal should use --bg-base" + assert 'var(--border)' in content, "Modal should use --border" + assert 'var(--accent)' in content, "Modal should use --accent" + + print("✓ Modal uses dashboard modal styling patterns") + +def test_javascript_functions(): + """Test JavaScript functions for modal interaction exist""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Check essential functions exist + assert 'function showAddHabitModal()' in content, "showAddHabitModal function not found" + assert 'function hideHabitModal()' in content, "hideHabitModal function not found" + assert 'async function createHabit()' in content or 'function createHabit()' in content, "createHabit function not found" + + # Check form validation logic + assert "createBtn.disabled" in content, "Create button disable logic not found" + assert "nameInput.value.trim()" in content, "Name trim validation not found" + + # Check modal show/hide logic + assert "modal.classList.add('active')" in content, "Modal show logic not found" + assert "modal.classList.remove('active')" in content, "Modal hide logic not found" + + # Check API integration + assert "fetch('/api/habits'" in content, "API call to /api/habits not found" + assert "method: 'POST'" in content, "POST method not found" + + print("✓ JavaScript functions for modal interaction exist") + +def test_toast_notification(): + """Test toast notification element exists""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Find toast element + assert parser.toast_found, "Toast notification element with id='toast' not found" + + # Check toast styles exist + assert '.toast' in content, "Toast styles not found" + assert '.toast.show' in content, "Toast show state styles not found" + + # Check showToast function exists + assert 'function showToast(' in content, "showToast function not found" + + print("✓ Toast notification element exists") + +def test_form_validation_event_listeners(): + """Test form validation with event listeners""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Check for DOMContentLoaded event listener + assert "addEventListener('DOMContentLoaded'" in content or "DOMContentLoaded" in content, \ + "Should have DOMContentLoaded event listener" + + # Check for input event listener on name field + assert "addEventListener('input'" in content, "Should have input event listener for validation" + + # Check for Enter key handling + assert "addEventListener('keypress'" in content or "e.key === 'Enter'" in content, \ + "Should handle Enter key submission" + + print("✓ Form validation event listeners exist") + +def run_tests(): + """Run all tests""" + tests = [ + test_modal_structure, + test_name_input_field, + test_frequency_radio_buttons, + test_modal_buttons, + test_add_button_triggers_modal, + test_modal_styling, + test_javascript_functions, + test_toast_notification, + test_form_validation_event_listeners, + ] + + print("Running habit modal tests...\n") + + failed = [] + for test in tests: + try: + test() + except AssertionError as e: + print(f"✗ {test.__name__}: {e}") + failed.append(test.__name__) + except Exception as e: + print(f"✗ {test.__name__}: Unexpected error: {e}") + failed.append(test.__name__) + + print(f"\n{'='*50}") + if failed: + print(f"FAILED: {len(failed)} test(s) failed") + for name in failed: + print(f" - {name}") + sys.exit(1) + else: + print(f"SUCCESS: All {len(tests)} tests passed!") + sys.exit(0) + +if __name__ == '__main__': + run_tests()