fix: address Phase 2 COE verification findings — dropdown styling, ARIA, sidebar clipping, manage-views wiring, persistence
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
This commit is contained in:
+60
-58
@@ -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 = '<button class="filter-pill' + allActive + '" data-device="all">All</button>';
|
||||
for (var j = 0; j < devices.length; j++) {
|
||||
var active = _activeFilterDevice === devices[j] ? ' filter-pill--active' : '';
|
||||
html += '<button class="filter-pill' + active + '" data-device="' + escapeHtml(devices[j]) + '">' + escapeHtml(devices[j]) + '</button>';
|
||||
}
|
||||
|
||||
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 += '<button class="view-dropdown-item' + allActive + '" data-view="all"><span class="view-dropdown-shortcut">1</span> All Sessions</button>';
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all"><span class="view-dropdown__shortcut">1</span> All Sessions</button>';
|
||||
|
||||
// — User views (shortcuts 2–8)
|
||||
if (views.length > 0) {
|
||||
html += '<div class="view-dropdown-separator"></div>';
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
for (var i = 0; i < views.length && i < 7; i++) {
|
||||
var v = views[i];
|
||||
var vActive = _activeView === v.name ? ' view-dropdown-item--active' : '';
|
||||
html += '<button class="view-dropdown-item' + vActive + '" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown-shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
|
||||
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown__shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
|
||||
}
|
||||
}
|
||||
|
||||
// — Hidden (N) (always last system view, shortcut 9)
|
||||
html += '<div class="view-dropdown-separator"></div>';
|
||||
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown-item--active' : '';
|
||||
html += '<button class="view-dropdown-item' + hiddenActive + '" data-view="hidden"><span class="view-dropdown-shortcut">9</span> Hidden <span class="view-dropdown-badge">' + hiddenCount + '</span></button>';
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden"><span class="view-dropdown__shortcut">9</span> Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||
|
||||
// — Actions
|
||||
html += '<div class="view-dropdown-separator"></div>';
|
||||
html += '<button class="view-dropdown-item view-dropdown-action" data-action="new-view">+ New View</button>';
|
||||
html += '<button class="view-dropdown-item view-dropdown-action" data-action="manage-views">Manage Views\u2026</button>';
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="new-view">+ New View</button>';
|
||||
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-views">Manage Views\u2026</button>';
|
||||
|
||||
menu.innerHTML = html;
|
||||
|
||||
@@ -913,23 +894,23 @@ function renderSidebarViewDropdown() {
|
||||
var html = '';
|
||||
|
||||
// — All Sessions (always first)
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown-item--active' : '';
|
||||
html += '<button class="view-dropdown-item' + allActive + '" data-view="all"><span class="view-dropdown-shortcut">1</span> All Sessions</button>';
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all"><span class="view-dropdown__shortcut">1</span> All Sessions</button>';
|
||||
|
||||
// — User views
|
||||
if (views.length > 0) {
|
||||
html += '<div class="view-dropdown-separator"></div>';
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
for (var i = 0; i < views.length && i < 7; i++) {
|
||||
var v = views[i];
|
||||
var vActive = _activeView === v.name ? ' view-dropdown-item--active' : '';
|
||||
html += '<button class="view-dropdown-item' + vActive + '" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown-shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
|
||||
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown__shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>';
|
||||
}
|
||||
}
|
||||
|
||||
// — Hidden (N) (always last system view)
|
||||
html += '<div class="view-dropdown-separator"></div>';
|
||||
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown-item--active' : '';
|
||||
html += '<button class="view-dropdown-item' + hiddenActive + '" data-view="hidden"><span class="view-dropdown-shortcut">9</span> Hidden <span class="view-dropdown-badge">' + hiddenCount + '</span></button>';
|
||||
html += '<div class="view-dropdown__separator"></div>';
|
||||
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden"><span class="view-dropdown__shortcut">9</span> Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
|
||||
|
||||
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();
|
||||
|
||||
@@ -517,8 +517,7 @@ body {
|
||||
}
|
||||
|
||||
#sidebar-view-dropdown-menu {
|
||||
left: 0;
|
||||
top: calc(100% + 2px);
|
||||
position: fixed;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user