diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e639f01..71c3e76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,17 @@ name: CI on: push: - branches: [main, feature/**] + branches: [main, master, "feature/**"] pull_request: - branches: [main] + branches: [main, master] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true jobs: - test: - name: Lint & Test + lint: + name: Ruff Lint runs-on: ubuntu-latest steps: - name: Checkout code @@ -22,10 +26,48 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ruff pytest pytest-asyncio pytest-mock anyio pyyaml aiohttp + pip install ruff - name: Run Ruff linter run: ruff check . - - name: Run Pytest test suite - run: pytest tests/ + test-backend: + name: Backend Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-asyncio pytest-mock anyio pyyaml aiohttp + + - name: Run Pytest backend test suite + run: pytest tests/ --ignore=tests/test_playwright_e2e.py + + test-e2e: + name: Playwright E2E Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-asyncio pytest-mock anyio pyyaml aiohttp playwright + playwright install chromium + + - name: Run Playwright E2E test suite + run: pytest tests/test_playwright_e2e.py -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..88e4cb6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + name: Build & Publish Release + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build HACS zip archive + run: | + cd custom_components + zip -r ../google_assistant_entity_console.zip google_assistant_entity_console -x "*.pyc" "*__pycache__*" + cd .. + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: google_assistant_entity_console.zip + generate_release_notes: true + draft: false + prerelease: false diff --git a/README.md b/README.md index 761c24b..7e61898 100644 --- a/README.md +++ b/README.md @@ -66,14 +66,14 @@ Configuring entities exposed to Google Assistant in Home Assistant traditionally 1. Open **HACS** in your Home Assistant sidebar. 2. Click the three dots (⋮) in the top-right corner and select **Custom repositories**. -3. Enter the repository URL: `https://github.com/spelech/googleAssistantConfigGen` +3. Enter the repository URL: `https://github.com/spelech/GoogleAssistantEntityConsole` 4. Select **Integration** as the Category and click **Add**. 5. Find **Google Assistant Entity Console** in HACS, click **Download**, and select the latest release. 6. Restart Home Assistant. ### Method 2: Manual Installation -1. Download the latest release `.zip` from the [Releases](https://github.com/spelech/googleAssistantConfigGen/releases) tab. +1. Download the latest release `.zip` from the [Releases](https://github.com/spelech/GoogleAssistantEntityConsole/releases) tab. 2. Extract and copy the `custom_components/google_assistant_entity_console/` directory into your Home Assistant `/config/custom_components/` directory. 3. Restart Home Assistant. diff --git a/custom_components/google_assistant_entity_console/manifest.json b/custom_components/google_assistant_entity_console/manifest.json index f74658c..a023d69 100644 --- a/custom_components/google_assistant_entity_console/manifest.json +++ b/custom_components/google_assistant_entity_console/manifest.json @@ -3,10 +3,10 @@ "name": "Google Assistant Entity Console", "codeowners": ["@spelech"], "dependencies": ["frontend", "http"], - "documentation": "https://github.com/spelech/googleAssistantConfigGen", - "issue_tracker": "https://github.com/spelech/googleAssistantConfigGen/issues", + "documentation": "https://github.com/spelech/GoogleAssistantEntityConsole", + "issue_tracker": "https://github.com/spelech/GoogleAssistantEntityConsole/issues", "iot_class": "local_polling", "config_flow": true, "requirements": [], - "version": "1.9.0" + "version": "2.0.0" } diff --git a/custom_components/google_assistant_entity_console/static/app.js b/custom_components/google_assistant_entity_console/static/app.js index 8cf7189..2489c82 100644 --- a/custom_components/google_assistant_entity_console/static/app.js +++ b/custom_components/google_assistant_entity_console/static/app.js @@ -4,6 +4,12 @@ let filteredEntities = []; let rooms = []; let currentAliases = []; const collapsedGroups = new Set(); +let activeFilters = { + exposure: 'all', + area: null, + domain: null +}; +let sidebarCollapsed = localStorage.getItem('gaec_sidebar_collapsed') === 'true'; // DOM Elements const totalEntitiesEl = document.getElementById('totalEntities'); @@ -12,6 +18,29 @@ const searchInput = document.getElementById('searchInput'); const roomFilter = document.getElementById('roomFilter'); const exposedOnlyCheckbox = document.getElementById('exposedOnlyCheckbox'); +// Slicing & Sidebar DOM Elements +const appSidebar = document.getElementById('appSidebar'); +const sidebarToggleBtn = document.getElementById('sidebarToggleBtn'); +const sidebarCollapseBtn = document.getElementById('sidebarCollapseBtn'); +const sidebarBackdrop = document.getElementById('sidebarBackdrop'); +const sidebarFilterInput = document.getElementById('sidebarFilterInput'); +const exposureNav = document.getElementById('exposureNav'); +const areasNav = document.getElementById('areasNav'); +const domainsNav = document.getElementById('domainsNav'); +const countAllEl = document.getElementById('countAll'); +const countExposedEl = document.getElementById('countExposed'); +const countUnexposedEl = document.getElementById('countUnexposed'); +const countOverridesEl = document.getElementById('countOverrides'); +const areasCountBadge = document.getElementById('areasCountBadge'); +const domainsCountBadge = document.getElementById('domainsCountBadge'); +const breadcrumbContainer = document.getElementById('breadcrumbContainer'); +const breadcrumbSliceText = document.getElementById('breadcrumbSliceText'); +const breadcrumbChips = document.getElementById('breadcrumbChips'); +const clearSliceBtn = document.getElementById('clearSliceBtn'); +const sliceBulkActionBtn = document.getElementById('sliceBulkActionBtn'); +const sliceBulkActionText = document.getElementById('sliceBulkActionText'); +const regexIndicator = document.getElementById('regexIndicator'); + // Advanced Filters DOM & State const toggleAdvancedFiltersBtn = document.getElementById('toggleAdvancedFiltersBtn'); const advancedFiltersDrawer = document.getElementById('advancedFiltersDrawer'); @@ -178,6 +207,29 @@ function setupEventListeners() { if (exposedOnlyCheckbox) exposedOnlyCheckbox.addEventListener('change', applyFilters); if (groupingType) groupingType.addEventListener('change', applyFilters); + // Sidebar & Slicing Event Listeners + if (sidebarToggleBtn) { + sidebarToggleBtn.addEventListener('click', () => toggleSidebar()); + } + if (sidebarCollapseBtn) { + sidebarCollapseBtn.addEventListener('click', () => toggleSidebar(true)); + } + if (sidebarBackdrop) { + sidebarBackdrop.addEventListener('click', () => toggleSidebar(true)); + } + if (sidebarFilterInput) { + sidebarFilterInput.addEventListener('input', () => renderSidebar()); + } + if (clearSliceBtn) { + clearSliceBtn.addEventListener('click', clearAllFilters); + } + if (sliceBulkActionBtn) { + sliceBulkActionBtn.addEventListener('click', handleSliceBulkAction); + } + if (sidebarCollapsed && appSidebar) { + appSidebar.classList.add('collapsed'); + } + // Advanced Filters Event Listeners if (toggleAdvancedFiltersBtn && advancedFiltersDrawer) { toggleAdvancedFiltersBtn.addEventListener('click', () => { @@ -252,6 +304,16 @@ function setupEventListeners() { setupAiEventListeners(); } +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function updateStats() { if (totalEntitiesEl) totalEntitiesEl.textContent = entities.length; if (exposedEntitiesEl) exposedEntitiesEl.textContent = entities.filter(e => e.should_expose).length; @@ -280,6 +342,309 @@ function updateStats() { rebuildBtn.classList.remove('pulse-highlight'); } } + + renderSidebar(); +} + +function parseSearchQuery(query) { + const specifiers = { + exposure: null, + area: null, + domain: null, + floor: null + }; + + let text = (query || '').trim(); + + // Match is:exposed, is:unexposed, not:exposed, is:override, has:alias + text = text.replace(/\b(?:is|status):(exposed|unexposed|override|overrides|alias|aliases)\b/gi, (match, val) => { + const v = val.toLowerCase(); + if (v === 'exposed') specifiers.exposure = 'exposed'; + else if (v === 'unexposed') specifiers.exposure = 'unexposed'; + else if (v.startsWith('override') || v.startsWith('alias')) specifiers.exposure = 'overrides'; + return ''; + }); + + text = text.replace(/\bnot:exposed\b/gi, () => { + specifiers.exposure = 'unexposed'; + return ''; + }); + + text = text.replace(/\bhas:(?:alias|aliases|override|overrides)\b/gi, () => { + specifiers.exposure = 'overrides'; + return ''; + }); + + // Match area:"Living Room" or area:basement + text = text.replace(/\barea:(?:"([^"]+)"|(\S+))/gi, (match, p1, p2) => { + specifiers.area = (p1 || p2).toLowerCase(); + return ''; + }); + + // Match domain:light + text = text.replace(/\bdomain:(?:"([^"]+)"|(\S+))/gi, (match, p1, p2) => { + specifiers.domain = (p1 || p2).toLowerCase(); + return ''; + }); + + // Match floor:"Main Floor" or floor:basement + text = text.replace(/\bfloor:(?:"([^"]+)"|(\S+))/gi, (match, p1, p2) => { + specifiers.floor = (p1 || p2).toLowerCase(); + return ''; + }); + + return { + specifiers, + remainingQuery: text.replace(/\s+/g, ' ').trim() + }; +} + +function renderSidebar() { + if (!exposureNav || !areasNav || !domainsNav) return; + + // Filter term for sidebar categories search + const filterTerm = sidebarFilterInput ? sidebarFilterInput.value.toLowerCase().trim() : ''; + + // 1. Exposure counts + const totalCount = entities.length; + const exposedCount = entities.filter(e => e.should_expose || e.yaml_exposed).length; + const unexposedCount = totalCount - exposedCount; + const overridesCount = entities.filter(e => (e.aliases && e.aliases.length > 0) || (e.display_name && e.display_name !== e.original_name)).length; + + if (countAllEl) countAllEl.textContent = totalCount; + if (countExposedEl) countExposedEl.textContent = exposedCount; + if (countUnexposedEl) countUnexposedEl.textContent = unexposedCount; + if (countOverridesEl) countOverridesEl.textContent = overridesCount; + + // 2. Areas section + const areaMap = new Map(); + let unassignedAreaCount = 0; + entities.forEach(e => { + const a = (e.area || '').trim(); + if (!a || a === 'TBA' || a === 'No Room') { + unassignedAreaCount++; + } else { + areaMap.set(a, (areaMap.get(a) || 0) + 1); + } + }); + + const sortedAreas = [...areaMap.keys()].sort((a, b) => a.localeCompare(b)); + if (areasCountBadge) areasCountBadge.textContent = sortedAreas.length + (unassignedAreaCount > 0 ? 1 : 0); + + areasNav.innerHTML = ''; + sortedAreas.forEach(areaName => { + if (filterTerm && !areaName.toLowerCase().includes(filterTerm)) return; + const count = areaMap.get(areaName); + const item = document.createElement('div'); + const isActive = activeFilters.area && activeFilters.area.toLowerCase() === areaName.toLowerCase(); + item.className = `sidebar-item ${isActive ? 'active' : ''}`; + item.dataset.filterType = 'area'; + item.dataset.filterValue = areaName; + item.innerHTML = ` + ${escapeHtml(areaName)} + ${count} + `; + item.addEventListener('click', () => setFilter('area', areaName)); + areasNav.appendChild(item); + }); + + if (unassignedAreaCount > 0 && (!filterTerm || 'unassigned area'.includes(filterTerm) || 'no room'.includes(filterTerm))) { + const item = document.createElement('div'); + const isActive = activeFilters.area === '__unassigned__'; + item.className = `sidebar-item ${isActive ? 'active' : ''}`; + item.dataset.filterType = 'area'; + item.dataset.filterValue = '__unassigned__'; + item.innerHTML = ` + Unassigned Area + ${unassignedAreaCount} + `; + item.addEventListener('click', () => setFilter('area', '__unassigned__')); + areasNav.appendChild(item); + } + + // 3. Domains section + const domainMap = new Map(); + entities.forEach(e => { + const d = (e.domain || 'other').trim(); + domainMap.set(d, (domainMap.get(d) || 0) + 1); + }); + + const sortedDomains = [...domainMap.keys()].sort((a, b) => domainMap.get(b) - domainMap.get(a)); + if (domainsCountBadge) domainsCountBadge.textContent = sortedDomains.length; + + domainsNav.innerHTML = ''; + sortedDomains.forEach(domain => { + const domainFriendly = getDomainName(domain); + if (filterTerm && !domain.toLowerCase().includes(filterTerm) && !domainFriendly.toLowerCase().includes(filterTerm)) return; + const count = domainMap.get(domain); + const item = document.createElement('div'); + const isActive = activeFilters.domain && activeFilters.domain.toLowerCase() === domain.toLowerCase(); + item.className = `sidebar-item ${isActive ? 'active' : ''}`; + item.dataset.filterType = 'domain'; + item.dataset.filterValue = domain; + item.innerHTML = ` + ${escapeHtml(domainFriendly)} + ${count} + `; + item.addEventListener('click', () => setFilter('domain', domain)); + domainsNav.appendChild(item); + }); + + // 4. Sync active classes on exposureNav items + if (exposureNav) { + const items = exposureNav.querySelectorAll('.sidebar-item'); + items.forEach(el => { + const val = el.dataset.sliceValue; + if (activeFilters.exposure === val) { + el.classList.add('active'); + } else { + el.classList.remove('active'); + } + if (!el.onclick) { + el.onclick = () => { + setFilter('exposure', val); + }; + } + }); + } + + updateBreadcrumbs(); +} + +function setFilter(type, value) { + if (type === 'exposure') { + activeFilters.exposure = (activeFilters.exposure === value) ? 'all' : value; + } else if (type === 'area') { + activeFilters.area = (activeFilters.area === value) ? null : value; + } else if (type === 'domain') { + activeFilters.domain = (activeFilters.domain === value) ? null : value; + } + + updateBreadcrumbs(); + renderSidebar(); + + // On mobile, close sidebar drawer upon selecting + if (window.innerWidth <= 768 && appSidebar && !appSidebar.classList.contains('collapsed')) { + toggleSidebar(true); + } + + applyFilters(); +} + +window.setFilter = setFilter; + +function clearAllFilters() { + activeFilters = { + exposure: 'all', + area: null, + domain: null + }; + if (searchInput) searchInput.value = ''; + const clearSearchBtn = document.getElementById('clearSearchBtn'); + if (clearSearchBtn) clearSearchBtn.style.display = 'none'; + + updateBreadcrumbs(); + renderSidebar(); + applyFilters(); +} + +function updateBreadcrumbs() { + if (!breadcrumbContainer || !breadcrumbSliceText || !breadcrumbChips) return; + + const chips = []; + if (activeFilters.exposure !== 'all') { + let label = 'Exposed'; + if (activeFilters.exposure === 'unexposed') label = 'Not Exposed'; + else if (activeFilters.exposure === 'overrides') label = 'Custom Overrides'; + chips.push({ + type: 'exposure', + html: `Exposure: ${escapeHtml(label)}` + }); + } + if (activeFilters.area) { + const areaLabel = activeFilters.area === '__unassigned__' ? 'Unassigned' : activeFilters.area; + chips.push({ + type: 'area', + html: `Area: ${escapeHtml(areaLabel)}` + }); + } + if (activeFilters.domain) { + chips.push({ + type: 'domain', + html: `Domain: ${escapeHtml(getDomainName(activeFilters.domain))}` + }); + } + + if (chips.length === 0) { + breadcrumbSliceText.style.display = 'inline'; + breadcrumbSliceText.textContent = `All Entities (${entities.length} items)`; + breadcrumbChips.style.display = 'none'; + breadcrumbChips.innerHTML = ''; + if (clearSliceBtn) clearSliceBtn.style.display = 'none'; + if (sliceBulkActionBtn) sliceBulkActionBtn.style.display = 'none'; + } else { + breadcrumbSliceText.style.display = 'none'; + breadcrumbChips.style.display = 'flex'; + breadcrumbChips.innerHTML = ''; + chips.forEach(c => { + const chipEl = document.createElement('div'); + chipEl.className = 'filter-chip breadcrumb-chip'; + chipEl.dataset.filterType = c.type; + chipEl.innerHTML = `${c.html} ×`; + chipEl.querySelector('.chip-remove').addEventListener('click', (e) => { + e.stopPropagation(); + setFilter(c.type, c.type === 'exposure' ? 'all' : null); + }); + breadcrumbChips.appendChild(chipEl); + }); + if (clearSliceBtn) clearSliceBtn.style.display = 'inline-flex'; + if (sliceBulkActionBtn) sliceBulkActionBtn.style.display = 'inline-flex'; + } +} + +function toggleSidebar(forceCollapse) { + if (!appSidebar) return; + const shouldCollapse = forceCollapse !== undefined ? forceCollapse : !appSidebar.classList.contains('collapsed'); + appSidebar.classList.toggle('collapsed', shouldCollapse); + if (sidebarBackdrop) { + sidebarBackdrop.classList.toggle('active', !shouldCollapse && window.innerWidth <= 768); + } + localStorage.setItem('gaec_sidebar_collapsed', shouldCollapse); +} + +async function handleSliceBulkAction() { + if (filteredEntities.length === 0) return; + + const exposedInView = filteredEntities.filter(e => e.should_expose || e.yaml_exposed).length; + const willExpose = exposedInView < filteredEntities.length / 2; + const actionWord = willExpose ? 'Expose' : 'Unexpose'; + + if (!confirm(`${actionWord} all ${filteredEntities.length} entities in the active filter?`)) { + return; + } + + showToast(`${actionWord}ing ${filteredEntities.length} entities...`, 'info'); + let updated = 0; + for (const entity of filteredEntities) { + try { + const currentExposed = !!(entity.should_expose || entity.yaml_exposed); + if (currentExposed !== willExpose) { + entity.should_expose = willExpose; + await authFetch(`/api/google_assistant_entity_console/entities/${encodeURIComponent(entity.entity_id)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expose: willExpose }) + }); + updated++; + } + } catch (err) { + console.error('Failed to update entity in filter:', entity.entity_id, err); + } + } + + showToast(`Successfully updated ${updated} entities`, 'success'); + updateStats(); + applyFilters(); } function populateRoomFilter(uniqueRooms) { @@ -331,45 +696,141 @@ function showTableLoading() { // Filter and render table function applyFilters() { - const query = searchInput ? searchInput.value.toLowerCase().trim() : ''; + const rawSearchText = searchInput ? searchInput.value : ''; + const { specifiers, remainingQuery } = parseSearchQuery(rawSearchText); + + let regex = null; + let isRegexActive = false; + + if (remainingQuery) { + try { + regex = new RegExp(remainingQuery, 'i'); + isRegexActive = true; + } catch (e) { + // Graceful fallback to substring search if invalid regex + regex = null; + isRegexActive = false; + } + } + + if (regexIndicator) { + if (remainingQuery && isRegexActive) { + regexIndicator.style.display = 'inline-flex'; + regexIndicator.title = 'Regex pattern active'; + } else { + regexIndicator.style.display = 'none'; + } + } + const room = roomFilter ? roomFilter.value : 'all'; const exposedOnly = exposedOnlyCheckbox ? exposedOnlyCheckbox.checked : false; const hideGroupedLights = hideGroupedLightsCheckbox ? hideGroupedLightsCheckbox.checked : false; const hideUnavailable = hideUnavailableCheckbox ? hideUnavailableCheckbox.checked : false; - + + // Combined effective exposure filter + const effectiveExposure = specifiers.exposure || (activeFilters.exposure !== 'all' ? activeFilters.exposure : null); + + // Combined effective area filter + const effectiveArea = specifiers.area || (activeFilters.area ? activeFilters.area.toLowerCase() : null); + + // Combined effective domain filter + const effectiveDomain = specifiers.domain || (activeFilters.domain ? activeFilters.domain.toLowerCase() : null); + filteredEntities = entities.filter(e => { - // Query match - const matchesQuery = !query || - e.entity_id.toLowerCase().includes(query) || - (e.display_name || '').toLowerCase().includes(query) || - (e.aliases || []).some(a => a && typeof a === 'string' && a.toLowerCase().includes(query)); - - // Domain match (Multi-select) - const matchesDomain = selectedDomains.size === 0 || selectedDomains.has(e.domain); - - // Room match + // 1. Exposure filter (from sidebar OR search specifier is:exposed / not:exposed / is:override) + if (effectiveExposure) { + if (effectiveExposure === 'exposed' && !(e.should_expose || e.yaml_exposed)) return false; + if (effectiveExposure === 'unexposed' && (e.should_expose || e.yaml_exposed)) return false; + if (effectiveExposure === 'overrides') { + const hasOverrides = (e.aliases && e.aliases.length > 0) || (e.display_name && e.display_name !== e.original_name); + if (!hasOverrides) return false; + } + } + + // 2. Area filter (from sidebar OR search specifier area:basement) + if (effectiveArea) { + if (effectiveArea === '__unassigned__') { + const isUnassigned = !e.area || e.area === 'TBA' || e.area === 'No Room'; + if (!isUnassigned) return false; + } else { + const entityArea = (e.area || '').toLowerCase(); + if (!entityArea.includes(effectiveArea)) return false; + } + } + + // 3. Domain filter (from sidebar OR search specifier domain:light) + if (effectiveDomain) { + const entityDomain = (e.domain || '').toLowerCase(); + if (entityDomain !== effectiveDomain) return false; + } + + // 4. Floor filter (from search specifier floor:main) + if (specifiers.floor) { + const entityFloor = (e.floor || '').toLowerCase(); + if (!entityFloor.includes(specifiers.floor)) return false; + } + + // 5. Remaining text or regex match across friendly name, entity_id, google name, aliases, area + if (remainingQuery) { + const targets = [ + e.entity_id || '', + e.display_name || '', + e.original_name || e.name || '', + e.area || '', + ...(e.aliases || []).filter(a => typeof a === 'string') + ]; + const matchesQuery = regex + ? targets.some(t => regex.test(t)) + : targets.some(t => t.toLowerCase().includes(remainingQuery.toLowerCase())); + if (!matchesQuery) return false; + } + + // 6. Domain chip filter (Multi-select) + const matchesDomainChips = selectedDomains.size === 0 || selectedDomains.has(e.domain); + if (!matchesDomainChips) return false; + + // 7. Room dropdown filter const matchesRoom = room === 'all' || (e.area || '').toLowerCase() === room; - - // Exposed match (either marked in registry or currently exposed in YAML) - const matchesExposed = !exposedOnly || e.should_expose || e.yaml_exposed; - - // Hide Grouped Lights match + if (!matchesRoom) return false; + + // 8. Exposed Only toggle + const matchesExposedOnly = !exposedOnly || e.should_expose || e.yaml_exposed; + if (!matchesExposedOnly) return false; + + // 9. Grouped lights filter const matchesGroupedLights = !hideGroupedLights || e.domain !== 'light' || !e.in_group; - - // Hide Unavailable match + if (!matchesGroupedLights) return false; + + // 10. Unavailable filter const matchesUnavailable = !hideUnavailable || !e.unavailable; - - return matchesQuery && matchesDomain && matchesRoom && matchesExposed && matchesGroupedLights && matchesUnavailable; + if (!matchesUnavailable) return false; + + return true; }); + // Update match count badge const matchCountEl = document.getElementById('filterMatchCount'); if (matchCountEl) { - if (!query && room === 'all' && !exposedOnly && selectedDomains.size === 0) { + const isDefaultState = !rawSearchText && room === 'all' && !exposedOnly && selectedDomains.size === 0 + && activeFilters.exposure === 'all' && !activeFilters.area && !activeFilters.domain; + if (isDefaultState) { matchCountEl.textContent = `${entities.length} items`; } else { matchCountEl.textContent = `${filteredEntities.length} of ${entities.length}`; } } + + // Update bulk action text + const hasActiveFilters = activeFilters.exposure !== 'all' || !!activeFilters.area || !!activeFilters.domain || !!specifiers.exposure || !!specifiers.area; + if (sliceBulkActionBtn) { + if (hasActiveFilters && filteredEntities.length > 0) { + const exposedInView = filteredEntities.filter(e => e.should_expose || e.yaml_exposed).length; + const willExpose = exposedInView < filteredEntities.length / 2; + if (sliceBulkActionText) { + sliceBulkActionText.textContent = `${willExpose ? 'Expose' : 'Unexpose'} All in Filter (${filteredEntities.length})`; + } + } + } renderTable(); } diff --git a/custom_components/google_assistant_entity_console/static/index.html b/custom_components/google_assistant_entity_console/static/index.html index 6fdebac..6103118 100644 --- a/custom_components/google_assistant_entity_console/static/index.html +++ b/custom_components/google_assistant_entity_console/static/index.html @@ -21,14 +21,19 @@
-
- -
-
-
-
- Total Entities -

0

-
-
-
-
-
- Exposed to Google -

0

-
-
-
-
-
- Pending Sync -

0

-
-
-
-
-
- Active YAML Config -

Loading...

-
-
-
+ +
+ + - -
-
- -
-
- - + +
+ + + + + +
+
+ +
+
+ + +
+
+ + +
+
+ + Exposed Only +
+ +
+
+ + + +
- -
- - - - - - - - - - - - - - -
EntityNicknames / AliasesExposed StatusActions
-
- Loading Home Assistant registry... -
-
+ +
+ + + + + + + + + + + + + + +
EntityNicknames / AliasesExposed StatusActions
+
+ Loading Home Assistant registry... +
+
+
+
diff --git a/custom_components/google_assistant_entity_console/static/style.css b/custom_components/google_assistant_entity_console/static/style.css index e1d057b..e207988 100644 --- a/custom_components/google_assistant_entity_console/static/style.css +++ b/custom_components/google_assistant_entity_console/static/style.css @@ -73,6 +73,345 @@ html, body { flex-shrink: 0; } +.header-left { + display: flex; + align-items: center; + gap: 0.85rem; +} + +.btn-sidebar-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: transparent; + border: 1px solid var(--border); + color: var(--on-surface); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.btn-sidebar-toggle:hover { + background: var(--surface-variant); + border-color: var(--primary); + color: var(--primary); +} + +/* 2-Pane Responsive Layout */ +.app-layout { + display: flex; + flex: 1; + min-height: 0; + gap: 0.75rem; + overflow: hidden; + position: relative; +} + +/* Collapsible Left Slicing Sidebar */ +.app-sidebar { + width: 260px; + min-width: 260px; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + display: flex; + flex-direction: column; + overflow: hidden; + transition: width 0.25s cubic-bezier(0.2, 0, 0, 1), min-width 0.25s cubic-bezier(0.2, 0, 0, 1), margin-left 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s ease; + flex-shrink: 0; +} + +.app-sidebar.collapsed { + width: 0; + min-width: 0; + margin-left: -0.75rem; + border: none; + opacity: 0; + pointer-events: none; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.sidebar-title { + font-size: 0.82rem; + font-weight: 700; + color: var(--on-surface); + display: flex; + align-items: center; + gap: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sidebar-collapse-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + background: transparent; + border: 1px solid transparent; + color: var(--on-surface-variant); + cursor: pointer; + transition: all 0.15s ease; +} + +.sidebar-collapse-btn:hover { + background: var(--surface-variant); + color: var(--on-surface); + border-color: var(--border); +} + +.sidebar-search-container { + position: relative; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.sidebar-search-icon { + position: absolute; + left: 1.35rem; + top: 50%; + transform: translateY(-50%); + font-size: 0.75rem; + color: var(--on-surface-variant); + pointer-events: none; +} + +#sidebarFilterInput { + width: 100%; + background-color: var(--surface-variant); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.4rem 0.6rem 0.4rem 1.8rem; + font-size: 0.8rem; + font-family: var(--font-main); + color: var(--on-surface); + outline: none; + box-sizing: border-box; + transition: border-color 0.15s ease; +} + +#sidebarFilterInput:focus { + border-color: var(--primary); +} + +.sidebar-nav-scroll { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.sidebar-section { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0.5rem; +} + +.sidebar-section-title { + font-size: 0.7rem; + font-weight: 700; + color: var(--on-surface-variant); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.25rem 0.5rem; +} + +.sidebar-section-badge { + font-size: 0.68rem; + color: var(--on-surface-variant); + background: var(--surface-variant); + padding: 1px 6px; + border-radius: 10px; + font-weight: 600; +} + +.sidebar-items { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.65rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.82rem; + color: var(--on-surface); + transition: background-color 0.12s ease, color 0.12s ease; + user-select: none; +} + +.sidebar-item:hover { + background-color: var(--surface-variant); +} + +.sidebar-item.active { + background-color: var(--primary-container); + color: var(--on-primary-container); + font-weight: 600; +} + +.sidebar-item-label { + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-item-count { + font-size: 0.7rem; + padding: 1px 6px; + border-radius: 10px; + background: var(--surface-variant); + color: var(--on-surface-variant); + font-weight: 500; + flex-shrink: 0; +} + +.sidebar-item.active .sidebar-item-count { + background: rgba(255, 255, 255, 0.2); + color: var(--on-primary-container); +} + +.badge-success { + background-color: var(--success-container); + color: var(--on-success-container); +} + +.badge-accent { + background-color: var(--warning-container); + color: var(--on-warning-container); +} + +/* Main Content Pane */ +.main-content-pane { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.65rem; + overflow: hidden; +} + +/* Breadcrumb Bar */ +.breadcrumb-container { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.85rem; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + flex-shrink: 0; +} + +.breadcrumb-left { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.82rem; + color: var(--on-surface); +} + +.breadcrumb-icon { + color: var(--primary); + font-size: 0.85rem; +} + +.breadcrumb-text { + font-weight: 600; +} + +.clear-slice-btn { + background: var(--surface-variant); + border: 1px solid var(--border); + color: var(--on-surface-variant); + border-radius: 6px; + padding: 2px 7px; + font-size: 0.72rem; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + transition: all 0.15s ease; +} + +.clear-slice-btn:hover { + color: var(--danger); + border-color: var(--danger); +} + +.breadcrumb-chips-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--surface-variant); + border: 1px solid var(--border); + color: var(--on-surface); + font-size: 0.74rem; + font-weight: 500; + padding: 2px 8px; + border-radius: 6px; +} + +.filter-chip strong { + color: var(--primary); + font-weight: 600; +} + +.filter-chip .chip-remove { + cursor: pointer; + color: var(--on-surface-variant); + font-size: 0.85rem; + display: inline-flex; + align-items: center; + transition: color 0.15s ease; +} + +.filter-chip .chip-remove:hover { + color: var(--danger); +} + +.btn-sm { + padding: 0.35rem 0.75rem; + font-size: 0.78rem; + border-radius: 6px; +} + .logo { display: flex; align-items: center; @@ -287,7 +626,7 @@ html, body { .search-box input { width: 100%; - padding: 0.5rem 4.5rem 0.5rem 2.2rem; + padding: 0.5rem 6.5rem 0.5rem 2.2rem; background-color: var(--surface-variant); border: 1px solid transparent; border-radius: 10px; @@ -304,6 +643,23 @@ html, body { background-color: var(--surface-variant); } +.regex-indicator { + position: absolute; + right: 5.2rem; + background: var(--primary-container); + color: var(--on-primary-container); + font-family: monospace; + font-size: 0.72rem; + font-weight: 700; + padding: 1px 5px; + border-radius: 4px; + user-select: none; + letter-spacing: 0.5px; + display: inline-flex; + align-items: center; + border: 1px solid var(--border); +} + .clear-search-btn { position: absolute; right: 3.5rem; @@ -1040,3 +1396,29 @@ tr { padding: 0.5rem 0.75rem; } } + +@media (max-width: 768px) { + .app-sidebar { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 100; + box-shadow: 4px 0 16px rgba(0, 0, 0, 0.4); + border-radius: 0 14px 14px 0; + } + .app-sidebar.collapsed { + margin-left: -275px; + } + .sidebar-backdrop { + display: none; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 99; + backdrop-filter: blur(2px); + } + .sidebar-backdrop.active { + display: block; + } +} diff --git a/docs/screenshots/console-combined-sidebar-filters.png b/docs/screenshots/console-combined-sidebar-filters.png new file mode 100644 index 0000000..b956141 Binary files /dev/null and b/docs/screenshots/console-combined-sidebar-filters.png differ diff --git a/docs/screenshots/console-regex-search.png b/docs/screenshots/console-regex-search.png new file mode 100644 index 0000000..22d578b Binary files /dev/null and b/docs/screenshots/console-regex-search.png differ diff --git a/docs/screenshots/console-sidebar-collapsed.png b/docs/screenshots/console-sidebar-collapsed.png new file mode 100644 index 0000000..8a98b44 Binary files /dev/null and b/docs/screenshots/console-sidebar-collapsed.png differ diff --git a/docs/screenshots/console-sidebar-overview.png b/docs/screenshots/console-sidebar-overview.png new file mode 100644 index 0000000..b45ee8f Binary files /dev/null and b/docs/screenshots/console-sidebar-overview.png differ diff --git a/docs/screenshots/console-sidebar-sliced-bar.png b/docs/screenshots/console-sidebar-sliced-bar.png new file mode 100644 index 0000000..a94ef41 Binary files /dev/null and b/docs/screenshots/console-sidebar-sliced-bar.png differ diff --git a/docs/screenshots/console-syntax-search.png b/docs/screenshots/console-syntax-search.png new file mode 100644 index 0000000..d2da05f Binary files /dev/null and b/docs/screenshots/console-syntax-search.png differ diff --git a/docs/superpowers/plans/2026-10-13-sidebar-slicing-and-regex-search.md b/docs/superpowers/plans/2026-10-13-sidebar-slicing-and-regex-search.md new file mode 100644 index 0000000..274a728 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-sidebar-slicing-and-regex-search.md @@ -0,0 +1,73 @@ +# Implementation Plan: Sidebar Slicing & Regex Search for Google Assistant Entity Console + +- **Date**: 2026-10-13 +- **Branch**: `feature/sidebar-slicing-and-regex-search` +- **Target Repository**: `spelech/GoogleAssistantEntityConsole` + +--- + +## Proposed Changes + +### 1. `static/index.html` +- Add collapsible sidebar markup (`#sidebar`) inside the main layout wrapper. +- Include sidebar search input (`#sidebarFilterInput`) for quick area/domain lookup. +- Include navigation containers: `#exposureNav`, `#areasNav`, `#domainsNav`. +- Add sidebar toggle button (`#sidebarToggleBtn`) in top app header. +- Add breadcrumb bar (`#breadcrumbBar`) with `#breadcrumbSliceText`, `#clearSliceBtn`, and `#sliceBulkActionBtn`. +- Add regex indicator pill (`#regexIndicator`) in `#searchContainer`. + +### 2. `static/style.css` +- Add 2-pane flex layout styles: + - `#appContainer`: `display: flex; height: 100vh; overflow: hidden;` + - `#sidebar`: `width: 260px; min-width: 260px; transition: all 0.25s ease; ...` + - `#sidebar.collapsed`: `width: 0; min-width: 0; padding: 0; border: none; overflow: hidden;` + - `#mainContentPane`: `flex: 1; display: flex; flex-direction: column; overflow: hidden;` +- Theme sidebar items using `--card-background-color`, `--secondary-background-color`, and `--primary-color`. +- Add responsive drawer styles for viewports `< 768px` with backdrop scrim. +- Add breadcrumb bar and regex badge styles. + +### 3. `static/app.js` +- State: + - `activeSlice: { type: 'all', value: null }` + - `sidebarCollapsed: false` (loaded from `localStorage`) +- Functions: + - `renderSidebar()`: Computes dynamic item counts for exposure states, areas, and domains. + - `setSlice(type, value, label)`: Sets active slice, highlights active sidebar element, updates breadcrumb bar, and refreshes table. + - `clearSlice()`: Resets slice to `'all'`. + - `matchesSearch(entity, query)`: Compiles `new RegExp(query, 'i')` or falls back to case-insensitive `.includes()` across name, entity_id, google_name, aliases, and area_name. Updates `#regexIndicator`. + - `toggleSidebar()`: Toggles `.collapsed` class and persists state. + - `exposeAllInSlice(expose)`: Batch sets exposure for all entities in the current slice. +- Wire event listeners for sidebar filter, sidebar toggle, breadcrumb actions, and search input. + +### 4. `manifest.json` +- Bump version to `1.10.0` for asset cache busting. + +--- + +## Verification Plan + +### Automated Tests +- Run `pytest tests/` in repo root. All 342 tests must pass. + +### Interactive Live Verification (Home Assistant DevTools) +1. Navigate to Entity Console in Home Assistant browser page. +2. Verify sidebar renders with accurate counts: Exposure states, Areas, Domains. +3. Test Slicing: + - Click an area (e.g. Living Room) -> Table filters to Living Room, breadcrumb shows `Area / Living Room (X entities)`. + - Click a domain (e.g. Light) -> Table filters to Light. + - Click `Exposed Only` -> Table filters to exposed entities. + - Click `✕ Clear Slice` -> Restores to all entities. +4. Test Regex Search: + - Type `light\.(living|bed)` -> Filters to matching entities, regex badge highlights. + - Type invalid regex like `[` -> Falls back to substring match, does not crash or throw unhandled error. +5. Test Sidebar Collapse: + - Click collapse button -> Sidebar smoothly slides shut, main table fills full width. + - Reload page -> Sidebar remembers collapsed state from `localStorage`. +6. Theme Check: + - Verify all elements adapt to Home Assistant theme variables (light/dark mode). + +### Release / PR Verification +- Capture screenshots of sidebar slicing and regex search. +- Commit to `feature/sidebar-slicing-and-regex-search` and push to origin. +- Open PR on GitHub and verify CI actions pass 100% green. +- Attach screenshots to PR description. diff --git a/docs/superpowers/specs/2026-10-13-sidebar-slicing-and-regex-search-design.md b/docs/superpowers/specs/2026-10-13-sidebar-slicing-and-regex-search-design.md new file mode 100644 index 0000000..f0ae8eb --- /dev/null +++ b/docs/superpowers/specs/2026-10-13-sidebar-slicing-and-regex-search-design.md @@ -0,0 +1,96 @@ +# Design Spec: Sidebar Slicing & Regex Search for Google Assistant Entity Console + +- **Date**: 2026-10-13 +- **Branch**: `feature/sidebar-slicing-and-regex-search` +- **Target Repository**: `spelech/GoogleAssistantEntityConsole` + +--- + +## 1. Overview +This specification adds a Home Assistant-native 2-pane slicing interface to the Google Assistant Entity Console. It introduces a collapsible left sidebar categorized by Exposure status, Areas/Rooms, and Domains (with dynamic item count badges), an instant regex-capable search engine across names, entity IDs, and aliases, and slice-level breadcrumbs and bulk operations, all while preserving the user's active Home Assistant theme tokens. + +--- + +## 2. Architecture & Layout + +### 2.1 Dual-Pane Shell +- **Root Container**: Full `100vh` flexbox shell (`display: flex; height: 100vh; overflow: hidden;`). +- **Collapsible Sidebar (`#sidebar`)**: + - Desktop width: `260px` flex-shrink `0`. + - Collapsed state: Width transitions to `0px` (or compact toggle state), setting `display: none` or hiding with CSS transform. + - Collapse toggle button in top bar and at sidebar header. + - Responsive behavior: On mobile viewports (`max-width: 768px`), renders as an off-canvas drawer with backdrop overlay scrim. + - State persistence: Remembers collapsed state in `localStorage.getItem('gaec_sidebar_collapsed')`. +- **Main Pane (`#mainPane`)**: + - Flex child (`flex: 1; display: flex; flex-direction: column; overflow: hidden;`). + - Contains KPI stats strip, breadcrumb & slice action bar, sticky table header, and isolated scrollable entity grid. + +### 2.2 Slicing Navigation Hierarchy +The sidebar displays three primary sections: +1. **Exposure Status**: + - `All Entities (total)` + - `Exposed to Google (count)` + - `Not Exposed (count)` + - `Custom Voice Overrides (count)` (entities with custom voice name or aliases) +2. **Areas / Rooms**: + - Alphabetical list of Home Assistant areas with count badges. + - Includes `Unassigned Area` for devices without an area. +3. **Domains**: + - List of domains (`light`, `switch`, `climate`, `cover`, `fan`, `sensor`, etc.) with standard icons and count badges. +4. **Sidebar Filter Input**: + - Quick text filter at the top of the sidebar allowing users with large setups (30+ areas) to quickly locate an area or domain in the tree. + +--- + +## 3. Search & Regex Engine + +### 3.1 Search Matching Logic +- **Fields Evaluated**: + 1. Entity friendly name (`entity.name` or `entity.attributes.friendly_name`) + 2. Entity ID (`entity.entity_id`) + 3. Google Voice Name (`entity.google_name` or `entity.name`) + 4. Voice Aliases (`entity.aliases` array) + 5. Area Name (`entity.area_name`) +- **Regex Support**: + - Attempts compilation via `new RegExp(query, 'i')`. + - **Graceful Fallback**: If `new RegExp()` throws a `SyntaxError` (e.g. incomplete group `(living|bed` or bracket `[`), the engine automatically catches the error and executes standard case-insensitive substring `.includes()` matching. + - **Regex Status Indicator**: An icon badge (`.*`) in the search bar indicates whether a valid regex pattern is currently active. + +--- + +## 4. Slice Scoping & Bulk Actions + +### 4.1 Breadcrumb & Slicing Scope +- Clicking any sidebar item sets `activeSlice = { type, value, label, count }`. +- **Breadcrumb Bar**: + - Displays `Category / Selection (X matches)`. + - Includes `✕ Clear Slice` button to reset to `All Entities`. +- **Slice Bulk Action**: + - `Expose All in Slice` / `Unexpose All in Slice` button triggers batch exposure toggle for all entities currently visible in the active slice. + +--- + +## 5. Home Assistant Theme Integrity + +- Strict usage of Home Assistant CSS custom properties: + - `--primary-background-color`: App background. + - `--card-background-color`: Sidebar background and data grid container. + - `--secondary-background-color`: Hover rows, sidebar item hover, search input background. + - `--primary-text-color`: Main headings and entity titles. + - `--secondary-text-color`: Subtitles, entity IDs, count badges. + - `--primary-color` / `--accent-color`: Selected sidebar item, active indicators, primary buttons. + - `--divider-color`: All table borders, dividers, and panel separators. + - `--success-color` / `--error-color`: Exposure status indicators. +- Live `postMessage` listener for `hass-update` ensures immediate synchronization when HA toggles between dark/light or custom theme modes. + +--- + +## 6. Verification Plan +1. **Pytest Integration Suite**: Run all 342 tests in `tests/` to guarantee no regressions. +2. **Browser Testing (Home Assistant DevTools)**: + - Verify sidebar collapses and expands properly. + - Verify clicking Areas, Domains, and Exposure status filters the table accurately. + - Verify regex search (e.g. `^light\.(living|bed)` or `fan|lamp`) correctly filters matching entities. + - Verify invalid regexes do not throw console errors and gracefully fall back to substring search. + - Verify dark and light themes render cleanly using Home Assistant CSS variables. +3. **CI & Screenshots**: Verify GitHub Actions CI passes green and attach screenshots to the PR. diff --git a/tests/test_playwright_e2e.py b/tests/test_playwright_e2e.py new file mode 100644 index 0000000..a7cf63a --- /dev/null +++ b/tests/test_playwright_e2e.py @@ -0,0 +1,340 @@ +"""End-to-End Playwright test suite for Google Assistant Entity Console frontend. + +Tests cover: +- Slicing sidebar multi-section stacked filtering (Exposure + Area + Domain) +- Dynamic breadcrumb chips and single-chip removal +- Combined search syntax specifiers (not:exposed, is:unexposed, area:, domain:) +- Regex search and regex indicator badge +- Sidebar collapse/expand toggle +- Dynamic bulk action button +""" + +import http.server +import json +import threading +from pathlib import Path + +import pytest + +try: + from playwright.sync_api import sync_playwright + PLAYWRIGHT_AVAILABLE = True +except ImportError: + PLAYWRIGHT_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not PLAYWRIGHT_AVAILABLE, + reason="playwright is not installed in the environment", +) + +STATIC_DIR = Path(__file__).resolve().parent.parent / "custom_components" / "google_assistant_entity_console" / "static" + +SAMPLE_ENTITIES = [ + { + "entity_id": "light.basement_ceiling", + "name": "Basement Ceiling", + "domain": "light", + "area": "Basement", + "should_expose": False, + "aliases": [], + }, + { + "entity_id": "light.basement_lamp", + "name": "Basement Lamp", + "domain": "light", + "area": "Basement", + "should_expose": True, + "aliases": ["Desk Lamp"], + }, + { + "entity_id": "switch.basement_fan", + "name": "Basement Fan", + "domain": "switch", + "area": "Basement", + "should_expose": False, + "aliases": [], + }, + { + "entity_id": "light.living_room_lamp", + "name": "Living Room Lamp", + "domain": "light", + "area": "Living Room", + "should_expose": True, + "aliases": [], + }, + { + "entity_id": "switch.kitchen_plug", + "name": "Kitchen Plug", + "domain": "switch", + "area": "Kitchen", + "should_expose": False, + "aliases": [], + }, + { + "entity_id": "sensor.unassigned_temp", + "name": "Unassigned Sensor", + "domain": "sensor", + "area": None, + "should_expose": False, + "aliases": [], + }, +] + + +class StaticHttpServer: + """Lightweight threaded HTTP server serving static frontend files.""" + + def __init__(self, directory: Path): + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(directory), **kwargs) + + def log_message(self, format, *args): + pass # Suppress request logging during tests + + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.port = self.server.server_address[1] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + + def start(self): + self.thread.start() + + def stop(self): + self.server.shutdown() + + +@pytest.fixture(scope="module") +def static_server(): + """Start static server for the module duration.""" + server = StaticHttpServer(STATIC_DIR) + server.start() + yield server + server.stop() + + +@pytest.fixture +def page(static_server): + """Launch headless browser and navigate to console with mocked entities.""" + with sync_playwright() as p: + try: + browser = p.chromium.launch(headless=True) + except Exception as e: + pytest.skip(f"Chromium browser could not be launched: {e}") + + context = browser.new_context(viewport={"width": 1280, "height": 800}) + pg = context.new_page() + + # Intercept /entities endpoint + pg.route( + "**/api/google_assistant_entity_console/entities", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({ + "entities": SAMPLE_ENTITIES, + "version": "2.0.0", + "yaml_filename": "configuration.yaml", + }), + ), + ) + + # Mock AI settings endpoint to prevent 404 console errors + pg.route( + "**/api/google_assistant_entity_console/ai/settings", + lambda route: route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({}), + ), + ) + + url = f"http://127.0.0.1:{static_server.port}/index.html" + pg.goto(url) + pg.wait_for_selector("#entityTableBody tr.entity-row") + + yield pg + + context.close() + browser.close() + + +def test_initial_load(page): + """Test initial rendering of entities and sidebar statistics.""" + rows = page.locator("#entityTableBody tr.entity-row").count() + assert rows == 6 + + # Verify counts in sidebar + count_all = page.locator("#countAll").text_content() + assert count_all == "6" + + count_exposed = page.locator("#countExposed").text_content() + assert count_exposed == "2" + + count_unexposed = page.locator("#countUnexposed").text_content() + assert count_unexposed == "4" + + +def test_stacked_sidebar_filtering_and_breadcrumbs(page): + """Test stacking exposure, area, and domain filters, and removing chips individually.""" + # 1. Filter: Not Exposed + page.click('[data-slice-value="unexposed"]') + page.wait_for_timeout(200) + + unexposed_chips = page.locator(".breadcrumb-chip").all_text_contents() + assert len(unexposed_chips) == 1 + assert "Not Exposed" in unexposed_chips[0] + assert page.locator("#entityTableBody tr.entity-row").count() == 4 + + # 2. Stack Area: Basement + page.click('[data-filter-type="area"][data-filter-value="Basement"]') + page.wait_for_timeout(200) + + chips = page.locator(".breadcrumb-chip").all_text_contents() + assert len(chips) == 2 + assert any("Not Exposed" in c for c in chips) + assert any("Basement" in c for c in chips) + # Basement has 3 entities (2 unexposed: ceiling, fan; 1 exposed: lamp) + assert page.locator("#entityTableBody tr.entity-row").count() == 2 + + # 3. Stack Domain: light + page.click('[data-filter-type="domain"][data-filter-value="light"]') + page.wait_for_timeout(200) + + chips = page.locator(".breadcrumb-chip").all_text_contents() + assert len(chips) == 3 + # Only light.basement_ceiling matches (unexposed + Basement + light) + assert page.locator("#entityTableBody tr.entity-row").count() == 1 + + # 4. Remove Domain chip + page.click('.breadcrumb-chip[data-filter-type="domain"] .chip-remove') + page.wait_for_timeout(200) + + chips = page.locator(".breadcrumb-chip").all_text_contents() + assert len(chips) == 2 + assert page.locator("#entityTableBody tr.entity-row").count() == 2 + + # 5. Clear all filters via button + page.click("#clearSliceBtn") + page.wait_for_timeout(200) + + assert page.locator(".breadcrumb-chip").count() == 0 + assert page.locator("#entityTableBody tr.entity-row").count() == 6 + + +def test_search_syntax_specifiers(page): + """Test search syntax specifiers like not:exposed, area:, domain:.""" + # not:exposed basement + page.fill("#searchInput", "not:exposed basement") + page.wait_for_timeout(300) + assert page.locator("#entityTableBody tr.entity-row").count() == 2 + + # is:unexposed domain:light + page.fill("#searchInput", "is:unexposed domain:light") + page.wait_for_timeout(300) + assert page.locator("#entityTableBody tr.entity-row").count() == 1 + + # is:exposed area:living + page.fill("#searchInput", "is:exposed area:living") + page.wait_for_timeout(300) + assert page.locator("#entityTableBody tr.entity-row").count() == 1 + + # has:alias + page.fill("#searchInput", "has:alias") + page.wait_for_timeout(300) + assert page.locator("#entityTableBody tr.entity-row").count() == 1 + + # Clear search + page.fill("#searchInput", "") + page.wait_for_timeout(200) + assert page.locator("#entityTableBody tr.entity-row").count() == 6 + + +def test_regex_search(page): + """Test regex pattern matching across entity IDs and indicator badge.""" + page.fill("#searchInput", r"light\..*lamp") + page.wait_for_timeout(300) + + # Matches light.basement_lamp and light.living_room_lamp + assert page.locator("#entityTableBody tr.entity-row").count() == 2 + assert page.locator("#regexIndicator").is_visible() is True + + # Clear search and verify regex badge disappears + page.fill("#searchInput", "") + page.wait_for_timeout(200) + assert page.locator("#regexIndicator").is_visible() is False + + +def test_sidebar_toggle(page): + """Test sidebar collapse and expand toggle behavior.""" + sidebar = page.locator("#appSidebar") + is_collapsed = "collapsed" in (sidebar.get_attribute("class") or "") + assert not is_collapsed + + # Click collapse button + page.click("#sidebarCollapseBtn") + page.wait_for_timeout(200) + + is_collapsed_after = "collapsed" in (sidebar.get_attribute("class") or "") + assert is_collapsed_after is True + + # Click expand toggle button + page.click("#sidebarToggleBtn") + page.wait_for_timeout(200) + + is_collapsed_final = "collapsed" in (sidebar.get_attribute("class") or "") + assert is_collapsed_final is False + + +def test_bulk_action_button_dynamic_text(page): + """Test that the bulk action button dynamically updates text and count based on filter.""" + bulk_btn = page.locator("#sliceBulkActionBtn") + bulk_text = page.locator("#sliceBulkActionText") + + # Initially hidden when no filters active + assert not bulk_btn.is_visible() + + # Filter to Not Exposed + Basement + page.click('[data-slice-value="unexposed"]') + page.click('[data-filter-type="area"][data-filter-value="Basement"]') + page.wait_for_timeout(200) + + assert bulk_btn.is_visible() + assert "Expose All in Filter (2)" in (bulk_text.text_content() or "") + + # Clear filters + page.click("#clearSliceBtn") + page.wait_for_timeout(200) + assert not bulk_btn.is_visible() + + +def test_sidebar_category_filter_input(page): + """Test filtering categories inside the sidebar using the quick filter input.""" + # Filter areas by typing 'base' + page.fill("#sidebarFilterInput", "base") + page.wait_for_timeout(200) + + visible_areas = page.locator("#areasNav .sidebar-item").all_text_contents() + assert len(visible_areas) == 1 + assert "Basement" in visible_areas[0] + + # Clear sidebar filter input + page.fill("#sidebarFilterInput", "") + page.wait_for_timeout(200) + + all_areas = page.locator("#areasNav .sidebar-item").all_text_contents() + assert len(all_areas) >= 3 + + +def test_unassigned_area_filter(page): + """Test slicing by unassigned area.""" + page.click('[data-filter-type="area"][data-filter-value="__unassigned__"]') + page.wait_for_timeout(200) + + chips = page.locator(".breadcrumb-chip").all_text_contents() + assert len(chips) == 1 + assert "Unassigned" in chips[0] + + rows = page.locator("#entityTableBody tr.entity-row").count() + assert rows == 1 + assert "sensor.unassigned_temp" in (page.locator("#entityTableBody").text_content() or "") +