feat: US-004 - Frontend: Search and filter collapse to icons
This commit is contained in:
@@ -31,15 +31,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Filter bar */
|
/* Filter bar */
|
||||||
|
/* Filter/Search Bar - Collapsible */
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container, .filter-container {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 300ms ease, padding 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container.expanded {
|
||||||
|
max-height: 100px;
|
||||||
|
padding: 0 var(--space-3) var(--space-3) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-container.expanded {
|
||||||
|
max-height: 500px;
|
||||||
|
padding: 0 var(--space-3) var(--space-3) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group {
|
.filter-group {
|
||||||
@@ -47,6 +118,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-label {
|
.filter-label {
|
||||||
@@ -79,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.filter-bar {
|
.filter-options {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,6 +989,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
|
<!-- Collapsed toolbar with icons -->
|
||||||
|
<div class="filter-toolbar">
|
||||||
|
<button class="filter-icon-btn" id="searchToggle" title="Search habits">
|
||||||
|
<i data-lucide="search"></i>
|
||||||
|
</button>
|
||||||
|
<button class="filter-icon-btn" id="filterToggle" title="Filter and sort">
|
||||||
|
<i data-lucide="sliders"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search container (collapsed by default) -->
|
||||||
|
<div class="search-container" id="searchContainer">
|
||||||
|
<input type="text" id="searchInput" class="search-input" placeholder="Search habits by name...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter options container (collapsed by default) -->
|
||||||
|
<div class="filter-container" id="filterContainer">
|
||||||
|
<div class="filter-options">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label class="filter-label">Category</label>
|
<label class="filter-label">Category</label>
|
||||||
<select id="categoryFilter" class="filter-select" onchange="applyFiltersAndSort()">
|
<select id="categoryFilter" class="filter-select" onchange="applyFiltersAndSort()">
|
||||||
@@ -950,6 +1040,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats Section -->
|
<!-- Stats Section -->
|
||||||
<div id="statsSection" class="stats-section" style="display: none;">
|
<div id="statsSection" class="stats-section" style="display: none;">
|
||||||
@@ -1173,6 +1265,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search and filter collapse/expand
|
||||||
|
function toggleSearch() {
|
||||||
|
const searchContainer = document.getElementById('searchContainer');
|
||||||
|
const searchToggle = document.getElementById('searchToggle');
|
||||||
|
const filterContainer = document.getElementById('filterContainer');
|
||||||
|
const filterToggle = document.getElementById('filterToggle');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
|
||||||
|
const isExpanded = searchContainer.classList.contains('expanded');
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
// Collapse search
|
||||||
|
searchContainer.classList.remove('expanded');
|
||||||
|
searchToggle.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
// Collapse filter first if open
|
||||||
|
filterContainer.classList.remove('expanded');
|
||||||
|
filterToggle.classList.remove('active');
|
||||||
|
|
||||||
|
// Expand search
|
||||||
|
searchContainer.classList.add('expanded');
|
||||||
|
searchToggle.classList.add('active');
|
||||||
|
|
||||||
|
// Focus input after animation
|
||||||
|
setTimeout(() => searchInput.focus(), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilters() {
|
||||||
|
const filterContainer = document.getElementById('filterContainer');
|
||||||
|
const filterToggle = document.getElementById('filterToggle');
|
||||||
|
const searchContainer = document.getElementById('searchContainer');
|
||||||
|
const searchToggle = document.getElementById('searchToggle');
|
||||||
|
|
||||||
|
const isExpanded = filterContainer.classList.contains('expanded');
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
// Collapse filters
|
||||||
|
filterContainer.classList.remove('expanded');
|
||||||
|
filterToggle.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
// Collapse search first if open
|
||||||
|
searchContainer.classList.remove('expanded');
|
||||||
|
searchToggle.classList.remove('active');
|
||||||
|
|
||||||
|
// Expand filters
|
||||||
|
filterContainer.classList.add('expanded');
|
||||||
|
filterToggle.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAll() {
|
||||||
|
const searchContainer = document.getElementById('searchContainer');
|
||||||
|
const searchToggle = document.getElementById('searchToggle');
|
||||||
|
const filterContainer = document.getElementById('filterContainer');
|
||||||
|
const filterToggle = document.getElementById('filterToggle');
|
||||||
|
|
||||||
|
searchContainer.classList.remove('expanded');
|
||||||
|
searchToggle.classList.remove('active');
|
||||||
|
filterContainer.classList.remove('expanded');
|
||||||
|
filterToggle.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search habits by name
|
||||||
|
function searchHabits(habits, query) {
|
||||||
|
if (!query || query.trim() === '') {
|
||||||
|
return habits;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return habits.filter(habit =>
|
||||||
|
habit.name.toLowerCase().includes(lowerQuery)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply filters and sort
|
// Apply filters and sort
|
||||||
function applyFiltersAndSort() {
|
function applyFiltersAndSort() {
|
||||||
const categoryFilter = document.getElementById('categoryFilter').value;
|
const categoryFilter = document.getElementById('categoryFilter').value;
|
||||||
@@ -1279,8 +1446,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters and sort
|
// Apply search, filters, and sort
|
||||||
let filteredHabits = filterHabits(habits);
|
const searchQuery = document.getElementById('searchInput').value;
|
||||||
|
let searchedHabits = searchHabits(habits, searchQuery);
|
||||||
|
let filteredHabits = filterHabits(searchedHabits);
|
||||||
let sortedHabits = sortHabits(filteredHabits);
|
let sortedHabits = sortHabits(filteredHabits);
|
||||||
|
|
||||||
if (sortedHabits.length === 0) {
|
if (sortedHabits.length === 0) {
|
||||||
@@ -2251,6 +2420,38 @@
|
|||||||
// Initialize page
|
// Initialize page
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
restoreFilters();
|
restoreFilters();
|
||||||
|
|
||||||
|
// Add event listeners for search/filter collapse
|
||||||
|
document.getElementById('searchToggle').addEventListener('click', toggleSearch);
|
||||||
|
document.getElementById('filterToggle').addEventListener('click', toggleFilters);
|
||||||
|
|
||||||
|
// Search input listener
|
||||||
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
|
renderHabits();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC key to collapse
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
collapseAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to collapse
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const filterBar = document.querySelector('.filter-bar');
|
||||||
|
const searchContainer = document.getElementById('searchContainer');
|
||||||
|
const filterContainer = document.getElementById('filterContainer');
|
||||||
|
|
||||||
|
// If click is outside filter bar and something is expanded
|
||||||
|
if (!filterBar.contains(e.target)) {
|
||||||
|
if (searchContainer.classList.contains('expanded') ||
|
||||||
|
filterContainer.classList.contains('expanded')) {
|
||||||
|
collapseAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
loadHabits();
|
loadHabits();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1520,16 +1520,17 @@ def test_pickers_wrap_mobile():
|
|||||||
print("✓ Test 94: Icon and color pickers wrap properly on mobile")
|
print("✓ Test 94: Icon and color pickers wrap properly on mobile")
|
||||||
|
|
||||||
def test_filter_bar_stacks_mobile():
|
def test_filter_bar_stacks_mobile():
|
||||||
"""Test 95: Filter bar stacks vertically on mobile"""
|
"""Test 95: Filter options stack vertically on mobile"""
|
||||||
habits_path = Path(__file__).parent.parent / 'habits.html'
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
content = habits_path.read_text()
|
content = habits_path.read_text()
|
||||||
|
|
||||||
# Check for filter bar mobile styles
|
# Check for filter options mobile styles
|
||||||
mobile_section = content[content.find('@media (max-width: 768px)'):content.find('@media (max-width: 768px)') + 3000]
|
mobile_section = content[content.find('@media (max-width: 768px)'):content.find('@media (max-width: 768px)') + 3000]
|
||||||
|
|
||||||
assert '.filter-bar' in mobile_section, "Should have filter-bar mobile styles"
|
assert '.filter-options' in mobile_section or '.filter-group' in mobile_section, \
|
||||||
assert 'flex-direction: column' in mobile_section, "Filter bar should stack vertically"
|
"Should have filter options mobile styles"
|
||||||
print("✓ Test 95: Filter bar stacks vertically on mobile")
|
assert 'flex-direction: column' in mobile_section, "Filter options should stack vertically"
|
||||||
|
print("✓ Test 95: Filter options stack vertically on mobile")
|
||||||
|
|
||||||
def test_stats_2x2_mobile():
|
def test_stats_2x2_mobile():
|
||||||
"""Test 96: Stats row shows 2x2 grid on mobile"""
|
"""Test 96: Stats row shows 2x2 grid on mobile"""
|
||||||
@@ -1737,6 +1738,20 @@ def run_all_tests():
|
|||||||
test_toast_messages_for_toggle,
|
test_toast_messages_for_toggle,
|
||||||
test_delete_request_format,
|
test_delete_request_format,
|
||||||
test_typecheck_us003,
|
test_typecheck_us003,
|
||||||
|
# US-004 tests (Search/filter collapse)
|
||||||
|
test_filter_toolbar_icons,
|
||||||
|
test_search_container_collapsible,
|
||||||
|
test_filter_container_collapsible,
|
||||||
|
test_toggle_functions_exist,
|
||||||
|
test_toggle_logic,
|
||||||
|
test_css_animations,
|
||||||
|
test_esc_key_listener,
|
||||||
|
test_click_outside_listener,
|
||||||
|
test_search_function_exists,
|
||||||
|
test_search_input_listener,
|
||||||
|
test_render_habits_uses_search,
|
||||||
|
test_event_listeners_initialized,
|
||||||
|
test_typecheck_us004,
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f"\nRunning {len(tests)} frontend tests for US-002, US-003, US-006 through US-014...\n")
|
print(f"\nRunning {len(tests)} frontend tests for US-002, US-003, US-006 through US-014...\n")
|
||||||
@@ -2014,5 +2029,234 @@ def test_typecheck_us003():
|
|||||||
assert result.returncode == 0, f"Typecheck failed: {result.stderr}"
|
assert result.returncode == 0, f"Typecheck failed: {result.stderr}"
|
||||||
print("✓ Test 124: Typecheck passes")
|
print("✓ Test 124: Typecheck passes")
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# US-004: Frontend - Search and filter collapse to icons
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
def test_filter_toolbar_icons():
|
||||||
|
"""Test 125: Filter bar shows icon toolbar on page load (~40px height)"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
content = habits_path.read_text()
|
||||||
|
|
||||||
|
# Check for toolbar structure
|
||||||
|
assert 'filter-toolbar' in content, "Should have filter-toolbar element"
|
||||||
|
assert 'id="searchToggle"' in content, "Should have search toggle button"
|
||||||
|
assert 'id="filterToggle"' in content, "Should have filter toggle button"
|
||||||
|
assert 'data-lucide="search"' in content, "Should have search icon"
|
||||||
|
assert 'data-lucide="sliders"' in content or 'data-lucide="filter"' in content, \
|
||||||
|
"Should have filter/sliders icon"
|
||||||
|
|
||||||
|
# Check toolbar height
|
||||||
|
assert 'min-height: 40px' in content or 'min-height:40px' in content, \
|
||||||
|
"Toolbar should be ~40px height"
|
||||||
|
|
||||||
|
print("✓ Test 125: Filter toolbar with icons exists")
|
||||||
|
|
||||||
|
def test_search_container_collapsible():
|
||||||
|
"""Test 126: Search container is collapsible with expanded class"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
content = habits_path.read_text()
|
||||||
|
|
||||||
|
# Check for search container structure
|
||||||
|
assert 'search-container' in content, "Should have search-container element"
|
||||||
|
assert 'id="searchContainer"' in content, "Search container should have id"
|
||||||
|
assert 'id="searchInput"' in content, "Should have search input"
|
||||||
|
|
||||||
|
# Check CSS for collapse/expand states
|
||||||
|
assert 'max-height: 0' in content, "Collapsed state should have max-height: 0"
|
||||||
|
assert '.search-container.expanded' in content or '.expanded' in content, \
|
||||||
|
"Should have expanded state CSS"
|
||||||
|
|
||||||
|
print("✓ Test 126: Search container is collapsible")
|
||||||
|
|
||||||
|
def test_filter_container_collapsible():
|
||||||
|
"""Test 127: Filter container is collapsible with expanded class"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
content = habits_path.read_text()
|
||||||
|
|
||||||
|
# Check for filter container structure
|
||||||
|
assert 'filter-container' in content, "Should have filter-container element"
|
||||||
|
assert 'id="filterContainer"' in content, "Filter container should have id"
|
||||||
|
assert 'filter-options' in content, "Should have filter-options wrapper"
|
||||||
|
|
||||||
|
# Check that filter options are inside container
|
||||||
|
filter_start = content.find('id="filterContainer"')
|
||||||
|
category_pos = content.find('id="categoryFilter"')
|
||||||
|
status_pos = content.find('id="statusFilter"')
|
||||||
|
sort_pos = content.find('id="sortSelect"')
|
||||||
|
|
||||||
|
assert filter_start < category_pos, "Category filter should be inside filter container"
|
||||||
|
assert filter_start < status_pos, "Status filter should be inside filter container"
|
||||||
|
assert filter_start < sort_pos, "Sort select should be inside filter container"
|
||||||
|
|
||||||
|
print("✓ Test 127: Filter container is collapsible")
|
||||||
|
|
||||||
|
def test_toggle_functions_exist():
|
||||||
|
"""Test 128: toggleSearch, toggleFilters, and collapseAll functions defined"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
js = habits_path.read_text()
|
||||||
|
|
||||||
|
assert 'function toggleSearch()' in js, "Should have toggleSearch function"
|
||||||
|
assert 'function toggleFilters()' in js, "Should have toggleFilters function"
|
||||||
|
assert 'function collapseAll()' in js, "Should have collapseAll function"
|
||||||
|
|
||||||
|
print("✓ Test 128: Toggle functions are defined")
|
||||||
|
|
||||||
|
def test_toggle_logic():
|
||||||
|
"""Test 129: Toggle functions add/remove 'expanded' class"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
js = habits_path.read_text()
|
||||||
|
|
||||||
|
# Extract toggleSearch function
|
||||||
|
toggle_start = js.find('function toggleSearch()')
|
||||||
|
toggle_end = js.find('function toggleFilters()', toggle_start)
|
||||||
|
toggle_search_code = js[toggle_start:toggle_end]
|
||||||
|
|
||||||
|
assert "classList.contains('expanded')" in toggle_search_code, \
|
||||||
|
"Should check for expanded state"
|
||||||
|
assert "classList.add('expanded')" in toggle_search_code, \
|
||||||
|
"Should add expanded class"
|
||||||
|
assert "classList.remove('expanded')" in toggle_search_code, \
|
||||||
|
"Should remove expanded class"
|
||||||
|
assert "classList.add('active')" in toggle_search_code, \
|
||||||
|
"Should add active class to button"
|
||||||
|
|
||||||
|
print("✓ Test 129: Toggle functions implement expand/collapse logic")
|
||||||
|
|
||||||
|
def test_css_animations():
|
||||||
|
"""Test 130: CSS includes transition animations (300ms ease)"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
css = habits_path.read_text()
|
||||||
|
|
||||||
|
# Check for transition properties
|
||||||
|
assert 'transition:' in css or 'transition :' in css, "Should have CSS transitions"
|
||||||
|
assert '300ms' in css, "Should have 300ms transition duration"
|
||||||
|
assert 'ease' in css, "Should use ease timing function"
|
||||||
|
|
||||||
|
# Check for max-height transition specifically
|
||||||
|
assert 'max-height' in css, "Should animate max-height"
|
||||||
|
|
||||||
|
print("✓ Test 130: CSS animations defined (300ms ease)")
|
||||||
|
|
||||||
|
def test_esc_key_listener():
|
||||||
|
"""Test 131: ESC key collapses expanded search/filter"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
js = habits_path.read_text()
|
||||||
|
|
||||||
|
assert "addEventListener('keydown'" in js, "Should have keydown event listener"
|
||||||
|
assert "e.key === 'Escape'" in js or 'e.key === "Escape"' in js, \
|
||||||
|
"Should check for Escape key"
|
||||||
|
assert 'collapseAll()' in js, "Should call collapseAll on ESC"
|
||||||
|
|
||||||
|
print("✓ Test 131: ESC key listener implemented")
|
||||||
|
|
||||||
|
def test_click_outside_listener():
|
||||||
|
"""Test 132: Clicking outside collapses expanded search/filter"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
js = habits_path.read_text()
|
||||||
|
|
||||||
|
# Find click outside logic
|
||||||
|
assert "addEventListener('click'" in js, "Should have click event listener"
|
||||||
|
assert '.filter-bar' in js, "Should reference filter-bar element"
|
||||||
|
assert 'contains(e.target)' in js, "Should check if click is outside"
|
||||||
|
|
||||||
|
# Check that it calls collapseAll when clicked outside
|
||||||
|
# Look for the pattern in the entire file since it might be structured across lines
|
||||||
|
click_listeners = []
|
||||||
|
pos = 0
|
||||||
|
while True:
|
||||||
|
pos = js.find("addEventListener('click'", pos)
|
||||||
|
if pos == -1:
|
||||||
|
break
|
||||||
|
click_listeners.append(js[pos:pos + 800])
|
||||||
|
pos += 1
|
||||||
|
|
||||||
|
# Check if any click listener has the collapse logic
|
||||||
|
has_collapse = any('collapseAll' in listener for listener in click_listeners)
|
||||||
|
assert has_collapse, "Should call collapseAll when clicking outside"
|
||||||
|
|
||||||
|
print("✓ Test 132: Click outside listener implemented")
|
||||||
|
|
||||||
|
def test_search_function_exists():
|
||||||
|
"""Test 133: searchHabits function filters by name"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
js = habits_path.read_text()
|
||||||
|
|
||||||
|
assert 'function searchHabits(' in js, "Should have searchHabits function"
|
||||||
|
|
||||||
|
# Check search logic
|
||||||
|
search_start = js.find('function searchHabits(')
|
||||||
|
search_end = js.find('function ', search_start + 20)
|
||||||
|
search_code = js[search_start:search_end]
|
||||||
|
|
||||||
|
assert 'toLowerCase()' in search_code, "Should use case-insensitive search"
|
||||||
|
assert 'includes(' in search_code, "Should use includes for matching"
|
||||||
|
assert 'habit.name' in search_code, "Should search by habit name"
|
||||||
|
|
||||||
|
print("✓ Test 133: searchHabits function filters by name")
|
||||||
|
|
||||||
|
def test_search_input_listener():
|
||||||
|
"""Test 134: Search input triggers renderHabits on input event"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
js = habits_path.read_text()
|
||||||
|
|
||||||
|
# Check for search input event listener
|
||||||
|
assert "getElementById('searchInput')" in js or 'getElementById("searchInput")' in js, \
|
||||||
|
"Should get searchInput element"
|
||||||
|
assert "addEventListener('input'" in js, "Should listen to input events"
|
||||||
|
assert 'renderHabits()' in js, "Should call renderHabits when searching"
|
||||||
|
|
||||||
|
print("✓ Test 134: Search input listener triggers renderHabits")
|
||||||
|
|
||||||
|
def test_render_habits_uses_search():
|
||||||
|
"""Test 135: renderHabits applies searchHabits before filters"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
js = habits_path.read_text()
|
||||||
|
|
||||||
|
# Find renderHabits function
|
||||||
|
render_start = js.find('function renderHabits()')
|
||||||
|
render_section = js[render_start:render_start + 2000]
|
||||||
|
|
||||||
|
assert 'searchHabits(' in render_section, "Should call searchHabits"
|
||||||
|
assert 'filterHabits(' in render_section, "Should call filterHabits"
|
||||||
|
|
||||||
|
# Check that search happens before filter
|
||||||
|
search_pos = render_section.find('searchHabits(')
|
||||||
|
filter_pos = render_section.find('filterHabits(')
|
||||||
|
assert search_pos < filter_pos, "Search should happen before filtering"
|
||||||
|
|
||||||
|
print("✓ Test 135: renderHabits applies search before filters")
|
||||||
|
|
||||||
|
def test_event_listeners_initialized():
|
||||||
|
"""Test 136: Toggle button event listeners attached on page load"""
|
||||||
|
habits_path = Path(__file__).parent.parent / 'habits.html'
|
||||||
|
js = habits_path.read_text()
|
||||||
|
|
||||||
|
# Find initialization section (near end of script)
|
||||||
|
init_section = js[-2000:] # Last 2000 chars
|
||||||
|
|
||||||
|
assert "getElementById('searchToggle')" in init_section or \
|
||||||
|
'getElementById("searchToggle")' in init_section, \
|
||||||
|
"Should attach listener to searchToggle"
|
||||||
|
assert "getElementById('filterToggle')" in init_section or \
|
||||||
|
'getElementById("filterToggle")' in init_section, \
|
||||||
|
"Should attach listener to filterToggle"
|
||||||
|
assert 'toggleSearch' in init_section, "Should attach toggleSearch handler"
|
||||||
|
assert 'toggleFilters' in init_section, "Should attach toggleFilters handler"
|
||||||
|
|
||||||
|
print("✓ Test 136: Event listeners initialized on page load")
|
||||||
|
|
||||||
|
def test_typecheck_us004():
|
||||||
|
"""Test 137: Typecheck passes for US-004 changes"""
|
||||||
|
repo_root = Path(__file__).parent.parent.parent
|
||||||
|
result = subprocess.run(
|
||||||
|
['python3', '-m', 'py_compile', 'dashboard/api.py', 'dashboard/habits_helpers.py'],
|
||||||
|
cwd=repo_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"Typecheck failed: {result.stderr}"
|
||||||
|
print("✓ Test 137: Typecheck passes")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
run_all_tests()
|
run_all_tests()
|
||||||
|
|||||||
Reference in New Issue
Block a user