From 30f61d159deedbcd672c47c858270c410be50887 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 15 Apr 2026 20:36:16 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20address=20Phase=202=20COE=20verification?= =?UTF-8?q?=20findings=20=E2=80=94=20dropdown=20styling,=20ARIA,=20sidebar?= =?UTF-8?q?=20clipping,=20manage-views=20wiring,=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: BEM class names — renderViewDropdown/renderSidebarViewDropdown now emit view-dropdown__item, __separator, __shortcut, __action, __count matching CSS Fix 2: role=menuitem — all buttons in both dropdown renderers now carry role="menuitem" so handleGlobalKeydown arrow-key navigation works Fix 3: Sidebar dropdown clipping — #sidebar-view-dropdown-menu changed to position:fixed; toggleSidebarViewDropdown uses getBoundingClientRect() to escape .session-sidebar { overflow:hidden } clipping Fix 4: manage-views wiring — bindStaticEventListeners now calls openSettings() + switchSettingsTab('views') instead of falling through to closeViewDropdown only Fix 5: active_view persistence — delete and rename paths in renderViewsSettingsTab now PATCH /api/state when _activeView changes Fix 6: Sidebar dropdown click-outside — second document click listener closes sidebar-view-dropdown-menu on outside clicks Fix 7: Case-insensitive reserved names — showNewViewInput and commitRename use name.toLowerCase() for 'all'/'hidden' check Fix 8: Rename input maxLength — views-settings-rename-input now has maxLength=30 matching the creation input Fix 9: Local device_id — added _localDeviceId variable, fetched from /api/instance-info at startup; createNewSession uses _localDeviceId instead of _serverSettings.device_id (which is not in /api/settings response) Fix 10: Dead code — renderFilterBar body emptied; dead filter-bar click handler removed from bindStaticEventListeners Tests: 24 new tests added covering all 10 findings; 1070 total tests pass --- muxplex/frontend/app.js | 118 ++++---- muxplex/frontend/style.css | 3 +- muxplex/tests/test_frontend_js.py | 428 ++++++++++++++++++++++++++++++ 3 files changed, 489 insertions(+), 60 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 7049a77..6ab499b 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -138,6 +138,7 @@ let _serverSettings = null; let _gridViewMode = 'flat'; let _activeFilterDevice = 'all'; let _activeView = 'all'; +let _localDeviceId = null; const DISPLAY_DEFAULTS = { fontSize: 14, hoverPreviewDelay: 1500, @@ -784,27 +785,7 @@ function renderGroupedGrid(sessions, mobile) { * @param {Array} allSessions - Full (unfiltered) session list used to derive device names. */ function renderFilterBar(container, allSessions) { - allSessions = allSessions || []; - // Collect unique device names preserving insertion order - var devices = []; - var seen = {}; - for (var i = 0; i < allSessions.length; i++) { - var dn = allSessions[i].deviceName || 'Unknown'; - if (!seen[dn]) { - seen[dn] = true; - devices.push(dn); - } - } - - // Build HTML: 'All' pill first, then one pill per device - var allActive = _activeFilterDevice === 'all' ? ' filter-pill--active' : ''; - var html = ''; - for (var j = 0; j < devices.length; j++) { - var active = _activeFilterDevice === devices[j] ? ' filter-pill--active' : ''; - html += ''; - } - - container.innerHTML = html; + // Dead code: filter bar replaced by Views feature. Kept as empty stub for export compatibility. } // --------------------------------------------------------------------------- @@ -826,28 +807,28 @@ function renderViewDropdown() { var html = ''; // — All Sessions (always first, shortcut 1) - var allActive = _activeView === 'all' ? ' view-dropdown-item--active' : ''; - html += ''; + var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : ''; + html += ''; // — User views (shortcuts 2–8) if (views.length > 0) { - html += '
'; + html += '
'; for (var i = 0; i < views.length && i < 7; i++) { var v = views[i]; - var vActive = _activeView === v.name ? ' view-dropdown-item--active' : ''; - html += ''; + var vActive = _activeView === v.name ? ' view-dropdown__item--active' : ''; + html += ''; } } // — Hidden (N) (always last system view, shortcut 9) - html += '
'; - var hiddenActive = _activeView === 'hidden' ? ' view-dropdown-item--active' : ''; - html += ''; + html += '
'; + var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : ''; + html += ''; // — Actions - html += '
'; - html += ''; - html += ''; + html += '
'; + html += ''; + html += ''; menu.innerHTML = html; @@ -913,23 +894,23 @@ function renderSidebarViewDropdown() { var html = ''; // — All Sessions (always first) - var allActive = _activeView === 'all' ? ' view-dropdown-item--active' : ''; - html += ''; + var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : ''; + html += ''; // — User views if (views.length > 0) { - html += '
'; + html += '
'; for (var i = 0; i < views.length && i < 7; i++) { var v = views[i]; - var vActive = _activeView === v.name ? ' view-dropdown-item--active' : ''; - html += ''; + var vActive = _activeView === v.name ? ' view-dropdown__item--active' : ''; + html += ''; } } // — Hidden (N) (always last system view) - html += '
'; - var hiddenActive = _activeView === 'hidden' ? ' view-dropdown-item--active' : ''; - html += ''; + html += '
'; + var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : ''; + html += ''; menu.innerHTML = html; } @@ -948,6 +929,12 @@ function toggleSidebarViewDropdown() { menu.classList.add('hidden'); if (trigger) trigger.setAttribute('aria-expanded', 'false'); } else { + // Position with fixed coordinates to escape sidebar overflow:hidden clipping + if (trigger) { + var rect = trigger.getBoundingClientRect(); + menu.style.top = (rect.bottom + 2) + 'px'; + menu.style.left = rect.left + 'px'; + } menu.classList.remove('hidden'); if (trigger) trigger.setAttribute('aria-expanded', 'true'); renderSidebarViewDropdown(); @@ -998,8 +985,8 @@ function showNewViewInput() { // Validate: not empty if (!name) return; - // Validate: not reserved - if (name === 'all' || name === 'hidden') { + // Validate: not reserved (case-insensitive) + if (name.toLowerCase() === 'all' || name.toLowerCase() === 'hidden') { showToast('Cannot use reserved name \'' + name + '\''); return; } @@ -1204,6 +1191,7 @@ function renderViewsSettingsTab() { // If deleting the active view, fall back to 'all' if (_activeView === views[idx].name) { _activeView = 'all'; + api('PATCH', '/api/state', { active_view: _activeView }).catch(function() {}); } _saveViewsAndRerender(updated); return; @@ -1227,6 +1215,7 @@ function renderViewsSettingsTab() { input.type = 'text'; input.className = 'views-settings-rename-input'; input.value = currentName; + input.maxLength = 30; target.parentNode.replaceChild(input, target); input.focus(); input.select(); @@ -1239,8 +1228,8 @@ function renderViewsSettingsTab() { renderViewsSettingsTab(); return; } - // Validate: not reserved - if (newName === 'all' || newName === 'hidden') { + // Validate: not reserved (case-insensitive) + if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') { showToast('Cannot use reserved name \'' + newName + '\''); renderViewsSettingsTab(); return; @@ -1260,6 +1249,7 @@ function renderViewsSettingsTab() { // If this was the active view, update _activeView if (_activeView === currentName) { _activeView = newName; + api('PATCH', '/api/state', { active_view: _activeView }).catch(function() {}); } _saveViewsAndRerender(updated); } @@ -2684,8 +2674,8 @@ async function createNewSession(name, remoteId) { } if (viewIdx >= 0) { var newSessionKey = remoteId ? (remoteId + ':' + sessionName) : sessionName; - if (!remoteId && _serverSettings && _serverSettings.device_id) { - newSessionKey = _serverSettings.device_id + ':' + sessionName; + if (!remoteId && _localDeviceId) { + newSessionKey = _localDeviceId + ':' + sessionName; } var updatedViews = JSON.parse(JSON.stringify(views)); if (!updatedViews[viewIdx].sessions.includes(newSessionKey)) { @@ -2816,6 +2806,10 @@ function bindStaticEventListeners() { if (action) { if (action.dataset.action === 'new-view') { showNewViewInput(); + } else if (action.dataset.action === 'manage-views') { + closeViewDropdown(); + openSettings(); + switchSettingsTab('views'); } else { closeViewDropdown(); } @@ -2840,7 +2834,7 @@ function bindStaticEventListeners() { }); } - // Click-outside closes the dropdown + // Click-outside closes the header view dropdown document.addEventListener('click', function(e) { var dropdown = $('view-dropdown-menu'); if (!dropdown || dropdown.classList.contains('hidden')) return; @@ -2849,6 +2843,18 @@ function bindStaticEventListeners() { if (!dropdown.contains(e.target)) closeViewDropdown(); }); + // Click-outside closes the sidebar view dropdown + document.addEventListener('click', function(e) { + var sidebarDropdown = $('sidebar-view-dropdown-menu'); + if (!sidebarDropdown || sidebarDropdown.classList.contains('hidden')) return; + var sidebarTrigger = $('sidebar-view-dropdown-trigger'); + if (sidebarTrigger && sidebarTrigger.contains(e.target)) return; + if (!sidebarDropdown.contains(e.target)) { + sidebarDropdown.classList.add('hidden'); + if (sidebarTrigger) sidebarTrigger.setAttribute('aria-expanded', 'false'); + } + }); + var newSessionBtn = $('new-session-btn'); if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); var sidebarNewSessionBtn = $('sidebar-new-session-btn'); @@ -3060,17 +3066,6 @@ function bindStaticEventListeners() { }); } - // Filter bar — delegated click handler (pills are re-rendered each poll) - var filterBarEl = $('filter-bar'); - if (filterBarEl) { - filterBarEl.addEventListener('click', function(e) { - var pill = e.target.closest && e.target.closest('.filter-pill'); - if (!pill) return; - _activeFilterDevice = pill.dataset.device || 'all'; - renderGrid(_currentSessions || []); - }); - } - // Commands tab — delete template textarea with 500ms debounce var _deleteTemplateDebounceTimer; on($('setting-delete-template'), 'input', function() { @@ -3149,6 +3144,13 @@ document.addEventListener('DOMContentLoaded', async function() { // Load ALL settings (now includes display + sidebar) before first render await loadServerSettings(); + // Cache local device_id from /api/instance-info for session key construction + api('GET', '/api/instance-info').then(function(res) { + return res.json(); + }).then(function(info) { + if (info && info.device_id) _localDeviceId = info.device_id; + }).catch(function() { /* non-critical — local session key falls back to plain name */ }); + var _initDs = getDisplaySettings(); applyDisplaySettings(_initDs); _gridViewMode = loadGridViewMode(); diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index f3628fb..2d2bc58 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -517,8 +517,7 @@ body { } #sidebar-view-dropdown-menu { - left: 0; - top: calc(100% + 2px); + position: fixed; min-width: 180px; } diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index a35ce36..6a79c23 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -3247,3 +3247,431 @@ def test_no_active_filter_device_in_render_grid() -> None: assert "_activeFilterDevice" not in body, ( "renderGrid must not reference _activeFilterDevice (filtered mode removed)" ) + + +# ============================================================ +# Phase 2 COE verification findings +# ============================================================ + + +# ─── Fix 1: CSS/JS class name mismatch (BEM) ───────────────────────────────── + + +def test_render_view_dropdown_uses_bem_item_class() -> None: + """renderViewDropdown must use BEM class 'view-dropdown__item' (not 'view-dropdown-item').""" + match = re.search( + r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewDropdown function not found" + body = match.group(1) + assert "view-dropdown__item" in body, ( + "renderViewDropdown must use BEM class 'view-dropdown__item'" + ) + + +def test_render_view_dropdown_no_single_hyphen_item_class() -> None: + """renderViewDropdown must NOT use single-hyphen 'view-dropdown-item'.""" + match = re.search( + r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewDropdown function not found" + body = match.group(1) + # Should not have the old single-hyphen class; allow only '--active' modifiers via '__' + import re as _re + bad_matches = _re.findall(r'"view-dropdown-item(?!--active)', body) + assert not bad_matches, ( + "renderViewDropdown must not use single-hyphen 'view-dropdown-item' class (use BEM 'view-dropdown__item')" + ) + + +def test_render_view_dropdown_uses_bem_separator_class() -> None: + """renderViewDropdown must use BEM class 'view-dropdown__separator'.""" + match = re.search( + r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewDropdown function not found" + body = match.group(1) + assert "view-dropdown__separator" in body, ( + "renderViewDropdown must use BEM class 'view-dropdown__separator'" + ) + + +def test_render_view_dropdown_uses_bem_action_class() -> None: + """renderViewDropdown must use BEM class 'view-dropdown__action'.""" + match = re.search( + r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewDropdown function not found" + body = match.group(1) + assert "view-dropdown__action" in body, ( + "renderViewDropdown must use BEM class 'view-dropdown__action'" + ) + + +def test_render_view_dropdown_uses_bem_count_class() -> None: + """renderViewDropdown must use BEM class 'view-dropdown__count' (not 'view-dropdown-badge').""" + match = re.search( + r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewDropdown function not found" + body = match.group(1) + assert "view-dropdown__count" in body, ( + "renderViewDropdown must use BEM class 'view-dropdown__count' (was 'view-dropdown-badge')" + ) + assert "view-dropdown-badge" not in body, ( + "renderViewDropdown must not use old 'view-dropdown-badge' class" + ) + + +def test_render_sidebar_view_dropdown_uses_bem_item_class() -> None: + """renderSidebarViewDropdown must use BEM class 'view-dropdown__item'.""" + match = re.search( + r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderSidebarViewDropdown function not found" + body = match.group(1) + assert "view-dropdown__item" in body, ( + "renderSidebarViewDropdown must use BEM class 'view-dropdown__item'" + ) + + +def test_render_sidebar_view_dropdown_no_single_hyphen_item_class() -> None: + """renderSidebarViewDropdown must NOT use single-hyphen 'view-dropdown-item'.""" + match = re.search( + r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderSidebarViewDropdown function not found" + body = match.group(1) + import re as _re + bad_matches = _re.findall(r'"view-dropdown-item(?!--active)', body) + assert not bad_matches, ( + "renderSidebarViewDropdown must not use single-hyphen 'view-dropdown-item' class" + ) + + +def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None: + """renderSidebarViewDropdown must use BEM class 'view-dropdown__count'.""" + match = re.search( + r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderSidebarViewDropdown function not found" + body = match.group(1) + assert "view-dropdown__count" in body, ( + "renderSidebarViewDropdown must use BEM class 'view-dropdown__count'" + ) + assert "view-dropdown-badge" not in body, ( + "renderSidebarViewDropdown must not use old 'view-dropdown-badge' class" + ) + + +# ─── Fix 2: role="menuitem" on dropdown buttons ────────────────────────────── + + +def test_render_view_dropdown_buttons_have_role_menuitem() -> None: + """renderViewDropdown must add role='menuitem' to every button it renders.""" + match = re.search( + r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewDropdown function not found" + body = match.group(1) + assert 'role="menuitem"' in body, ( + "renderViewDropdown must include role=\"menuitem\" on buttons — " + "handleGlobalKeydown arrow navigation queries [role='menuitem']" + ) + + +def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None: + """renderSidebarViewDropdown must add role='menuitem' to every button it renders.""" + match = re.search( + r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderSidebarViewDropdown function not found" + body = match.group(1) + assert 'role="menuitem"' in body, ( + "renderSidebarViewDropdown must include role=\"menuitem\" on buttons" + ) + + +# ─── Fix 3: sidebar dropdown uses position:fixed ──────────────────────────── + + +def test_toggle_sidebar_view_dropdown_positions_with_bounding_rect() -> None: + """toggleSidebarViewDropdown must use getBoundingClientRect when opening.""" + match = re.search( + r"function toggleSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "toggleSidebarViewDropdown function not found" + body = match.group(1) + assert "getBoundingClientRect" in body, ( + "toggleSidebarViewDropdown must use getBoundingClientRect() to position " + "the menu when opening — sidebar has overflow:hidden which clips absolute children" + ) + + +# ─── Fix 4: manage-views handler opens settings ────────────────────────────── + + +def test_manage_views_action_opens_settings() -> None: + """bindStaticEventListeners manage-views handler must call openSettings().""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + # The manage-views action must do more than just close the dropdown + # Find the section near 'manage-views' + assert "manage-views" in body, "bindStaticEventListeners must handle manage-views action" + # After manage-views, openSettings must be called + idx = body.find("manage-views") + nearby = body[idx : idx + 200] + assert "openSettings" in nearby, ( + "bindStaticEventListeners manage-views handler must call openSettings()" + ) + + +def test_manage_views_action_switches_to_views_tab() -> None: + """bindStaticEventListeners manage-views handler must call switchSettingsTab('views').""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + idx = body.find("manage-views") + assert idx >= 0, "manage-views action not found in bindStaticEventListeners" + nearby = body[idx : idx + 300] + assert "switchSettingsTab" in nearby, ( + "bindStaticEventListeners manage-views handler must call switchSettingsTab" + ) + assert "'views'" in nearby or '"views"' in nearby, ( + "bindStaticEventListeners manage-views handler must switch to 'views' tab" + ) + + +# ─── Fix 5: active_view persisted on delete/rename ─────────────────────────── + + +def test_delete_active_view_persists_active_view_to_server() -> None: + """When deleting the active view in renderViewsSettingsTab, PATCH /api/state with active_view.""" + match = re.search( + r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewsSettingsTab function not found" + body = match.group(1) + # Find the section that sets _activeView = 'all' (the delete fallback path) + fallback_idx = body.find("_activeView = 'all'") + assert fallback_idx >= 0, "_activeView = 'all' fallback not found in renderViewsSettingsTab" + # Within ~200 chars after the fallback, there must be an api( call for persistence + nearby = body[fallback_idx : fallback_idx + 200] + assert "api(" in nearby, ( + "renderViewsSettingsTab delete path must call api() immediately after setting _activeView = 'all'" + ) + assert "active_view" in nearby, ( + "renderViewsSettingsTab delete path must PATCH /api/state with active_view" + ) + + +def test_rename_active_view_persists_active_view_to_server() -> None: + """When renaming the active view in renderViewsSettingsTab, PATCH /api/state with active_view.""" + match = re.search( + r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewsSettingsTab function not found" + body = match.group(1) + # Find the commitRename block or rename path + rename_idx = body.find("commitRename") + assert rename_idx >= 0, "commitRename not found in renderViewsSettingsTab" + rename_block = body[rename_idx:] + assert "api(" in rename_block, ( + "renderViewsSettingsTab rename path must call api() to persist active_view" + ) + assert "active_view" in rename_block, ( + "renderViewsSettingsTab rename path must PATCH /api/state with active_view" + ) + + +# ─── Fix 6: click-outside handler for sidebar dropdown ─────────────────────── + + +def test_bind_static_event_listeners_has_sidebar_dropdown_click_outside() -> None: + """bindStaticEventListeners must have a click-outside handler for sidebar-view-dropdown-menu.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + # There must be a document click listener that references the sidebar dropdown menu + # and closes it when clicking outside + assert "sidebar-view-dropdown-menu" in body, ( + "bindStaticEventListeners must reference sidebar-view-dropdown-menu" + ) + # The click-outside pattern: document.addEventListener click that checks sidebar dropdown + # We verify that sidebar dropdown is handled in a click listener beyond the direct click handler + click_count = body.count("document.addEventListener('click'") + assert click_count >= 2, ( + "bindStaticEventListeners must have at least 2 document click listeners: " + "one for header dropdown click-outside and one for sidebar dropdown click-outside" + ) + + +# ─── Fix 7: case-insensitive reserved name check ───────────────────────────── + + +def test_show_new_view_input_reserved_name_check_is_case_insensitive() -> None: + """showNewViewInput reserved name check must use toLowerCase().""" + match = re.search( + r"function showNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewViewInput function not found" + body = match.group(1) + # Must use toLowerCase for reserved name check + assert "toLowerCase" in body, ( + "showNewViewInput reserved name check must use toLowerCase() — spec requires case-insensitive check" + ) + + +def test_rename_reserved_name_check_is_case_insensitive() -> None: + """The rename commitRename reserved name check must use toLowerCase().""" + match = re.search( + r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewsSettingsTab function not found" + body = match.group(1) + rename_idx = body.find("commitRename") + assert rename_idx >= 0, "commitRename not found in renderViewsSettingsTab" + rename_block = body[rename_idx:] + assert "toLowerCase" in rename_block, ( + "commitRename reserved name check must use toLowerCase() — spec requires case-insensitive" + ) + + +# ─── Fix 8: rename input maxLength ─────────────────────────────────────────── + + +def test_rename_input_has_max_length() -> None: + """renderViewsSettingsTab rename input must have maxLength = 30.""" + match = re.search( + r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "renderViewsSettingsTab function not found" + body = match.group(1) + rename_idx = body.find("views-settings-rename-input") + assert rename_idx >= 0, "rename input not found in renderViewsSettingsTab" + rename_block = body[rename_idx : rename_idx + 400] + assert "maxLength" in rename_block, ( + "renderViewsSettingsTab rename input must set maxLength = 30 " + "(matches creation input constraint)" + ) + + +# ─── Fix 9: device_id from /api/instance-info ──────────────────────────────── + + +def test_local_device_id_variable_declared() -> None: + """_localDeviceId module-level variable must be declared.""" + assert "_localDeviceId" in _JS, ( + "_localDeviceId must be declared as a module-level variable — " + "used by createNewSession for local session key construction" + ) + + +def test_instance_info_fetched_at_startup() -> None: + """DOMContentLoaded or init code must fetch /api/instance-info to cache device_id.""" + match = re.search( + r"DOMContentLoaded.*?\{(.*?)(?=\}\);\s*\n// |\}\);\s*$)", + _JS, + re.DOTALL, + ) + assert match, "DOMContentLoaded handler not found" + body = match.group(1) + assert "instance-info" in body or "/api/instance-info" in body, ( + "DOMContentLoaded must fetch /api/instance-info to cache device_id in _localDeviceId" + ) + + +def test_create_new_session_uses_local_device_id() -> None: + """createNewSession must use _localDeviceId (not _serverSettings.device_id) for local sessions.""" + match = re.search( + r"async function createNewSession\s*\([\w,\s]+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "createNewSession function not found" + body = match.group(1) + assert "_localDeviceId" in body, ( + "createNewSession must use _localDeviceId — device_id is not in /api/settings response" + ) + assert "_serverSettings.device_id" not in body, ( + "createNewSession must not use _serverSettings.device_id — device_id comes from /api/instance-info" + ) + + +# ─── Fix 10: dead code removal (renderFilterBar and filter bar click handler) ─ + + +def test_render_filter_bar_body_is_empty() -> None: + """renderFilterBar function body must be empty (dead code).""" + match = re.search( + r"function renderFilterBar\s*\(.*?\)\s*\{(.*?)(?=\n\})", + _JS, + re.DOTALL, + ) + assert match, "renderFilterBar function not found" + body = match.group(1).strip() + assert body == "" or body == "// dead code" or body.startswith("//"), ( + "renderFilterBar body must be empty (dead code removed) — " + f"but found content: {body[:80]!r}" + ) + + +def test_bind_static_event_listeners_no_filter_bar_click_handler() -> None: + """bindStaticEventListeners must not contain the dead filter-bar click handler.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + # The filter bar click handler used to set _activeFilterDevice + assert "_activeFilterDevice = pill.dataset.device" not in body, ( + "bindStaticEventListeners must not contain the dead filter-bar click handler " + "that sets _activeFilterDevice" + )