8687d72d0b
- Vendored Lit 3 (21KB ESM bundle, no build step required) - New components: toast-notification, connection-status, session-pill, session-tile - Extracted utilities: escapeHtml, formatTimestamp, sessionPriority, ansiToHtml - SVG icons registry for reuse - Updated app.js and index.html to use new components - All 1306 tests pass (incremental migration, vanilla JS still supported)
4757 lines
181 KiB
Python
4757 lines
181 KiB
Python
"""Tests for frontend/app.js — verifies palette code removal and handleGlobalKeydown simplification."""
|
||
|
||
import pathlib
|
||
import re
|
||
|
||
JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "app.js"
|
||
TERMINAL_JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "terminal.js"
|
||
|
||
# Read once per module — tests are read-only so sharing is safe.
|
||
_JS: str = JS_PATH.read_text()
|
||
_TERMINAL_JS: str = TERMINAL_JS_PATH.read_text()
|
||
|
||
|
||
# ── Palette state variables must be removed ──────────────────────────────────
|
||
|
||
|
||
def test_no_palette_max_items_constant() -> None:
|
||
"""PALETTE_MAX_ITEMS constant must be removed."""
|
||
assert "PALETTE_MAX_ITEMS" not in _JS, (
|
||
"PALETTE_MAX_ITEMS must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_palette_selected_index_variable() -> None:
|
||
"""_paletteSelectedIndex variable must be removed."""
|
||
assert "_paletteSelectedIndex" not in _JS, (
|
||
"_paletteSelectedIndex must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_palette_filtered_sessions_variable() -> None:
|
||
"""_paletteFilteredSessions variable must be removed."""
|
||
assert "_paletteFilteredSessions" not in _JS, (
|
||
"_paletteFilteredSessions must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_palette_open_variable() -> None:
|
||
"""_paletteOpen variable must be removed."""
|
||
assert "_paletteOpen" not in _JS, "_paletteOpen must be removed from app.js"
|
||
|
||
|
||
def test_no_palette_input_listener_variable() -> None:
|
||
"""_paletteInputListener variable must be removed."""
|
||
assert "_paletteInputListener" not in _JS, (
|
||
"_paletteInputListener must be removed from app.js"
|
||
)
|
||
|
||
|
||
# ── Palette functions must be removed ────────────────────────────────────────
|
||
|
||
|
||
def test_no_render_palette_list_function() -> None:
|
||
"""renderPaletteList function must be removed."""
|
||
assert "renderPaletteList" not in _JS, (
|
||
"renderPaletteList must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_highlight_palette_item_function() -> None:
|
||
"""highlightPaletteItem function must be removed."""
|
||
assert "highlightPaletteItem" not in _JS, (
|
||
"highlightPaletteItem must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_open_palette_function() -> None:
|
||
"""openPalette function must be removed."""
|
||
assert "openPalette" not in _JS, "openPalette must be removed from app.js"
|
||
|
||
|
||
def test_no_close_palette_function() -> None:
|
||
"""closePalette function must be removed."""
|
||
assert "closePalette" not in _JS, "closePalette must be removed from app.js"
|
||
|
||
|
||
def test_no_on_palette_input_function() -> None:
|
||
"""onPaletteInput function must be removed."""
|
||
assert "onPaletteInput" not in _JS, "onPaletteInput must be removed from app.js"
|
||
|
||
|
||
def test_no_handle_palette_keydown_function() -> None:
|
||
"""handlePaletteKeydown function must be removed."""
|
||
assert "handlePaletteKeydown" not in _JS, (
|
||
"handlePaletteKeydown must be removed from app.js"
|
||
)
|
||
|
||
|
||
# ── handleGlobalKeydown must be simplified ───────────────────────────────────
|
||
|
||
|
||
def test_handle_global_keydown_exists() -> None:
|
||
"""handleGlobalKeydown function must exist."""
|
||
assert "function handleGlobalKeydown" in _JS, (
|
||
"handleGlobalKeydown must still exist in app.js"
|
||
)
|
||
|
||
|
||
def test_handle_global_keydown_no_palette_open_check() -> None:
|
||
"""handleGlobalKeydown must not check _paletteOpen."""
|
||
# Extract the function body
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
assert "_paletteOpen" not in body, (
|
||
"handleGlobalKeydown must not reference _paletteOpen"
|
||
)
|
||
|
||
|
||
def test_handle_global_keydown_no_open_palette_call() -> None:
|
||
"""handleGlobalKeydown must not call openPalette."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
assert "openPalette" not in body, "handleGlobalKeydown must not call openPalette"
|
||
|
||
|
||
def test_handle_global_keydown_handles_escape_in_fullscreen() -> None:
|
||
"""handleGlobalKeydown must call closeSession() on Escape in fullscreen mode."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
assert "fullscreen" in body, "handleGlobalKeydown must check for fullscreen mode"
|
||
assert "Escape" in body, "handleGlobalKeydown must handle Escape key"
|
||
assert "closeSession" in body, "handleGlobalKeydown must call closeSession"
|
||
|
||
|
||
# ── bindStaticEventListeners must have no palette references ─────────────────
|
||
|
||
|
||
def test_bind_static_event_listeners_no_palette_trigger() -> None:
|
||
"""bindStaticEventListeners must not bind palette-trigger click."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "palette-trigger" not in body, (
|
||
"bindStaticEventListeners must not bind palette-trigger click"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_no_palette_backdrop() -> None:
|
||
"""bindStaticEventListeners must not bind palette-backdrop click."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "palette-backdrop" not in body, (
|
||
"bindStaticEventListeners must not bind palette-backdrop click"
|
||
)
|
||
|
||
|
||
# ── Palette test-only helpers must be removed ─────────────────────────────────
|
||
|
||
|
||
def test_no_set_palette_filtered_sessions_helper() -> None:
|
||
"""_setPaletteFilteredSessions test helper must be removed."""
|
||
assert "_setPaletteFilteredSessions" not in _JS, (
|
||
"_setPaletteFilteredSessions must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_get_palette_filtered_sessions_helper() -> None:
|
||
"""_getPaletteFilteredSessions test helper must be removed."""
|
||
assert "_getPaletteFilteredSessions" not in _JS, (
|
||
"_getPaletteFilteredSessions must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_set_palette_selected_index_helper() -> None:
|
||
"""_setPaletteSelectedIndex test helper must be removed."""
|
||
assert "_setPaletteSelectedIndex" not in _JS, (
|
||
"_setPaletteSelectedIndex must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_get_palette_selected_index_helper() -> None:
|
||
"""_getPaletteSelectedIndex test helper must be removed."""
|
||
assert "_getPaletteSelectedIndex" not in _JS, (
|
||
"_getPaletteSelectedIndex must be removed from app.js"
|
||
)
|
||
|
||
|
||
def test_no_set_palette_open_helper() -> None:
|
||
"""_setPaletteOpen test helper must be removed."""
|
||
assert "_setPaletteOpen" not in _JS, "_setPaletteOpen must be removed from app.js"
|
||
|
||
|
||
def test_no_is_palette_open_helper() -> None:
|
||
"""_isPaletteOpen test helper must be removed."""
|
||
assert "_isPaletteOpen" not in _JS, "_isPaletteOpen must be removed from app.js"
|
||
|
||
|
||
# ── module.exports must not include palette exports ───────────────────────────
|
||
|
||
|
||
def test_exports_no_render_palette_list() -> None:
|
||
"""module.exports must not export renderPaletteList."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "renderPaletteList" not in exports, (
|
||
"module.exports must not export renderPaletteList"
|
||
)
|
||
|
||
|
||
def test_exports_no_highlight_palette_item() -> None:
|
||
"""module.exports must not export highlightPaletteItem."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "highlightPaletteItem" not in exports, (
|
||
"module.exports must not export highlightPaletteItem"
|
||
)
|
||
|
||
|
||
def test_exports_no_open_palette() -> None:
|
||
"""module.exports must not export openPalette."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "openPalette" not in exports, "module.exports must not export openPalette"
|
||
|
||
|
||
def test_exports_no_close_palette() -> None:
|
||
"""module.exports must not export closePalette."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "closePalette" not in exports, "module.exports must not export closePalette"
|
||
|
||
|
||
def test_exports_no_on_palette_input() -> None:
|
||
"""module.exports must not export onPaletteInput."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "onPaletteInput" not in exports, (
|
||
"module.exports must not export onPaletteInput"
|
||
)
|
||
|
||
|
||
def test_exports_no_handle_palette_keydown() -> None:
|
||
"""module.exports must not export handlePaletteKeydown."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "handlePaletteKeydown" not in exports, (
|
||
"module.exports must not export handlePaletteKeydown"
|
||
)
|
||
|
||
|
||
def test_exports_still_has_handle_global_keydown() -> None:
|
||
"""module.exports must still export handleGlobalKeydown."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "handleGlobalKeydown" in exports, (
|
||
"module.exports must still export handleGlobalKeydown"
|
||
)
|
||
|
||
|
||
def test_exports_still_has_bind_static_event_listeners() -> None:
|
||
"""module.exports must still export bindStaticEventListeners."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "bindStaticEventListeners" in exports, (
|
||
"module.exports must still export bindStaticEventListeners"
|
||
)
|
||
|
||
|
||
# ── Settings state variables ─────────────────────────────────────────────────
|
||
|
||
|
||
def test_settings_open_state_variable_exists() -> None:
|
||
"""_settingsOpen state variable must be declared."""
|
||
assert "_settingsOpen" in _JS, "_settingsOpen must be declared in app.js"
|
||
|
||
|
||
def test_get_display_settings_function_exists_via_constant_section() -> None:
|
||
"""getDisplaySettings function must exist (display settings are now server-side)."""
|
||
assert "function getDisplaySettings" in _JS, (
|
||
"getDisplaySettings must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_get_display_settings_reads_server_settings() -> None:
|
||
"""getDisplaySettings must read from _serverSettings (not localStorage)."""
|
||
match = re.search(
|
||
r"function getDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getDisplaySettings function not found"
|
||
body = match.group(1)
|
||
assert "_serverSettings" in body, (
|
||
"getDisplaySettings must read from _serverSettings"
|
||
)
|
||
assert "localStorage" not in body, (
|
||
"getDisplaySettings must not use localStorage — display settings are server-side"
|
||
)
|
||
|
||
|
||
def test_display_defaults_constant_exists() -> None:
|
||
"""DISPLAY_DEFAULTS constant must be declared."""
|
||
assert "DISPLAY_DEFAULTS" in _JS, "DISPLAY_DEFAULTS must be in app.js"
|
||
|
||
|
||
def test_display_defaults_has_font_size() -> None:
|
||
"""DISPLAY_DEFAULTS must contain fontSize: 14."""
|
||
assert "fontSize" in _JS, "DISPLAY_DEFAULTS must include fontSize"
|
||
assert "14" in _JS, "DISPLAY_DEFAULTS fontSize must be 14"
|
||
|
||
|
||
def test_display_defaults_has_hover_preview_delay() -> None:
|
||
"""DISPLAY_DEFAULTS must contain hoverPreviewDelay: 1500."""
|
||
assert "hoverPreviewDelay" in _JS, "DISPLAY_DEFAULTS must include hoverPreviewDelay"
|
||
|
||
|
||
def test_display_defaults_has_grid_columns() -> None:
|
||
"""DISPLAY_DEFAULTS must contain gridColumns: 'auto'."""
|
||
assert "gridColumns" in _JS, "DISPLAY_DEFAULTS must include gridColumns"
|
||
|
||
|
||
def test_display_defaults_has_bell_sound() -> None:
|
||
"""DISPLAY_DEFAULTS must contain bellSound: false."""
|
||
assert "bellSound" in _JS, "DISPLAY_DEFAULTS must include bellSound"
|
||
|
||
|
||
def test_display_defaults_has_notification_permission() -> None:
|
||
"""DISPLAY_DEFAULTS must contain notificationPermission: 'default'."""
|
||
assert "notificationPermission" in _JS, (
|
||
"DISPLAY_DEFAULTS must include notificationPermission"
|
||
)
|
||
|
||
|
||
# ── Settings functions must exist ─────────────────────────────────────────────
|
||
|
||
|
||
def test_get_display_settings_function_exists() -> None:
|
||
"""getDisplaySettings function must exist."""
|
||
assert "function getDisplaySettings" in _JS, (
|
||
"getDisplaySettings must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_save_display_settings_function_absent() -> None:
|
||
"""saveDisplaySettings must not exist — display writes go via patchServerSetting."""
|
||
assert "function saveDisplaySettings" not in _JS, (
|
||
"saveDisplaySettings must not be defined in app.js — use patchServerSetting instead"
|
||
)
|
||
|
||
|
||
def test_open_settings_function_exists() -> None:
|
||
"""openSettings function must exist."""
|
||
assert "function openSettings" in _JS, "openSettings must be defined in app.js"
|
||
|
||
|
||
def test_close_settings_function_exists() -> None:
|
||
"""closeSettings function must exist."""
|
||
assert "function closeSettings" in _JS, "closeSettings must be defined in app.js"
|
||
|
||
|
||
def test_switch_settings_tab_function_exists() -> None:
|
||
"""switchSettingsTab function must exist."""
|
||
assert "function switchSettingsTab" in _JS, (
|
||
"switchSettingsTab must be defined in app.js"
|
||
)
|
||
|
||
|
||
# ── loadDisplaySettings implementation ───────────────────────────────────────
|
||
|
||
|
||
def test_get_display_settings_reads_from_server_settings() -> None:
|
||
"""getDisplaySettings must read from _serverSettings, not localStorage."""
|
||
match = re.search(
|
||
r"function getDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getDisplaySettings function not found"
|
||
body = match.group(1)
|
||
assert "_serverSettings" in body, (
|
||
"getDisplaySettings must read from _serverSettings"
|
||
)
|
||
assert "localStorage" not in body, (
|
||
"getDisplaySettings must not use localStorage — display settings are server-side"
|
||
)
|
||
|
||
|
||
def test_get_display_settings_uses_object_assign() -> None:
|
||
"""getDisplaySettings must merge with DISPLAY_DEFAULTS via Object.assign."""
|
||
match = re.search(
|
||
r"function getDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getDisplaySettings function not found"
|
||
body = match.group(1)
|
||
assert "Object.assign" in body, (
|
||
"getDisplaySettings must use Object.assign to merge with defaults"
|
||
)
|
||
|
||
|
||
def test_get_display_settings_uses_display_defaults() -> None:
|
||
"""getDisplaySettings must use DISPLAY_DEFAULTS as fallback for missing keys."""
|
||
match = re.search(
|
||
r"function getDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getDisplaySettings function not found"
|
||
body = match.group(1)
|
||
assert "DISPLAY_DEFAULTS" in body, (
|
||
"getDisplaySettings must use DISPLAY_DEFAULTS as fallback"
|
||
)
|
||
|
||
|
||
# ── saveDisplaySettings implementation ───────────────────────────────────────
|
||
|
||
|
||
def test_on_display_setting_change_uses_api_patch() -> None:
|
||
"""onDisplaySettingChange must write display settings via API PATCH, not localStorage."""
|
||
match = re.search(
|
||
r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "onDisplaySettingChange function not found"
|
||
body = match.group(1)
|
||
assert "api('PATCH'" in body or 'api("PATCH"' in body, (
|
||
"onDisplaySettingChange must write display settings via api('PATCH', '/api/settings', patch)"
|
||
)
|
||
assert "localStorage" not in body, (
|
||
"onDisplaySettingChange must not use localStorage for display settings"
|
||
)
|
||
|
||
|
||
def test_on_display_setting_change_catches_errors() -> None:
|
||
"""onDisplaySettingChange must handle API errors gracefully via .catch()."""
|
||
match = re.search(
|
||
r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "onDisplaySettingChange function not found"
|
||
body = match.group(1)
|
||
assert ".catch" in body, "onDisplaySettingChange must handle errors via .catch()"
|
||
|
||
|
||
# ── openSettings implementation ───────────────────────────────────────────────
|
||
|
||
|
||
def test_open_settings_sets_settings_open_true() -> None:
|
||
"""openSettings must set _settingsOpen = true."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "_settingsOpen" in body and "true" in body, (
|
||
"openSettings must set _settingsOpen = true"
|
||
)
|
||
|
||
|
||
def test_open_settings_calls_show_modal() -> None:
|
||
"""openSettings must call dialog.showModal()."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "showModal" in body, "openSettings must call dialog.showModal()"
|
||
|
||
|
||
def test_open_settings_removes_hidden_from_backdrop() -> None:
|
||
"""openSettings must remove hidden class from backdrop."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "settings-backdrop" in body, "openSettings must reference settings-backdrop"
|
||
assert "remove" in body, "openSettings must call remove (hidden class removal)"
|
||
|
||
|
||
def test_open_settings_loads_form_controls() -> None:
|
||
"""openSettings must load display settings into form controls."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "setting-font-size" in body, (
|
||
"openSettings must set setting-font-size form control"
|
||
)
|
||
assert "setting-hover-delay" in body, (
|
||
"openSettings must set setting-hover-delay form control"
|
||
)
|
||
assert "setting-grid-columns" in body, (
|
||
"openSettings must set setting-grid-columns form control"
|
||
)
|
||
|
||
|
||
# ── closeSettings implementation ──────────────────────────────────────────────
|
||
|
||
|
||
def test_close_settings_sets_settings_open_false() -> None:
|
||
"""closeSettings must set _settingsOpen = false."""
|
||
match = re.search(
|
||
r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "closeSettings function not found"
|
||
body = match.group(1)
|
||
assert "_settingsOpen" in body and "false" in body, (
|
||
"closeSettings must set _settingsOpen = false"
|
||
)
|
||
|
||
|
||
def test_close_settings_calls_dialog_close() -> None:
|
||
"""closeSettings must call dialog.close()."""
|
||
match = re.search(
|
||
r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "closeSettings function not found"
|
||
body = match.group(1)
|
||
assert ".close()" in body, "closeSettings must call dialog.close()"
|
||
|
||
|
||
def test_close_settings_adds_hidden_to_backdrop() -> None:
|
||
"""closeSettings must add hidden class to backdrop."""
|
||
match = re.search(
|
||
r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "closeSettings function not found"
|
||
body = match.group(1)
|
||
assert "settings-backdrop" in body, "closeSettings must reference settings-backdrop"
|
||
assert "add" in body, "closeSettings must call add (hidden class addition)"
|
||
|
||
|
||
# ── switchSettingsTab implementation ──────────────────────────────────────────
|
||
|
||
|
||
def test_switch_settings_tab_has_tab_name_param() -> None:
|
||
"""switchSettingsTab must accept tabName parameter."""
|
||
assert "function switchSettingsTab" in _JS, "switchSettingsTab must be defined"
|
||
match = re.search(
|
||
r"function switchSettingsTab\s*\((\w+)\)",
|
||
_JS,
|
||
)
|
||
assert match, "switchSettingsTab must have a parameter"
|
||
|
||
|
||
def test_switch_settings_tab_toggles_active_class() -> None:
|
||
"""switchSettingsTab must toggle settings-tab--active class."""
|
||
match = re.search(
|
||
r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "switchSettingsTab function not found"
|
||
body = match.group(1)
|
||
assert "settings-tab--active" in body, (
|
||
"switchSettingsTab must toggle settings-tab--active class"
|
||
)
|
||
|
||
|
||
def test_switch_settings_tab_toggles_aria_selected() -> None:
|
||
"""switchSettingsTab must toggle aria-selected on tab buttons."""
|
||
match = re.search(
|
||
r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "switchSettingsTab function not found"
|
||
body = match.group(1)
|
||
assert "aria-selected" in body, "switchSettingsTab must toggle aria-selected"
|
||
|
||
|
||
def test_switch_settings_tab_toggles_panel_hidden() -> None:
|
||
"""switchSettingsTab must toggle hidden class on settings-panel elements."""
|
||
match = re.search(
|
||
r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "switchSettingsTab function not found"
|
||
body = match.group(1)
|
||
assert "settings-panel" in body or "data-tab" in body, (
|
||
"switchSettingsTab must handle settings-panel elements via data-tab"
|
||
)
|
||
|
||
|
||
# ── handleGlobalKeydown settings integration ─────────────────────────────────
|
||
|
||
|
||
def test_handle_global_keydown_checks_settings_open() -> None:
|
||
"""handleGlobalKeydown must check _settingsOpen."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
assert "_settingsOpen" in body, "handleGlobalKeydown must check _settingsOpen"
|
||
|
||
|
||
def test_handle_global_keydown_calls_close_settings_on_escape() -> None:
|
||
"""handleGlobalKeydown must call closeSettings on Escape when settings open."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
assert "closeSettings" in body, "handleGlobalKeydown must call closeSettings"
|
||
|
||
|
||
def test_handle_global_keydown_opens_settings_on_comma() -> None:
|
||
"""handleGlobalKeydown must open settings when comma pressed."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
assert "openSettings" in body, (
|
||
"handleGlobalKeydown must call openSettings for comma key"
|
||
)
|
||
assert "," in body or "comma" in body.lower(), (
|
||
"handleGlobalKeydown must check for comma key"
|
||
)
|
||
|
||
|
||
def test_handle_global_keydown_comma_guards_input_elements() -> None:
|
||
"""handleGlobalKeydown comma shortcut must not fire in input/textarea/select."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
# Must guard against inputs
|
||
has_input_guard = (
|
||
"INPUT" in body
|
||
or "input" in body.lower()
|
||
or "textarea" in body.lower()
|
||
or "select" in body.lower()
|
||
or "tagName" in body
|
||
)
|
||
assert has_input_guard, (
|
||
"handleGlobalKeydown comma shortcut must guard against input/textarea/select"
|
||
)
|
||
|
||
|
||
# ── bindStaticEventListeners settings wiring ─────────────────────────────────
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_settings_btn() -> None:
|
||
"""bindStaticEventListeners must bind settings-btn click to openSettings."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "settings-btn" in body, (
|
||
"bindStaticEventListeners must bind settings-btn click"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_settings_btn_expanded() -> None:
|
||
"""bindStaticEventListeners must bind settings-btn-expanded click to openSettings."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "settings-btn-expanded" in body, (
|
||
"bindStaticEventListeners must bind settings-btn-expanded click"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_settings_backdrop() -> None:
|
||
"""bindStaticEventListeners must bind settings-backdrop click to closeSettings."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "settings-backdrop" in body, (
|
||
"bindStaticEventListeners must bind settings-backdrop click"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_dialog_cancel() -> None:
|
||
"""bindStaticEventListeners must bind dialog cancel event to closeSettings."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "cancel" in body, "bindStaticEventListeners must bind dialog cancel event"
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_settings_tabs() -> None:
|
||
"""bindStaticEventListeners must bind .settings-tab click to switchSettingsTab."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "settings-tab" in body, (
|
||
"bindStaticEventListeners must bind .settings-tab click"
|
||
)
|
||
assert "switchSettingsTab" in body, (
|
||
"bindStaticEventListeners must call switchSettingsTab"
|
||
)
|
||
|
||
|
||
# ── module.exports for new settings functions ────────────────────────────────
|
||
|
||
|
||
def test_exports_get_display_settings() -> None:
|
||
"""module.exports must export getDisplaySettings."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "getDisplaySettings" in exports, (
|
||
"module.exports must export getDisplaySettings"
|
||
)
|
||
|
||
|
||
def test_exports_no_save_display_settings() -> None:
|
||
"""module.exports must NOT export saveDisplaySettings (removed in server-settings migration)."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "saveDisplaySettings" not in exports, (
|
||
"module.exports must not export saveDisplaySettings — use patchServerSetting instead"
|
||
)
|
||
|
||
|
||
def test_exports_open_settings() -> None:
|
||
"""module.exports must export openSettings."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "openSettings" in exports, "module.exports must export openSettings"
|
||
|
||
|
||
def test_exports_close_settings() -> None:
|
||
"""module.exports must export closeSettings."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "closeSettings" in exports, "module.exports must export closeSettings"
|
||
|
||
|
||
def test_exports_switch_settings_tab() -> None:
|
||
"""module.exports must export switchSettingsTab."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "switchSettingsTab" in exports, (
|
||
"module.exports must export switchSettingsTab"
|
||
)
|
||
|
||
|
||
# ── Display tab wiring (task-8) ────────────────────────────────────────────────
|
||
|
||
|
||
def test_apply_display_settings_function_exists() -> None:
|
||
"""applyDisplaySettings function must exist."""
|
||
assert "function applyDisplaySettings" in _JS, (
|
||
"applyDisplaySettings must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_apply_display_settings_sets_font_size_css_property() -> None:
|
||
"""applyDisplaySettings must set --preview-font-size CSS custom property."""
|
||
match = re.search(
|
||
r"function applyDisplaySettings\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "applyDisplaySettings function not found"
|
||
body = match.group(1)
|
||
assert "--preview-font-size" in body, (
|
||
"applyDisplaySettings must set --preview-font-size CSS property"
|
||
)
|
||
assert "documentElement" in body, (
|
||
"applyDisplaySettings must call setProperty on document.documentElement"
|
||
)
|
||
|
||
|
||
def test_apply_display_settings_sets_grid_columns_repeat() -> None:
|
||
"""applyDisplaySettings must set repeat(N, 1fr) for numeric grid columns."""
|
||
match = re.search(
|
||
r"function applyDisplaySettings\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "applyDisplaySettings function not found"
|
||
body = match.group(1)
|
||
assert "repeat" in body and "1fr" in body, (
|
||
"applyDisplaySettings must set repeat(N, 1fr) for numeric grid columns"
|
||
)
|
||
|
||
|
||
def test_apply_display_settings_handles_auto_grid_columns() -> None:
|
||
"""applyDisplaySettings must handle 'auto' gridColumns by removing inline style."""
|
||
match = re.search(
|
||
r"function applyDisplaySettings\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "applyDisplaySettings function not found"
|
||
body = match.group(1)
|
||
assert "auto" in body, "applyDisplaySettings must handle 'auto' gridColumns"
|
||
assert "session-grid" in body or "gridTemplateColumns" in body, (
|
||
"applyDisplaySettings must update session-grid or gridTemplateColumns"
|
||
)
|
||
|
||
|
||
def test_on_display_setting_change_function_exists() -> None:
|
||
"""onDisplaySettingChange function must exist."""
|
||
assert "function onDisplaySettingChange" in _JS, (
|
||
"onDisplaySettingChange must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_on_display_setting_change_reads_font_size() -> None:
|
||
"""onDisplaySettingChange must read from setting-font-size element."""
|
||
match = re.search(
|
||
r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "onDisplaySettingChange function not found"
|
||
body = match.group(1)
|
||
assert "setting-font-size" in body, (
|
||
"onDisplaySettingChange must read from setting-font-size"
|
||
)
|
||
|
||
|
||
def test_on_display_setting_change_reads_hover_delay() -> None:
|
||
"""onDisplaySettingChange must read from setting-hover-delay element."""
|
||
match = re.search(
|
||
r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "onDisplaySettingChange function not found"
|
||
body = match.group(1)
|
||
assert "setting-hover-delay" in body, (
|
||
"onDisplaySettingChange must read from setting-hover-delay"
|
||
)
|
||
|
||
|
||
def test_on_display_setting_change_reads_grid_columns() -> None:
|
||
"""onDisplaySettingChange must read from setting-grid-columns element."""
|
||
match = re.search(
|
||
r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "onDisplaySettingChange function not found"
|
||
body = match.group(1)
|
||
assert "setting-grid-columns" in body, (
|
||
"onDisplaySettingChange must read from setting-grid-columns"
|
||
)
|
||
|
||
|
||
def test_on_display_setting_change_calls_save_display_settings() -> None:
|
||
"""onDisplaySettingChange must save display settings via api('PATCH', '/api/settings', ...)."""
|
||
match = re.search(
|
||
r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "onDisplaySettingChange function not found"
|
||
body = match.group(1)
|
||
assert "api('PATCH'" in body or 'api("PATCH"' in body, (
|
||
"onDisplaySettingChange must save display settings via api('PATCH', '/api/settings', patch)"
|
||
)
|
||
|
||
|
||
def test_on_display_setting_change_calls_apply_display_settings() -> None:
|
||
"""onDisplaySettingChange must call applyDisplaySettings."""
|
||
match = re.search(
|
||
r"function onDisplaySettingChange\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "onDisplaySettingChange function not found"
|
||
body = match.group(1)
|
||
assert "applyDisplaySettings" in body, (
|
||
"onDisplaySettingChange must call applyDisplaySettings"
|
||
)
|
||
|
||
|
||
def test_hover_preview_no_hardcoded_1500() -> None:
|
||
"""Hover preview delays must not be hardcoded 1500ms — must use loadDisplaySettings."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
# Count occurrences of hardcoded 1500 in the body
|
||
hardcoded_count = body.count(", 1500)")
|
||
assert hardcoded_count == 0, (
|
||
f"Hover preview must not use hardcoded 1500ms delay (found {hardcoded_count} occurrences). "
|
||
"Use loadDisplaySettings().hoverPreviewDelay instead."
|
||
)
|
||
|
||
|
||
def test_hover_preview_reads_delay_from_settings() -> None:
|
||
"""Hover preview must read delay from getDisplaySettings().hoverPreviewDelay."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "getDisplaySettings" in body and "hoverPreviewDelay" in body, (
|
||
"bindStaticEventListeners hover preview must read delay from getDisplaySettings().hoverPreviewDelay"
|
||
)
|
||
|
||
|
||
def test_hover_preview_skips_when_delay_zero() -> None:
|
||
"""Hover preview must check delay > 0 before setting setTimeout."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
# The body must have a conditional guard on the delay value
|
||
assert "delay > 0" in body or "> 0" in body, (
|
||
"bindStaticEventListeners hover preview must guard: if (delay > 0) before setTimeout"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_font_size_change() -> None:
|
||
"""bindStaticEventListeners must bind change event on setting-font-size."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-font-size" in body, (
|
||
"bindStaticEventListeners must reference setting-font-size for change binding"
|
||
)
|
||
assert "onDisplaySettingChange" in body, (
|
||
"bindStaticEventListeners must call onDisplaySettingChange on change"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_hover_delay_change() -> None:
|
||
"""bindStaticEventListeners must bind change event on setting-hover-delay."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-hover-delay" in body, (
|
||
"bindStaticEventListeners must reference setting-hover-delay for change binding"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_grid_columns_change() -> None:
|
||
"""bindStaticEventListeners must bind change event on setting-grid-columns."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-grid-columns" in body, (
|
||
"bindStaticEventListeners must reference setting-grid-columns for change binding"
|
||
)
|
||
|
||
|
||
def test_dom_content_loaded_calls_apply_display_settings() -> None:
|
||
"""DOMContentLoaded handler must call applyDisplaySettings(getDisplaySettings())."""
|
||
# Find the DOMContentLoaded handler block
|
||
match = re.search(
|
||
r"DOMContentLoaded.*?\{(.*?)(?=\}\);?\s*\n// |\}\);\s*$)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "DOMContentLoaded handler not found"
|
||
body = match.group(1)
|
||
assert "applyDisplaySettings" in body, (
|
||
"DOMContentLoaded handler must call applyDisplaySettings(getDisplaySettings())"
|
||
)
|
||
assert "getDisplaySettings" in body, (
|
||
"DOMContentLoaded handler must call applyDisplaySettings(getDisplaySettings())"
|
||
)
|
||
|
||
|
||
def test_exports_apply_display_settings() -> None:
|
||
"""module.exports must export applyDisplaySettings."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "applyDisplaySettings" in exports, (
|
||
"module.exports must export applyDisplaySettings"
|
||
)
|
||
|
||
|
||
def test_exports_on_display_setting_change() -> None:
|
||
"""module.exports must export onDisplaySettingChange."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "onDisplaySettingChange" in exports, (
|
||
"module.exports must export onDisplaySettingChange"
|
||
)
|
||
|
||
|
||
# ─── Server settings functions (task-1-sessions-tab) ─────────────────────────
|
||
|
||
|
||
def test_load_server_settings_function_exists() -> None:
|
||
"""loadServerSettings function must exist."""
|
||
assert "function loadServerSettings" in _JS, (
|
||
"loadServerSettings must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_load_server_settings_fetches_api_settings() -> None:
|
||
"""loadServerSettings must fetch GET /api/settings."""
|
||
match = re.search(
|
||
r"async function loadServerSettings\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "loadServerSettings function not found"
|
||
body = match.group(1)
|
||
assert "/api/settings" in body, "loadServerSettings must fetch /api/settings"
|
||
assert "GET" in body, "loadServerSettings must use GET method"
|
||
|
||
|
||
def test_load_server_settings_caches_in_server_settings() -> None:
|
||
"""loadServerSettings must cache result in _serverSettings."""
|
||
assert "_serverSettings" in _JS, "_serverSettings variable must exist in app.js"
|
||
match = re.search(
|
||
r"async function loadServerSettings\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "loadServerSettings function not found"
|
||
body = match.group(1)
|
||
assert "_serverSettings" in body, (
|
||
"loadServerSettings must cache the result in _serverSettings"
|
||
)
|
||
|
||
|
||
def test_patch_server_setting_function_exists() -> None:
|
||
"""patchServerSetting function must exist."""
|
||
assert "function patchServerSetting" in _JS, (
|
||
"patchServerSetting must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_patch_server_setting_sends_patch_request() -> None:
|
||
"""patchServerSetting must send PATCH to /api/settings."""
|
||
match = re.search(
|
||
r"async function patchServerSetting\s*\(.*?\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "patchServerSetting function not found"
|
||
body = match.group(1)
|
||
assert "/api/settings" in body, "patchServerSetting must send to /api/settings"
|
||
assert "PATCH" in body, "patchServerSetting must use PATCH method"
|
||
|
||
|
||
def test_patch_server_setting_shows_toast() -> None:
|
||
"""patchServerSetting must call showToast."""
|
||
match = re.search(
|
||
r"async function patchServerSetting\s*\(.*?\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "patchServerSetting function not found"
|
||
body = match.group(1)
|
||
assert "showToast" in body, "patchServerSetting must call showToast"
|
||
|
||
|
||
def test_open_settings_calls_load_server_settings() -> None:
|
||
"""openSettings must call loadServerSettings to populate sessions tab."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "loadServerSettings" in body, "openSettings must call loadServerSettings"
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_default_session_change() -> None:
|
||
"""bindStaticEventListeners must bind change on setting-default-session."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-default-session" in body, (
|
||
"bindStaticEventListeners must bind setting-default-session change"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_sort_order_change() -> None:
|
||
"""bindStaticEventListeners must bind change on setting-sort-order."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-sort-order" in body, (
|
||
"bindStaticEventListeners must bind setting-sort-order change"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_window_size_largest_change() -> None:
|
||
"""bindStaticEventListeners must bind change on setting-window-size-largest."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-window-size-largest" in body, (
|
||
"bindStaticEventListeners must bind setting-window-size-largest change"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_auto_open_change() -> None:
|
||
"""bindStaticEventListeners must bind change on setting-auto-open."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-auto-open" in body, (
|
||
"bindStaticEventListeners must bind setting-auto-open change"
|
||
)
|
||
|
||
|
||
def test_exports_load_server_settings() -> None:
|
||
"""module.exports must export loadServerSettings."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "loadServerSettings" in exports, (
|
||
"module.exports must export loadServerSettings"
|
||
)
|
||
|
||
|
||
def test_exports_patch_server_setting() -> None:
|
||
"""module.exports must export patchServerSetting."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "patchServerSetting" in exports, (
|
||
"module.exports must export patchServerSetting"
|
||
)
|
||
|
||
|
||
# ─── Notifications tab (task-2-notifications-tab) ─────────────────────────────
|
||
|
||
|
||
def test_open_settings_populates_bell_sound() -> None:
|
||
"""openSettings must set setting-bell-sound checkbox from loadDisplaySettings().bellSound."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "setting-bell-sound" in body, (
|
||
"openSettings must reference setting-bell-sound to set bell sound checkbox"
|
||
)
|
||
assert "bellSound" in body, "openSettings must read bellSound from display settings"
|
||
|
||
|
||
def test_open_settings_updates_notification_status_text() -> None:
|
||
"""openSettings must update notification permission status text and button."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "notification-status-text" in body, (
|
||
"openSettings must reference notification-status-text to update permission status"
|
||
)
|
||
assert "notification-request-btn" in body, (
|
||
"openSettings must reference notification-request-btn to update button state"
|
||
)
|
||
|
||
|
||
def test_open_settings_checks_notification_permission() -> None:
|
||
"""openSettings must check Notification.permission to update UI."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "Notification" in body or "_notificationPermission" in body, (
|
||
"openSettings must check Notification.permission or _notificationPermission"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_bell_sound_change() -> None:
|
||
"""bindStaticEventListeners must bind change on setting-bell-sound to save to localStorage."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-bell-sound" in body, (
|
||
"bindStaticEventListeners must bind setting-bell-sound change event"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_bell_sound_saves_to_display_settings() -> None:
|
||
"""bindStaticEventListeners bell sound change handler must save to display settings."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
# The handler needs to reference saveDisplaySettings and bellSound
|
||
assert "saveDisplaySettings" in body or "bellSound" in body, (
|
||
"bindStaticEventListeners bell sound change handler must save to display settings via saveDisplaySettings or bellSound"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_permission_btn() -> None:
|
||
"""bindStaticEventListeners must bind click on notification-request-btn."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "notification-request-btn" in body, (
|
||
"bindStaticEventListeners must bind notification-request-btn click event"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_permission_btn_calls_request_permission() -> None:
|
||
"""bindStaticEventListeners permission button handler must call Notification.requestPermission()."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "requestPermission" in body, (
|
||
"bindStaticEventListeners permission button must call Notification.requestPermission()"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_permission_btn_has_catch() -> None:
|
||
"""Notification.requestPermission() in permission button handler must have a .catch() handler."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert ".catch(" in body, (
|
||
"Notification.requestPermission() must have a .catch() handler for defensive error handling"
|
||
)
|
||
|
||
|
||
def test_update_notification_ui_has_null_guard() -> None:
|
||
"""_updateNotificationUI must guard against null inputs."""
|
||
match = re.search(
|
||
r"function _updateNotificationUI\s*\(.*?\)\s*\{(.*?)(?=\n\})",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_updateNotificationUI function not found"
|
||
body = match.group(1)
|
||
assert (
|
||
"null" in body or "non-null" in body or "!statusEl" in body or "!reqBtn" in body
|
||
), (
|
||
"_updateNotificationUI must include a null guard (null check) or JSDoc non-null annotation"
|
||
)
|
||
|
||
|
||
# ─── New Session tab (task-3-new-session-tab) ─────────────────────────────────
|
||
|
||
|
||
def test_new_session_default_template_constant_exists() -> None:
|
||
"""NEW_SESSION_DEFAULT_TEMPLATE constant must be declared in app.js."""
|
||
assert "NEW_SESSION_DEFAULT_TEMPLATE" in _JS, (
|
||
"NEW_SESSION_DEFAULT_TEMPLATE constant must be declared in app.js"
|
||
)
|
||
|
||
|
||
def test_new_session_default_template_value() -> None:
|
||
"""NEW_SESSION_DEFAULT_TEMPLATE must equal 'tmux new-session -d -s {name}'."""
|
||
assert "tmux new-session -d -s {name}" in _JS, (
|
||
"NEW_SESSION_DEFAULT_TEMPLATE must be 'tmux new-session -d -s {name}'"
|
||
)
|
||
|
||
|
||
def test_open_settings_populates_template_textarea() -> None:
|
||
"""openSettings must populate #setting-template textarea from server settings."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found"
|
||
body = match.group(1)
|
||
assert "setting-template" in body, (
|
||
"openSettings must reference setting-template textarea"
|
||
)
|
||
assert "new_session_template" in body, (
|
||
"openSettings must populate template from ss.new_session_template"
|
||
)
|
||
assert "NEW_SESSION_DEFAULT_TEMPLATE" in body, (
|
||
"openSettings must fall back to NEW_SESSION_DEFAULT_TEMPLATE"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_template_input_with_debounce() -> None:
|
||
"""bindStaticEventListeners must bind input on #setting-template with 500ms debounce."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-template" in body, (
|
||
"bindStaticEventListeners must bind setting-template input event"
|
||
)
|
||
assert "500" in body, (
|
||
"bindStaticEventListeners template input handler must use 500ms debounce"
|
||
)
|
||
assert "patchServerSetting" in body, (
|
||
"bindStaticEventListeners template input handler must call patchServerSetting"
|
||
)
|
||
assert "new_session_template" in body, (
|
||
"bindStaticEventListeners template input handler must pass 'new_session_template' key"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_template_reset_button() -> None:
|
||
"""bindStaticEventListeners must bind click on #setting-template-reset."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-template-reset" in body, (
|
||
"bindStaticEventListeners must bind setting-template-reset click event"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_reset_patches_server() -> None:
|
||
"""bindStaticEventListeners reset handler must call patchServerSetting with default template."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
# The reset button section must patch with the default template
|
||
assert "setting-template-reset" in body, "setting-template-reset must be referenced"
|
||
assert "NEW_SESSION_DEFAULT_TEMPLATE" in body, (
|
||
"bindStaticEventListeners reset handler must use NEW_SESSION_DEFAULT_TEMPLATE"
|
||
)
|
||
|
||
|
||
# ─── Header + button with inline name input (task-4-header-plus-button) ──────
|
||
|
||
|
||
def test_show_new_session_input_function_exists() -> None:
|
||
"""showNewSessionInput function must exist in app.js."""
|
||
assert "function showNewSessionInput" in _JS, (
|
||
"showNewSessionInput must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_create_new_session_function_exists() -> None:
|
||
"""createNewSession function must exist in app.js."""
|
||
assert "function createNewSession" in _JS, (
|
||
"createNewSession must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_show_new_session_input_creates_input_with_class() -> None:
|
||
"""Input setup must set class 'new-session-input' (via _createSessionInput factory)."""
|
||
match = re.search(
|
||
r"function _createSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_createSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "new-session-input" in body, (
|
||
"_createSessionInput must set input.className = 'new-session-input'"
|
||
)
|
||
|
||
|
||
def test_show_new_session_input_sets_placeholder() -> None:
|
||
"""Input setup must set placeholder containing 'Session name' (via _createSessionInput factory)."""
|
||
match = re.search(
|
||
r"function _createSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_createSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "Session name" in body, (
|
||
"_createSessionInput must set placeholder containing 'Session name'"
|
||
)
|
||
|
||
|
||
def test_show_new_session_input_disables_autocomplete() -> None:
|
||
"""Input setup must set autocomplete off (via _createSessionInput factory)."""
|
||
match = re.search(
|
||
r"function _createSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_createSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "autocomplete" in body.lower() and "off" in body.lower(), (
|
||
"_createSessionInput must set autocomplete off"
|
||
)
|
||
|
||
|
||
def test_show_new_session_input_disables_spellcheck() -> None:
|
||
"""Input setup must set spellcheck false (via _createSessionInput factory)."""
|
||
match = re.search(
|
||
r"function _createSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_createSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "spellcheck" in body.lower() and "false" in body.lower(), (
|
||
"_createSessionInput must set spellcheck false"
|
||
)
|
||
|
||
|
||
def test_show_new_session_input_hides_button() -> None:
|
||
"""showNewSessionInput must hide the button."""
|
||
match = re.search(
|
||
r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showNewSessionInput function not found"
|
||
body = match.group(1)
|
||
# Must hide the button (display none or style.display)
|
||
assert "display" in body or "style.display" in body or "hidden" in body, (
|
||
"showNewSessionInput must hide the button"
|
||
)
|
||
|
||
|
||
def test_show_new_session_input_focuses_input() -> None:
|
||
"""showNewSessionInput must call focus() on the input."""
|
||
match = re.search(
|
||
r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showNewSessionInput function not found"
|
||
body = match.group(1)
|
||
assert ".focus()" in body or "focus()" in body, (
|
||
"showNewSessionInput must call focus() on the input"
|
||
)
|
||
|
||
|
||
def test_show_new_session_input_handles_enter_key() -> None:
|
||
"""showNewSessionInput must handle Enter key to create session."""
|
||
match = re.search(
|
||
r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showNewSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "Enter" in body, "showNewSessionInput must handle Enter key"
|
||
assert "createNewSession" in body, (
|
||
"showNewSessionInput must call createNewSession on Enter"
|
||
)
|
||
|
||
|
||
def test_show_new_session_input_handles_escape_key() -> None:
|
||
"""showNewSessionInput must handle Escape key to cancel."""
|
||
match = re.search(
|
||
r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showNewSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "Escape" in body, "showNewSessionInput must handle Escape key"
|
||
|
||
|
||
def test_show_new_session_input_handles_blur_with_delay() -> None:
|
||
"""showNewSessionInput must handle blur with 150ms delay."""
|
||
match = re.search(
|
||
r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showNewSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "blur" in body, "showNewSessionInput must handle blur event"
|
||
assert "150" in body, "showNewSessionInput must use 150ms delay on blur"
|
||
|
||
|
||
def test_create_new_session_posts_to_api_sessions() -> None:
|
||
"""createNewSession must POST to /api/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 "/api/sessions" in body, "createNewSession must POST to /api/sessions"
|
||
assert "POST" in body, "createNewSession must use POST method"
|
||
|
||
|
||
def test_create_new_session_shows_toast() -> None:
|
||
"""createNewSession must call showToast."""
|
||
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 "showToast" in body, "createNewSession must call showToast"
|
||
|
||
|
||
def test_create_new_session_calls_poll_sessions() -> None:
|
||
"""createNewSession must call pollSessions."""
|
||
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 "pollSessions" in body, "createNewSession must call pollSessions"
|
||
|
||
|
||
def test_create_new_session_auto_opens_session() -> None:
|
||
"""createNewSession must call openSession when auto_open_created is not false."""
|
||
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 "openSession" in body, "createNewSession must call openSession"
|
||
assert "auto_open_created" in body, (
|
||
"createNewSession must check ss.auto_open_created"
|
||
)
|
||
|
||
|
||
def test_create_new_session_polls_before_open() -> None:
|
||
"""createNewSession must poll for the session to appear before calling openSession (not immediate setTimeout)."""
|
||
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)
|
||
# Old immediate pattern must be gone
|
||
assert "setTimeout(() => openSession" not in body, (
|
||
"createNewSession must not use immediate setTimeout(() => openSession) — should poll instead"
|
||
)
|
||
# Polling pattern must be present (recursive setTimeout or setInterval)
|
||
assert "setTimeout" in body or "setInterval" in body, (
|
||
"createNewSession must poll for session readiness (setTimeout or setInterval)"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_new_session_btn() -> None:
|
||
"""bindStaticEventListeners must bind click on new-session-btn to showNewSessionInput."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "new-session-btn" in body, (
|
||
"bindStaticEventListeners must bind new-session-btn click"
|
||
)
|
||
assert "showNewSessionInput" in body, (
|
||
"bindStaticEventListeners must call showNewSessionInput for new-session-btn"
|
||
)
|
||
|
||
|
||
def test_exports_show_new_session_input() -> None:
|
||
"""module.exports must export showNewSessionInput."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "showNewSessionInput" in exports, (
|
||
"module.exports must export showNewSessionInput"
|
||
)
|
||
|
||
|
||
def test_exports_create_new_session() -> None:
|
||
"""module.exports must export createNewSession."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "createNewSession" in exports, "module.exports must export createNewSession"
|
||
|
||
|
||
# ============================================================
|
||
# Sidebar + New sticky footer (task-5-sidebar-new-footer)
|
||
# ============================================================
|
||
|
||
|
||
def test_bind_sidebar_new_session_btn_in_bind_static_event_listeners() -> None:
|
||
"""bindStaticEventListeners must bind #sidebar-new-session-btn click to showNewSessionInput."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "sidebar-new-session-btn" in body, (
|
||
"bindStaticEventListeners must reference 'sidebar-new-session-btn'"
|
||
)
|
||
assert "showNewSessionInput" in body, (
|
||
"bindStaticEventListeners must call showNewSessionInput for the sidebar new-session button"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Mobile FAB (task-6-mobile-fab)
|
||
# ============================================================
|
||
|
||
|
||
def test_js_fab_bound_in_bind_static_event_listeners() -> None:
|
||
"""bindStaticEventListeners must bind #new-session-fab click to showNewSessionInput."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "new-session-fab" in body, (
|
||
"bindStaticEventListeners must reference 'new-session-fab'"
|
||
)
|
||
|
||
|
||
def test_js_open_session_hides_fab() -> None:
|
||
"""openSession must add 'hidden' class to the FAB element."""
|
||
match = re.search(
|
||
r"async function openSession\s*\(.*?\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSession function not found"
|
||
body = match.group(1)
|
||
assert "new-session-fab" in body, (
|
||
"openSession must reference 'new-session-fab' to hide FAB during fullscreen view"
|
||
)
|
||
assert "hidden" in body, (
|
||
"openSession must add 'hidden' class to FAB (classList.add('hidden'))"
|
||
)
|
||
|
||
|
||
def test_js_close_session_restores_fab() -> None:
|
||
"""closeSession must remove 'hidden' class from the FAB element."""
|
||
match = re.search(
|
||
r"function closeSession\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "closeSession function not found"
|
||
body = match.group(1)
|
||
assert "new-session-fab" in body, (
|
||
"closeSession must reference 'new-session-fab' to restore FAB when session closed"
|
||
)
|
||
assert "remove" in body, "closeSession must call classList.remove('hidden') on FAB"
|
||
|
||
|
||
def test_js_fab_exported() -> None:
|
||
"""new-session-fab binding functions must work - showNewSessionInput is exported."""
|
||
assert "showNewSessionInput" in _JS, (
|
||
"showNewSessionInput must exist in app.js (used by FAB click handler)"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Mobile FAB — dedicated fixed-position input (quality fix)
|
||
# ============================================================
|
||
|
||
|
||
def test_js_show_fab_session_input_exists() -> None:
|
||
"""showFabSessionInput function must exist in app.js (dedicated FAB input overlay)."""
|
||
assert "function showFabSessionInput" in _JS, (
|
||
"showFabSessionInput must be defined in app.js — "
|
||
"FAB needs a fixed-position overlay, not inline body insertion"
|
||
)
|
||
|
||
|
||
def test_js_show_fab_session_input_appends_to_body() -> None:
|
||
"""showFabSessionInput must append the overlay to document.body (fixed positioning)."""
|
||
match = re.search(
|
||
r"function showFabSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showFabSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "document.body" in body, (
|
||
"showFabSessionInput must append to document.body "
|
||
"(fixed-position overlays must be direct body children to escape overflow:hidden)"
|
||
)
|
||
|
||
|
||
def test_js_show_fab_session_input_handles_enter() -> None:
|
||
"""showFabSessionInput must call createNewSession on Enter key."""
|
||
match = re.search(
|
||
r"function showFabSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showFabSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "Enter" in body, "showFabSessionInput must handle Enter key"
|
||
assert "createNewSession" in body, (
|
||
"showFabSessionInput must call createNewSession on Enter"
|
||
)
|
||
|
||
|
||
def test_js_show_fab_session_input_handles_escape() -> None:
|
||
"""showFabSessionInput must handle Escape key to cancel."""
|
||
match = re.search(
|
||
r"function showFabSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showFabSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "Escape" in body, "showFabSessionInput must handle Escape key"
|
||
|
||
|
||
def test_js_fab_click_calls_show_fab_session_input() -> None:
|
||
"""bindStaticEventListeners must bind FAB click to showFabSessionInput (not inline body insert)."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "showFabSessionInput" in body, (
|
||
"bindStaticEventListeners must call showFabSessionInput for FAB click — "
|
||
"the old showNewSessionInput(fab) inserts into body which is clipped by overflow:hidden"
|
||
)
|
||
|
||
|
||
def test_js_show_fab_session_input_guards_against_duplicate_overlay() -> None:
|
||
"""showFabSessionInput must guard against creating a second overlay if one already exists."""
|
||
match = re.search(
|
||
r"function showFabSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showFabSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "fab-input-overlay" in body, (
|
||
"showFabSessionInput must check for an existing .fab-input-overlay before creating another — "
|
||
"prevents duplicate overlays if called programmatically while one is already open"
|
||
)
|
||
# Guard must be present: querySelector('.fab-input-overlay') with early return
|
||
assert "querySelector" in body and "return" in body, (
|
||
"showFabSessionInput must guard with document.querySelector('.fab-input-overlay') return; "
|
||
"at the top to prevent duplicate overlays"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# _createSessionInput factory (quality refactor — suggestion 1)
|
||
# ============================================================
|
||
|
||
|
||
def test_js_create_session_input_factory_exists() -> None:
|
||
"""_createSessionInput factory must exist in app.js to eliminate input setup duplication."""
|
||
assert "function _createSessionInput" in _JS, (
|
||
"_createSessionInput must be defined in app.js — "
|
||
"shared factory eliminates duplicated input setup in showNewSessionInput and showFabSessionInput"
|
||
)
|
||
|
||
|
||
def test_js_create_session_input_factory_sets_class() -> None:
|
||
"""_createSessionInput must return an input with class 'new-session-input'."""
|
||
match = re.search(
|
||
r"function _createSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_createSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "new-session-input" in body, (
|
||
"_createSessionInput must set input.className = 'new-session-input'"
|
||
)
|
||
|
||
|
||
def test_js_create_session_input_factory_sets_placeholder() -> None:
|
||
"""_createSessionInput must set placeholder containing 'Session name'."""
|
||
match = re.search(
|
||
r"function _createSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_createSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "Session name" in body, (
|
||
"_createSessionInput must set placeholder containing 'Session name'"
|
||
)
|
||
|
||
|
||
def test_js_create_session_input_factory_disables_autocomplete() -> None:
|
||
"""_createSessionInput must set autocomplete off."""
|
||
match = re.search(
|
||
r"function _createSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_createSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "autocomplete" in body.lower() and "off" in body.lower(), (
|
||
"_createSessionInput must set autocomplete off"
|
||
)
|
||
|
||
|
||
def test_js_create_session_input_factory_disables_spellcheck() -> None:
|
||
"""_createSessionInput must set spellcheck false."""
|
||
match = re.search(
|
||
r"function _createSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_createSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "spellcheck" in body.lower() and "false" in body.lower(), (
|
||
"_createSessionInput must set spellcheck false"
|
||
)
|
||
|
||
|
||
def test_js_show_new_session_input_uses_factory() -> None:
|
||
"""showNewSessionInput must call _createSessionInput() instead of duplicating input setup."""
|
||
match = re.search(
|
||
r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showNewSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "_createSessionInput" in body, (
|
||
"showNewSessionInput must call _createSessionInput() to create the input element"
|
||
)
|
||
|
||
|
||
def test_js_show_fab_session_input_uses_factory() -> None:
|
||
"""showFabSessionInput must call _createSessionInput() instead of duplicating input setup."""
|
||
match = re.search(
|
||
r"function showFabSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showFabSessionInput function not found"
|
||
body = match.group(1)
|
||
assert "_createSessionInput" in body, (
|
||
"showFabSessionInput must call _createSessionInput() to create the input element"
|
||
)
|
||
|
||
|
||
# ─── Task 7: Apply settings effects (task-7-apply-settings-effects) ──────────
|
||
|
||
|
||
# ── terminal.js: createTerminal receives font size as parameter ───────────────
|
||
|
||
|
||
def test_create_terminal_accepts_font_size_parameter() -> None:
|
||
"""createTerminal() must accept a fontSize parameter (from server settings via app.js).
|
||
The old localStorage-based approach is gone since the server-settings migration.
|
||
"""
|
||
match = re.search(
|
||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_TERMINAL_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "createTerminal function not found in terminal.js"
|
||
# Verify the function signature accepts a parameter (not zero-arg)
|
||
sig_match = re.search(r"function createTerminal\s*\(([^)]+)\)", _TERMINAL_JS)
|
||
assert sig_match, "createTerminal must accept a fontSize parameter"
|
||
param = sig_match.group(1).strip()
|
||
assert param, "createTerminal must accept a fontSize parameter (not zero-arg)"
|
||
assert "localStorage" not in match.group(1), (
|
||
"createTerminal must NOT read from localStorage — "
|
||
"fontSize must be passed as a parameter from app.js (getDisplaySettings().fontSize)"
|
||
)
|
||
|
||
|
||
def test_create_terminal_does_not_parse_json_from_localstorage() -> None:
|
||
"""createTerminal() must NOT parse JSON from localStorage (server-settings migration).
|
||
Font size now comes directly from the fontSize parameter passed by app.js.
|
||
"""
|
||
match = re.search(
|
||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_TERMINAL_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "createTerminal function not found in terminal.js"
|
||
body = match.group(1)
|
||
assert "JSON.parse" not in body, (
|
||
"createTerminal must NOT use JSON.parse — localStorage is no longer used; "
|
||
"fontSize comes directly as a parameter from app.js"
|
||
)
|
||
assert "fontSize" in body, (
|
||
"createTerminal must use the fontSize parameter for terminal configuration"
|
||
)
|
||
|
||
|
||
def test_create_terminal_applies_mobile_cap_with_math_min() -> None:
|
||
"""createTerminal() must apply mobile cap using Math.min(storedFontSize, 12)."""
|
||
match = re.search(
|
||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_TERMINAL_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "createTerminal function not found in terminal.js"
|
||
body = match.group(1)
|
||
assert "Math.min" in body, (
|
||
"createTerminal must use Math.min for mobile font size cap"
|
||
)
|
||
|
||
|
||
def test_create_terminal_uses_stored_font_size_not_hardcoded() -> None:
|
||
"""createTerminal() must use passed fontSize parameter, not hardcoded 14 or ternary."""
|
||
match = re.search(
|
||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_TERMINAL_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "createTerminal function not found in terminal.js"
|
||
body = match.group(1)
|
||
# The font size in Terminal constructor should use a variable, not a ternary with literal 14
|
||
# Check that there's no raw "mobile ? 12 : 14" pattern anymore
|
||
assert "mobile ? 12 : 14" not in body, (
|
||
"createTerminal must not use hardcoded 'mobile ? 12 : 14' ternary; "
|
||
"use the passed fontSize parameter with Math.min cap for mobile"
|
||
)
|
||
|
||
|
||
def test_create_terminal_has_default_font_size_14() -> None:
|
||
"""createTerminal() must default to fontSize 14 when no parameter is passed."""
|
||
match = re.search(
|
||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_TERMINAL_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "createTerminal function not found in terminal.js"
|
||
body = match.group(1)
|
||
assert "14" in body, (
|
||
"createTerminal must have default font size of 14 for when no fontSize parameter is passed"
|
||
)
|
||
|
||
|
||
# ── app.js: getVisibleSessions helper filters hidden sessions ─────────────────
|
||
|
||
|
||
def test_get_visible_sessions_filters_hidden_sessions() -> None:
|
||
"""getVisibleSessions() must filter sessions using _serverSettings.hidden_sessions.
|
||
|
||
Phase 1 refactor: getVisibleSessions is now a thin wrapper around filterVisible(),
|
||
which is the single canonical filter. hidden_sessions handling lives in filterVisible().
|
||
"""
|
||
match = re.search(
|
||
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getVisibleSessions function not found in app.js"
|
||
body = match.group(1)
|
||
# Phase 1: body is a thin wrapper — delegates to filterVisible() with _serverSettings
|
||
assert "filterVisible" in body, (
|
||
"getVisibleSessions must delegate to filterVisible() (Phase 1 refactor)"
|
||
)
|
||
assert "_serverSettings" in body, (
|
||
"getVisibleSessions must pass _serverSettings to filterVisible for hidden_sessions access"
|
||
)
|
||
|
||
|
||
# ── app.js: renderGrid filters hidden sessions ────────────────────────────────
|
||
|
||
|
||
def test_render_grid_filters_hidden_sessions() -> None:
|
||
"""renderGrid() must filter out hidden sessions via getVisibleSessions()."""
|
||
match = re.search(
|
||
r"function renderGrid\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderGrid function not found in app.js"
|
||
body = match.group(1)
|
||
assert "getVisibleSessions" in body, (
|
||
"renderGrid must call getVisibleSessions() to filter hidden sessions"
|
||
)
|
||
|
||
|
||
def test_render_grid_creates_visible_array() -> None:
|
||
"""renderGrid() must create a 'visible' array excluding hidden session names."""
|
||
match = re.search(
|
||
r"function renderGrid\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderGrid function not found in app.js"
|
||
body = match.group(1)
|
||
assert "visible" in body, (
|
||
"renderGrid must create a 'visible' variable/array for non-hidden sessions"
|
||
)
|
||
|
||
|
||
def test_render_grid_uses_visible_for_empty_state_check() -> None:
|
||
"""renderGrid() must use visible.length (not sessions.length) for empty-state check."""
|
||
match = re.search(
|
||
r"function renderGrid\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderGrid function not found in app.js"
|
||
body = match.group(1)
|
||
# The empty-state guard must check visible.length, not sessions.length, so hidden
|
||
# sessions do not trigger the "no sessions" state.
|
||
assert "visible.length" in body, (
|
||
"renderGrid must use 'visible.length' for empty-state check, not sessions.length"
|
||
)
|
||
|
||
|
||
def test_render_grid_applies_alphabetical_sort_with_locale_compare() -> None:
|
||
"""renderGrid() must sort alphabetically using localeCompare when sortOrder is 'alphabetical'."""
|
||
match = re.search(
|
||
r"function renderGrid\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderGrid function not found in app.js"
|
||
body = match.group(1)
|
||
assert "alphabetical" in body, "renderGrid must check for 'alphabetical' sort order"
|
||
assert "localeCompare" in body, (
|
||
"renderGrid must use localeCompare for alphabetical sort"
|
||
)
|
||
|
||
|
||
def test_render_grid_reads_sort_order_from_server_settings() -> None:
|
||
"""renderGrid() must read sort_order from _serverSettings to determine ordering."""
|
||
match = re.search(
|
||
r"function renderGrid\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderGrid function not found in app.js"
|
||
body = match.group(1)
|
||
assert "sort_order" in body or "sortOrder" in body, (
|
||
"renderGrid must read sort_order from _serverSettings"
|
||
)
|
||
|
||
|
||
# ── app.js: renderSidebar filters hidden sessions ─────────────────────────────
|
||
|
||
|
||
def test_render_sidebar_filters_hidden_sessions() -> None:
|
||
"""renderSidebar() must filter out hidden sessions via getVisibleSessions()."""
|
||
match = re.search(
|
||
r"function renderSidebar\s*\(.*?\)\s*\{(.*?)(?=\nconst SIDEBAR_KEY|function |\n// ─)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderSidebar function not found in app.js"
|
||
body = match.group(1)
|
||
assert "getVisibleSessions" in body, (
|
||
"renderSidebar must call getVisibleSessions() to filter hidden sessions"
|
||
)
|
||
|
||
|
||
def test_render_sidebar_uses_visible_array() -> None:
|
||
"""renderSidebar() must use visible array for rendering, not original sessions."""
|
||
match = re.search(
|
||
r"function renderSidebar\s*\(.*?\)\s*\{(.*?)(?=\nconst SIDEBAR_KEY|function |\n// ─)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderSidebar function not found in app.js"
|
||
body = match.group(1)
|
||
assert "visible" in body, (
|
||
"renderSidebar must use 'visible' array after filtering hidden sessions"
|
||
)
|
||
|
||
|
||
# ── app.js: renderSheetList filters hidden sessions ───────────────────────────
|
||
|
||
|
||
def test_render_sheet_list_filters_hidden_sessions() -> None:
|
||
"""renderSheetList() must filter out hidden sessions via getVisibleSessions()."""
|
||
match = re.search(
|
||
r"function renderSheetList\s*\(\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderSheetList function not found in app.js"
|
||
body = match.group(1)
|
||
assert "getVisibleSessions" in body, (
|
||
"renderSheetList must call getVisibleSessions() to filter hidden sessions; "
|
||
"currently it reads _currentSessions directly and bypasses the filter"
|
||
)
|
||
|
||
|
||
# ── app.js: DOMContentLoaded calls loadServerSettings ────────────────────────
|
||
|
||
|
||
def test_dom_content_loaded_calls_load_server_settings() -> None:
|
||
"""DOMContentLoaded handler must call loadServerSettings() after startPolling()."""
|
||
match = re.search(
|
||
r"DOMContentLoaded.*?\{(.*?)(?=\}\);?\s*\n// |\}\);\s*$)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "DOMContentLoaded handler not found"
|
||
body = match.group(1)
|
||
assert "loadServerSettings" in body, (
|
||
"DOMContentLoaded handler must call loadServerSettings() "
|
||
"after startPolling() in the restoreState().then() chain"
|
||
)
|
||
|
||
|
||
# ─── Remote Instances UI (task-15) ────────────────────────────────────────────
|
||
|
||
|
||
def test_save_remote_instances_function_exists() -> None:
|
||
"""_saveRemoteInstances function must exist in app.js."""
|
||
assert "_saveRemoteInstances" in _JS, (
|
||
"_saveRemoteInstances function must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_save_remote_instances_calls_patch_server_setting() -> None:
|
||
"""_saveRemoteInstances must call patchServerSetting('remote_instances', ...)."""
|
||
match = re.search(
|
||
r"function _saveRemoteInstances\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//|window\.)|\n})",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_saveRemoteInstances function not found in app.js"
|
||
body = match.group(1)
|
||
assert "patchServerSetting" in body, (
|
||
"_saveRemoteInstances must call patchServerSetting"
|
||
)
|
||
assert "remote_instances" in body, (
|
||
"_saveRemoteInstances must pass 'remote_instances' to patchServerSetting"
|
||
)
|
||
|
||
|
||
def test_open_settings_populates_device_name() -> None:
|
||
"""openSettings must populate setting-device-name from server settings."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found in app.js"
|
||
body = match.group(1)
|
||
assert "setting-device-name" in body, (
|
||
"openSettings must populate #setting-device-name input from server settings"
|
||
)
|
||
assert "device_name" in body, (
|
||
"openSettings must reference device_name from server settings"
|
||
)
|
||
|
||
|
||
def test_open_settings_renders_remote_instances() -> None:
|
||
"""openSettings must render remote instances rows from server settings."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found in app.js"
|
||
body = match.group(1)
|
||
assert "setting-remote-instances" in body, (
|
||
"openSettings must populate #setting-remote-instances from server settings"
|
||
)
|
||
assert "remote_instances" in body, (
|
||
"openSettings must reference remote_instances when rendering the instances list"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_device_name_debounce() -> None:
|
||
"""bindStaticEventListeners must bind debounced save for setting-device-name."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found in app.js"
|
||
body = match.group(1)
|
||
assert "setting-device-name" in body, (
|
||
"bindStaticEventListeners must bind an event listener for #setting-device-name"
|
||
)
|
||
assert "device_name" in body, (
|
||
"bindStaticEventListeners must save device_name via patchServerSetting"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_add_remote_instance_btn() -> None:
|
||
"""bindStaticEventListeners must bind the add-remote-instance-btn click handler."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found in app.js"
|
||
body = match.group(1)
|
||
assert "add-remote-instance-btn" in body, (
|
||
"bindStaticEventListeners must bind click handler for #add-remote-instance-btn"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_remote_instance_remove() -> None:
|
||
"""bindStaticEventListeners must bind delegated handler for remote instance remove buttons."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found in app.js"
|
||
body = match.group(1)
|
||
assert "settings-remote-remove" in body, (
|
||
"bindStaticEventListeners must handle delegated clicks on .settings-remote-remove buttons"
|
||
)
|
||
|
||
|
||
def test_build_remote_instance_row_url_input_aria_label() -> None:
|
||
"""urlInput in _buildRemoteInstanceRow must have an aria-label attribute."""
|
||
match = re.search(
|
||
r"function _buildRemoteInstanceRow\s*\(.*?\)\s*\{(.*?)(?=\n(?:function|/\*\*|window\.)|\n})",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_buildRemoteInstanceRow function not found in app.js"
|
||
body = match.group(1)
|
||
assert "Remote instance URL" in body, (
|
||
"urlInput must have aria-label='Remote instance URL' for screen-reader accessibility"
|
||
)
|
||
|
||
|
||
def test_build_remote_instance_row_name_input_aria_label() -> None:
|
||
"""nameInput in _buildRemoteInstanceRow must have an aria-label attribute."""
|
||
match = re.search(
|
||
r"function _buildRemoteInstanceRow\s*\(.*?\)\s*\{(.*?)(?=\n(?:function|/\*\*|window\.)|\n})",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_buildRemoteInstanceRow function not found in app.js"
|
||
body = match.group(1)
|
||
assert "Remote instance display name" in body, (
|
||
"nameInput must have aria-label='Remote instance display name' for screen-reader accessibility"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Multi-Device tab — settings UI reorganization
|
||
# ============================================================
|
||
|
||
|
||
def test_open_settings_populates_multi_device_enabled() -> None:
|
||
"""openSettings must populate #setting-multi-device-enabled from server settings."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found in app.js"
|
||
body = match.group(1)
|
||
assert "setting-multi-device-enabled" in body, (
|
||
"openSettings must populate #setting-multi-device-enabled checkbox"
|
||
)
|
||
assert "multi_device_enabled" in body, (
|
||
"openSettings must reference multi_device_enabled from server settings"
|
||
)
|
||
|
||
|
||
def test_open_settings_updates_document_title() -> None:
|
||
"""openSettings must update document.title from the device_name setting."""
|
||
match = re.search(
|
||
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "openSettings function not found in app.js"
|
||
body = match.group(1)
|
||
assert "document.title" in body, (
|
||
"openSettings must update document.title from device_name"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_binds_multi_device_enabled() -> None:
|
||
"""bindStaticEventListeners must bind change on #setting-multi-device-enabled."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "setting-multi-device-enabled" in body, (
|
||
"bindStaticEventListeners must bind #setting-multi-device-enabled change event"
|
||
)
|
||
|
||
|
||
def test_device_name_change_updates_document_title() -> None:
|
||
"""device name change handler in bindStaticEventListeners must update document.title."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "document.title" in body, (
|
||
"bindStaticEventListeners device-name handler must update document.title"
|
||
)
|
||
|
||
|
||
# ─── task-4-frontend-session-key-comparison ─────────────────────────────────
|
||
|
||
|
||
def test_update_pill_bell_uses_session_key_or_name() -> None:
|
||
"""updatePillBell must compare using sessionKey||name against a viewingKey
|
||
that accounts for remote sessions (_viewingRemoteId prefix)."""
|
||
match = re.search(
|
||
r"function updatePillBell\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "updatePillBell function not found"
|
||
body = match.group(1)
|
||
# Must build viewingKey using _viewingRemoteId
|
||
assert "_viewingRemoteId" in body, (
|
||
"updatePillBell must reference _viewingRemoteId to build viewingKey"
|
||
)
|
||
assert "viewingKey" in body, "updatePillBell must define a viewingKey variable"
|
||
# Must compare using sessionKey || s.name (not just s.name)
|
||
assert "sessionKey" in body, (
|
||
"updatePillBell must compare using s.sessionKey || s.name (not just s.name)"
|
||
)
|
||
|
||
|
||
def test_update_session_pill_uses_session_key_or_name() -> None:
|
||
"""updateSessionPill must compare using sessionKey||name against a viewingKey
|
||
that accounts for remote sessions (_viewingRemoteId prefix)."""
|
||
match = re.search(
|
||
r"function updateSessionPill\s*\(\w+\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "updateSessionPill function not found"
|
||
body = match.group(1)
|
||
# Must build viewingKey using _viewingRemoteId
|
||
assert "_viewingRemoteId" in body, (
|
||
"updateSessionPill must reference _viewingRemoteId to build viewingKey"
|
||
)
|
||
assert "viewingKey" in body, "updateSessionPill must define a viewingKey variable"
|
||
# Must compare using sessionKey || s.name (not just s.name)
|
||
assert "sessionKey" in body, (
|
||
"updateSessionPill must compare using s.sessionKey || s.name (not just s.name)"
|
||
)
|
||
|
||
|
||
# ─── task-5-open-session-bell-clear ─────────────────────────────────────────
|
||
|
||
|
||
def test_open_session_fires_bell_clear_for_remote() -> None:
|
||
"""openSession must fire a bell/clear POST to the federation proxy for remote sessions."""
|
||
match = re.search(
|
||
r"async function openSession\s*\(.*?\)\s*\{(.*?)^\}",
|
||
_JS,
|
||
re.DOTALL | re.MULTILINE,
|
||
)
|
||
assert match, "openSession function not found"
|
||
body = match.group(1)
|
||
# Must POST to federation bell/clear endpoint when _remoteId is not empty
|
||
assert "/bell/clear" in body, (
|
||
"openSession must POST to /api/federation/{remoteId}/sessions/{name}/bell/clear"
|
||
)
|
||
# Must guard bell-clear with a _deviceId !== '' check (renamed from _remoteId in task-11)
|
||
assert "_deviceId !== ''" in body, (
|
||
"openSession must guard bell-clear POST with _deviceId !== '' check"
|
||
)
|
||
|
||
|
||
def test_open_session_bell_clear_is_fire_and_forget() -> None:
|
||
"""openSession bell-clear POST must be fire-and-forget using .catch() to swallow errors."""
|
||
match = re.search(
|
||
r"async function openSession\s*\(.*?\)\s*\{(.*?)^\}",
|
||
_JS,
|
||
re.DOTALL | re.MULTILINE,
|
||
)
|
||
assert match, "openSession function not found"
|
||
body = match.group(1)
|
||
# Must use .catch(function() {}) to swallow errors — not awaited
|
||
assert ".catch(function() {})" in body, (
|
||
"openSession bell-clear POST must swallow errors with .catch(function() {})"
|
||
)
|
||
# Must NOT await the bell-clear call (it's fire-and-forget)
|
||
bell_clear_lines = [line for line in body.splitlines() if "/bell/clear" in line]
|
||
assert bell_clear_lines, "openSession must contain a bell/clear call"
|
||
for line in bell_clear_lines:
|
||
assert "await" not in line, (
|
||
f"openSession bell-clear POST must NOT be awaited (fire-and-forget): {line.strip()}"
|
||
)
|
||
|
||
|
||
# ─── Task 4: sidebar functions use server-side settings ──────────────────────
|
||
|
||
|
||
def test_no_sidebar_key_constant() -> None:
|
||
"""SIDEBAR_KEY constant must be removed — sidebar state moves to _serverSettings."""
|
||
assert "SIDEBAR_KEY" not in _JS, (
|
||
"SIDEBAR_KEY constant must be removed from app.js; "
|
||
"sidebar open/closed state is now stored in _serverSettings.sidebarOpen"
|
||
)
|
||
|
||
|
||
def test_init_sidebar_reads_server_settings() -> None:
|
||
"""initSidebar must read sidebarOpen from _serverSettings, not localStorage."""
|
||
match = re.search(
|
||
r"function initSidebar\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "initSidebar function not found"
|
||
body = match.group(1)
|
||
assert "_serverSettings" in body, (
|
||
"initSidebar must read sidebarOpen from _serverSettings"
|
||
)
|
||
assert "localStorage" not in body, (
|
||
"initSidebar must not use localStorage — sidebar state is now server-side"
|
||
)
|
||
|
||
|
||
def test_init_sidebar_calls_patch_server_setting() -> None:
|
||
"""initSidebar must call patchServerSetting to persist the auto-detected state."""
|
||
match = re.search(
|
||
r"function initSidebar\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "initSidebar function not found"
|
||
body = match.group(1)
|
||
assert "patchServerSetting" in body, (
|
||
"initSidebar must call patchServerSetting to persist the auto-detected sidebar state"
|
||
)
|
||
|
||
|
||
def test_toggle_sidebar_reads_server_settings() -> None:
|
||
"""toggleSidebar must derive state from the DOM class, not from localStorage."""
|
||
match = re.search(
|
||
r"function toggleSidebar\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "toggleSidebar function not found"
|
||
body = match.group(1)
|
||
assert "_serverSettings" in body, (
|
||
"toggleSidebar must write new state to _serverSettings"
|
||
)
|
||
assert "localStorage" not in body, (
|
||
"toggleSidebar must not use localStorage — sidebar state is now server-side"
|
||
)
|
||
|
||
|
||
def test_toggle_sidebar_calls_patch_server_setting() -> None:
|
||
"""toggleSidebar must call patchServerSetting to persist the toggled state."""
|
||
match = re.search(
|
||
r"function toggleSidebar\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "toggleSidebar function not found"
|
||
body = match.group(1)
|
||
assert "patchServerSetting" in body, (
|
||
"toggleSidebar must call patchServerSetting to persist the toggled sidebar state"
|
||
)
|
||
|
||
|
||
def test_bind_sidebar_click_away_uses_server_settings() -> None:
|
||
"""bindSidebarClickAway must write false to _serverSettings on collapse."""
|
||
match = re.search(
|
||
r"function bindSidebarClickAway\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindSidebarClickAway function not found"
|
||
body = match.group(1)
|
||
assert "_serverSettings" in body, (
|
||
"bindSidebarClickAway must write false to _serverSettings.sidebarOpen on collapse"
|
||
)
|
||
assert "localStorage" not in body, (
|
||
"bindSidebarClickAway must not use localStorage — sidebar state is now server-side"
|
||
)
|
||
|
||
|
||
def test_bind_sidebar_click_away_calls_patch_server_setting() -> None:
|
||
"""bindSidebarClickAway must call patchServerSetting to persist collapsed state."""
|
||
match = re.search(
|
||
r"function bindSidebarClickAway\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindSidebarClickAway function not found"
|
||
body = match.group(1)
|
||
assert "patchServerSetting" in body, (
|
||
"bindSidebarClickAway must call patchServerSetting to persist collapsed state"
|
||
)
|
||
|
||
|
||
# ─── task-1: getVisibleSessions falsy-0 bug fix ────────────────────────────
|
||
|
||
|
||
def test_get_visible_sessions_has_no_remote_id_filter() -> None:
|
||
"""getVisibleSessions must not check s.remoteId at all.
|
||
|
||
After federation settings sync, hidden_sessions applies to both local and
|
||
remote sessions. The filter must check session name only, with no remoteId
|
||
guard. Must also not use !s.remoteId which would treat remoteId=0 as falsy.
|
||
"""
|
||
match = re.search(
|
||
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getVisibleSessions function not found in app.js"
|
||
body = match.group(1)
|
||
assert "!s.remoteId" not in body, (
|
||
"getVisibleSessions must NOT use '!s.remoteId' — this treats remoteId=0 as falsy, "
|
||
"hiding the first remote instance."
|
||
)
|
||
assert "s.remoteId" not in body, (
|
||
"getVisibleSessions must NOT reference s.remoteId at all — the filter applies to "
|
||
"all sessions regardless of origin (local or federated)."
|
||
)
|
||
|
||
|
||
# ─── task-2: updatePageTitle and updateFaviconBadge filter hidden sessions ─────
|
||
|
||
|
||
def test_update_page_title_filters_through_visible_sessions() -> None:
|
||
"""updatePageTitle() must count bell activity from getVisibleSessions(), not all sessions."""
|
||
match = re.search(
|
||
r"function updatePageTitle\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "updatePageTitle function not found in app.js"
|
||
body = match.group(1)
|
||
assert "getVisibleSessions" in body, (
|
||
"updatePageTitle must filter sessions through getVisibleSessions() "
|
||
"to exclude hidden sessions from the bell count"
|
||
)
|
||
|
||
|
||
def test_update_favicon_badge_filters_through_visible_sessions() -> None:
|
||
"""updateFaviconBadge() must check bell activity from getVisibleSessions(), not all sessions."""
|
||
match = re.search(
|
||
r"function updateFaviconBadge\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "updateFaviconBadge function not found in app.js"
|
||
body = match.group(1)
|
||
assert "getVisibleSessions" in body, (
|
||
"updateFaviconBadge must filter sessions through getVisibleSessions() "
|
||
"to exclude hidden sessions from the bell activity check"
|
||
)
|
||
|
||
|
||
# ─── Task 11: Frontend — Change Session Key Format to `device_id:name` ────────
|
||
|
||
|
||
def test_build_tile_html_uses_remote_id_for_data_remote_id() -> None:
|
||
"""buildTileHTML must use session.remoteId (not session.deviceId) for data-remote-id.
|
||
|
||
v0.4.1 hotfix changed this from deviceId to remoteId because deviceId is non-null for
|
||
local sessions, which caused them to route through federation endpoints and 404.
|
||
remoteId is null for local sessions, so only remote sessions get routed via federation.
|
||
"""
|
||
match = re.search(
|
||
r"function buildTileHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "buildTileHTML function not found"
|
||
body = match.group(1)
|
||
assert "session.remoteId" in body, (
|
||
"buildTileHTML must use session.remoteId for data-remote-id attribute — "
|
||
"deviceId is intentionally NOT used because it is non-null for local sessions, "
|
||
"which would incorrectly route them through federation endpoints"
|
||
)
|
||
assert "session.deviceId" not in body, (
|
||
"buildTileHTML must NOT use session.deviceId for data-remote-id — "
|
||
"deviceId is non-null for local sessions and causes federation 404s"
|
||
)
|
||
|
||
|
||
def test_build_sidebar_html_uses_remote_id_for_data_remote_id() -> None:
|
||
"""buildSidebarHTML must use session.remoteId (not session.deviceId) for data-remote-id.
|
||
|
||
v0.4.1 hotfix changed this from deviceId to remoteId because deviceId is non-null for
|
||
local sessions, which caused them to route through federation endpoints and 404.
|
||
remoteId is null for local sessions, so only remote sessions get routed via federation.
|
||
"""
|
||
match = re.search(
|
||
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "buildSidebarHTML function not found"
|
||
body = match.group(1)
|
||
assert "session.remoteId" in body, (
|
||
"buildSidebarHTML must use session.remoteId for data-remote-id attribute — "
|
||
"deviceId is intentionally NOT used because it is non-null for local sessions, "
|
||
"which would incorrectly route them through federation endpoints"
|
||
)
|
||
assert "session.deviceId" not in body, (
|
||
"buildSidebarHTML must NOT use session.deviceId for data-remote-id — "
|
||
"deviceId is non-null for local sessions and causes federation 404s"
|
||
)
|
||
|
||
|
||
def test_get_visible_sessions_checks_session_key_in_hidden() -> None:
|
||
"""getVisibleSessions must handle sessionKey/name dual-lookup for hidden_sessions matching.
|
||
|
||
hidden_sessions may contain either:
|
||
- old format: plain session name (e.g. 'dev')
|
||
- new format: device_id:name key (e.g. 'abc-123:dev')
|
||
|
||
Phase 1 refactor: this is now handled inside filterVisible(). getVisibleSessions delegates
|
||
to filterVisible(), which is the single canonical filter implementing dual-lookup.
|
||
"""
|
||
match = re.search(
|
||
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getVisibleSessions function not found"
|
||
body = match.group(1)
|
||
# Phase 1: thin wrapper — dual-lookup lives in filterVisible()
|
||
assert "filterVisible" in body, (
|
||
"getVisibleSessions must delegate to filterVisible() which implements "
|
||
"sessionKey/name dual-lookup for backward compatibility"
|
||
)
|
||
|
||
|
||
def test_open_session_uses_device_id_variable_for_federation() -> None:
|
||
"""openSession must use _deviceId variable (from opts.remoteId) for federation API calls.
|
||
|
||
The local variable _remoteId is renamed to _deviceId to reflect that the value
|
||
is now a device_id string, not an integer remote instance index.
|
||
"""
|
||
match = re.search(
|
||
r"async function openSession\s*\(.*?\)\s*\{(.*?)^\}",
|
||
_JS,
|
||
re.DOTALL | re.MULTILINE,
|
||
)
|
||
assert match, "openSession function not found"
|
||
body = match.group(1)
|
||
assert "_deviceId" in body, (
|
||
"openSession must use _deviceId variable (assigned from opts.remoteId) "
|
||
"for federation API URL construction — reflects that the value is now a device_id string"
|
||
)
|
||
|
||
|
||
def test_create_new_session_uses_device_id_variable_internally() -> None:
|
||
"""createNewSession must use a deviceId variable internally for federation endpoint construction.
|
||
|
||
The internal variable is renamed from 'remoteId' to 'deviceId' to reflect that
|
||
the value is now a device_id string (e.g. 'abc-123'), not an integer index.
|
||
"""
|
||
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 "var deviceId" in body, (
|
||
"createNewSession must declare 'var deviceId' internally (assigned from the remoteId parameter) "
|
||
"to reflect that the value is now a device_id string used in federation API URLs"
|
||
)
|
||
|
||
|
||
# ─── Task 12: Remove 'filtered' from gridViewMode options ─────────────────────
|
||
|
||
|
||
def test_load_grid_view_mode_guards_filtered_value() -> None:
|
||
"""loadGridViewMode must remap 'filtered' to 'flat' for users who had it set."""
|
||
match = re.search(
|
||
r"function loadGridViewMode\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*\*)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "loadGridViewMode function not found"
|
||
body = match.group(1)
|
||
assert "filtered" in body, (
|
||
"loadGridViewMode must guard against 'filtered': "
|
||
"if (mode === 'filtered') mode = 'flat'; — graceful fallback for existing users"
|
||
)
|
||
assert "flat" in body, (
|
||
"loadGridViewMode must fall back to 'flat' when mode is 'filtered'"
|
||
)
|
||
|
||
|
||
def test_set_grid_view_mode_guards_filtered_value() -> None:
|
||
"""_setGridViewMode (test helper) must remap 'filtered' to 'flat'."""
|
||
match = re.search(
|
||
r"function _setGridViewMode\s*\(\w+\)\s*\{(.*?)(?=\n\})",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_setGridViewMode function not found"
|
||
body = match.group(1)
|
||
assert "filtered" in body, (
|
||
"_setGridViewMode must guard against 'filtered': "
|
||
"if (mode === 'filtered') mode = 'flat';"
|
||
)
|
||
assert "flat" in body, "_setGridViewMode must remap 'filtered' to 'flat'"
|
||
|
||
|
||
def test_render_grid_no_filtered_mode_check() -> None:
|
||
"""renderGrid must not contain any _gridViewMode === 'filtered' check.
|
||
|
||
The 'filtered' gridViewMode has been removed. All references in renderGrid
|
||
that branch on _gridViewMode === 'filtered' must be deleted so the filter bar
|
||
is never rendered and the device-filter logic is never applied.
|
||
"""
|
||
match = re.search(
|
||
r"function renderGrid\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderGrid function not found in app.js"
|
||
body = match.group(1)
|
||
assert "'filtered'" not in body and '"filtered"' not in body, (
|
||
"renderGrid must not check _gridViewMode === 'filtered' — "
|
||
"the 'filtered' mode has been removed; only 'flat' and 'grouped' remain"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# COE verification fixes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_resolve_active_view_function_exists() -> None:
|
||
"""_resolveActiveView must exist in app.js for active_view fallback (GAP 4).
|
||
|
||
When active_view references a view name that no longer exists in the views
|
||
list, _resolveActiveView must fall back to 'all' so the user always sees
|
||
sessions rather than an empty/broken state.
|
||
"""
|
||
assert "_resolveActiveView" in _JS, (
|
||
"_resolveActiveView function must exist in app.js for active_view fallback; "
|
||
"when a view is deleted while a device is offline the stored active_view "
|
||
"must fall back to 'all'"
|
||
)
|
||
|
||
|
||
def test_resolve_active_view_falls_back_to_all() -> None:
|
||
"""_resolveActiveView must return 'all' when active_view is not in views list."""
|
||
match = re.search(
|
||
r"function _resolveActiveView\s*\(.*?\)\s*\{(.*?)^}",
|
||
_JS,
|
||
re.DOTALL | re.MULTILINE,
|
||
)
|
||
assert match, "_resolveActiveView function not found"
|
||
body = match.group(1)
|
||
assert "return 'all'" in body, (
|
||
"_resolveActiveView must return 'all' as the fallback when active_view "
|
||
"is not found in the views list"
|
||
)
|
||
assert "'all'" in body and "'hidden'" in body, (
|
||
"_resolveActiveView must pass 'all' and 'hidden' through without lookup "
|
||
"(these are reserved view names, not user-created views)"
|
||
)
|
||
|
||
|
||
def test_create_device_select_uses_device_id_for_option_value() -> None:
|
||
"""_createDeviceSelect must use device_id (not integer index) for option values.
|
||
|
||
Session creation routes now accept a device_id string, not an integer index.
|
||
The select option value must be remotes[i].device_id when available so the
|
||
correct device_id is passed to createNewSession().
|
||
"""
|
||
match = re.search(
|
||
r"function _createDeviceSelect\s*\(\s*\)\s*\{(.*?)^}",
|
||
_JS,
|
||
re.DOTALL | re.MULTILINE,
|
||
)
|
||
assert match, "_createDeviceSelect function not found"
|
||
body = match.group(1)
|
||
assert "device_id" in body, (
|
||
"_createDeviceSelect must use remotes[i].device_id (with String(i) fallback) "
|
||
"for option values; integer index no longer matches federation API expectations"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Active view state variable and getVisibleSessions (task-2-active-view)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_active_view_state_variable_exists() -> None:
|
||
"""_activeView state variable must be declared with default 'all'."""
|
||
assert "let _activeView = 'all'" in _JS, (
|
||
"let _activeView = 'all' must be declared in the App state section of app.js"
|
||
)
|
||
|
||
|
||
def test_get_visible_sessions_all_view_excludes_hidden() -> None:
|
||
"""getVisibleSessions must reference _activeView to honour the active view."""
|
||
match = re.search(
|
||
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getVisibleSessions function not found"
|
||
body = match.group(1)
|
||
assert "_activeView" in body, (
|
||
"getVisibleSessions must check _activeView to filter sessions by view"
|
||
)
|
||
|
||
|
||
def test_get_visible_sessions_hidden_view_shows_hidden() -> None:
|
||
"""getVisibleSessions must handle the 'hidden' view case.
|
||
|
||
Phase 1 refactor: getVisibleSessions is a thin wrapper around filterVisible().
|
||
The 'hidden' view logic lives in filterVisible(), not in getVisibleSessions().
|
||
"""
|
||
match = re.search(
|
||
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getVisibleSessions function not found"
|
||
body = match.group(1)
|
||
# Phase 1: thin wrapper — 'hidden' view handling lives in filterVisible()
|
||
assert "filterVisible" in body, (
|
||
"getVisibleSessions must delegate to filterVisible() which handles the 'hidden' view"
|
||
)
|
||
|
||
|
||
def test_get_visible_sessions_user_view_filters_by_session_key() -> None:
|
||
"""getVisibleSessions must use sessionKey when filtering for a user-defined view.
|
||
|
||
Phase 1 refactor: getVisibleSessions is a thin wrapper around filterVisible().
|
||
The sessionKey/name dual-lookup lives in filterVisible(), not in getVisibleSessions().
|
||
"""
|
||
match = re.search(
|
||
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "getVisibleSessions function not found"
|
||
body = match.group(1)
|
||
# Phase 1: thin wrapper — sessionKey handling lives in filterVisible()
|
||
assert "filterVisible" in body, (
|
||
"getVisibleSessions must delegate to filterVisible() which implements "
|
||
"sessionKey/name dual-lookup for user view filtering"
|
||
)
|
||
|
||
|
||
def test_get_active_view_helper_exported() -> None:
|
||
"""window.MuxplexApp must export _getActiveView test helper."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "_getActiveView" in exports, (
|
||
"module.exports must export _getActiveView test helper"
|
||
)
|
||
|
||
|
||
def test_set_active_view_helper_exported() -> None:
|
||
"""window.MuxplexApp must export _setActiveView test helper."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "_setActiveView" in exports, (
|
||
"module.exports must export _setActiveView test helper"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Auto-add session to active user view on creation (task-3-auto-add-to-view)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_new_session_references_active_view() -> None:
|
||
"""createNewSession must reference _activeView to auto-add the new session to the active user view."""
|
||
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 "_activeView" in body, (
|
||
"createNewSession must reference _activeView to auto-add new session to the active user view"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Header Dropdown — JS Render + Open/Close + View Switching (task-6)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_render_view_dropdown_function_exists() -> None:
|
||
"""renderViewDropdown function must exist in app.js."""
|
||
assert "function renderViewDropdown" in _JS, (
|
||
"renderViewDropdown must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_render_view_dropdown_exported() -> None:
|
||
"""module.exports must export renderViewDropdown."""
|
||
match = re.search(
|
||
r"module\.exports\s*=\s*\{(.*?)\};",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "module.exports block not found"
|
||
exports = match.group(1)
|
||
assert "renderViewDropdown" in exports, (
|
||
"module.exports must export renderViewDropdown"
|
||
)
|
||
|
||
|
||
def test_toggle_view_dropdown_function_exists() -> None:
|
||
"""toggleViewDropdown function must exist in app.js."""
|
||
assert "function toggleViewDropdown" in _JS, (
|
||
"toggleViewDropdown must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_switch_view_function_exists() -> None:
|
||
"""switchView function must exist in app.js."""
|
||
assert "function switchView" in _JS, "switchView must be defined in app.js"
|
||
|
||
|
||
def test_switch_view_patches_state() -> None:
|
||
"""switchView must PATCH /api/state to persist active_view."""
|
||
match = re.search(
|
||
r"function switchView\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "switchView function not found"
|
||
body = match.group(1)
|
||
assert "PATCH" in body, "switchView must use PATCH method"
|
||
assert "/api/state" in body, "switchView must PATCH /api/state"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Keyboard Shortcuts — Backtick, Number Keys, Arrow Navigation (task-7)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_handle_global_keydown_has_backtick_handler() -> None:
|
||
"""handleGlobalKeydown must handle backtick/Backquote key to toggle view dropdown."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
has_backtick = (
|
||
"Backquote" in body or "e.key === '`'" in body or 'e.key === "`"' in body
|
||
)
|
||
assert has_backtick, (
|
||
"handleGlobalKeydown must handle backtick/Backquote key to toggle view dropdown"
|
||
)
|
||
assert "toggleViewDropdown" in body, (
|
||
"handleGlobalKeydown backtick handler must call toggleViewDropdown"
|
||
)
|
||
|
||
|
||
def test_handle_global_keydown_has_number_key_shortcuts() -> None:
|
||
"""handleGlobalKeydown must call switchView for number keys 1–9."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
assert "switchView" in body, (
|
||
"handleGlobalKeydown must call switchView for number keys 1–9"
|
||
)
|
||
has_number_handling = "Digit" in body or "parseInt" in body
|
||
assert has_number_handling, (
|
||
"handleGlobalKeydown must handle number key codes (e.g. e.code.startsWith('Digit'))"
|
||
)
|
||
|
||
|
||
def test_backtick_only_on_grid_not_fullscreen() -> None:
|
||
"""Backtick handler must check _viewMode === 'grid' (not active in fullscreen)."""
|
||
match = re.search(
|
||
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "handleGlobalKeydown function not found"
|
||
body = match.group(1)
|
||
assert "_viewMode" in body, (
|
||
"handleGlobalKeydown backtick handler must check _viewMode"
|
||
)
|
||
assert "'grid'" in body, (
|
||
"handleGlobalKeydown must check _viewMode === 'grid' before handling backtick/number shortcuts"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# New View inline creation flow (task-8)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_show_new_view_input_function_exists() -> None:
|
||
"""app.js defines showNewViewInput for inline view creation."""
|
||
assert "function showNewViewInput" in _JS, (
|
||
"showNewViewInput must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_show_new_view_input_patches_settings() -> None:
|
||
"""showNewViewInput handler PATCHes /api/settings with views on Enter."""
|
||
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)
|
||
assert "PATCH" in body, (
|
||
"showNewViewInput must PATCH /api/settings to create the view"
|
||
)
|
||
assert "/api/settings" in body, (
|
||
"showNewViewInput must PATCH /api/settings with the updated views"
|
||
)
|
||
assert "views" in body, "showNewViewInput must include 'views' in the PATCH body"
|
||
|
||
|
||
# ============================================================
|
||
# Manage Views settings tab (task-9)
|
||
# ============================================================
|
||
|
||
|
||
def test_render_views_settings_tab_function_exists() -> None:
|
||
"""renderViewsSettingsTab function must exist in app.js."""
|
||
assert "function renderViewsSettingsTab" in _JS, (
|
||
"renderViewsSettingsTab must be defined in app.js"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Remove filtered gridViewMode rendering code (task-11)
|
||
# ============================================================
|
||
|
||
|
||
def test_no_active_filter_device_in_render_grid() -> None:
|
||
"""renderGrid must not reference _activeFilterDevice."""
|
||
match = re.search(
|
||
r"function renderGrid\(sessions\)\s*\{(.*?)(?=\n// -------|\n// =======)",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderGrid function not found"
|
||
body = match.group(1)
|
||
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_views_settings_tab_no_inline_rename_commit() -> None:
|
||
"""renderViewsSettingsTab must NOT have inline rename (commitRename removed in Issue 6).
|
||
|
||
Rename now lives in the Manage View panel, not the settings tab.
|
||
"""
|
||
match = re.search(
|
||
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderViewsSettingsTab function not found"
|
||
body = match.group(1)
|
||
# Inline rename (commitRename) must be gone — it moved to the Manage View panel
|
||
assert "commitRename" not in body, (
|
||
"renderViewsSettingsTab must not have inline commitRename — "
|
||
"rename moved to the Manage View panel (Issue 6)"
|
||
)
|
||
|
||
|
||
# ─── 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_moved_to_manage_view_panel() -> None:
|
||
"""renderViewsSettingsTab must NOT contain commitRename — it moved to Manage View panel (Issue 6)."""
|
||
match = re.search(
|
||
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderViewsSettingsTab function not found"
|
||
body = match.group(1)
|
||
# commitRename was removed from settings tab — it now lives in openManageViewPanel
|
||
assert "commitRename" not in body, (
|
||
"renderViewsSettingsTab must not have commitRename — rename moved to Manage View panel (Issue 6)"
|
||
)
|
||
|
||
|
||
# ─── Fix 8: rename input maxLength ───────────────────────────────────────────
|
||
|
||
|
||
def test_rename_input_not_in_settings_tab() -> None:
|
||
"""renderViewsSettingsTab must NOT have views-settings-rename-input (Issue 6: inline rename removed)."""
|
||
match = re.search(
|
||
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderViewsSettingsTab function not found"
|
||
body = match.group(1)
|
||
assert "views-settings-rename-input" not in body, (
|
||
"renderViewsSettingsTab must NOT have views-settings-rename-input — "
|
||
"inline rename was moved to the Manage View panel (Issue 6)"
|
||
)
|
||
|
||
|
||
# ─── 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"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tile options button (⋮) replaces tile-delete
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_tile_has_options_button() -> None:
|
||
"""buildTileHTML must include a .tile-options-btn button."""
|
||
fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0]
|
||
assert "tile-options-btn" in fn_body, (
|
||
"buildTileHTML must render a .tile-options-btn element"
|
||
)
|
||
|
||
|
||
def test_tile_options_btn_has_aria() -> None:
|
||
"""The ⋮ button must have aria-label='Session options' and aria-haspopup='true'."""
|
||
fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0]
|
||
assert "aria-label" in fn_body and "Session options" in fn_body, (
|
||
"tile-options-btn must have aria-label='Session options'"
|
||
)
|
||
assert "aria-haspopup" in fn_body, "tile-options-btn must have aria-haspopup='true'"
|
||
|
||
|
||
def test_tile_delete_button_removed() -> None:
|
||
"""buildTileHTML must NOT include the old .tile-delete button."""
|
||
fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0]
|
||
assert "tile-delete" not in fn_body, (
|
||
"buildTileHTML must not render the old .tile-delete button (kill moved to flyout)"
|
||
)
|
||
|
||
|
||
def test_open_flyout_menu_function_exists() -> None:
|
||
"""app.js must define an openFlyoutMenu function."""
|
||
assert "function openFlyoutMenu" in _JS, (
|
||
"app.js must contain an openFlyoutMenu function"
|
||
)
|
||
|
||
|
||
def test_close_flyout_menu_function_exists() -> None:
|
||
"""app.js must define a closeFlyoutMenu function."""
|
||
assert "function closeFlyoutMenu" in _JS, (
|
||
"app.js must contain a closeFlyoutMenu function"
|
||
)
|
||
|
||
|
||
def test_flyout_menu_uses_fixed_positioning() -> None:
|
||
"""openFlyoutMenu must use position:fixed and getBoundingClientRect for positioning."""
|
||
fn_body = _JS.split("function openFlyoutMenu")[1].split("\nfunction ")[0]
|
||
assert "getBoundingClientRect" in fn_body, (
|
||
"openFlyoutMenu must use getBoundingClientRect to calculate position"
|
||
)
|
||
|
||
|
||
def test_flyout_delegated_on_tile_container() -> None:
|
||
"""A delegated click listener must handle .tile-options-btn clicks."""
|
||
assert "tile-options-btn" in _JS.split("bindStaticEventListeners")[1], (
|
||
"bindStaticEventListeners must handle .tile-options-btn clicks via delegation"
|
||
)
|
||
|
||
|
||
def test_old_tile_delete_handler_removed() -> None:
|
||
"""The old delegated .tile-delete click handler must be removed."""
|
||
bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[
|
||
0
|
||
]
|
||
assert "tile-delete" not in bind_body, (
|
||
"The old .tile-delete delegated handler must be removed from bindStaticEventListeners"
|
||
)
|
||
|
||
|
||
# ─── Task 4: Context-dependent menu items — FLYOUT_MENU_MAP ──────────────────
|
||
|
||
|
||
def test_flyout_menu_map_exists() -> None:
|
||
"""app.js must define a FLYOUT_MENU_MAP data structure."""
|
||
assert "FLYOUT_MENU_MAP" in _JS, (
|
||
"app.js must contain a FLYOUT_MENU_MAP data structure"
|
||
)
|
||
|
||
|
||
def test_flyout_menu_map_has_three_view_types() -> None:
|
||
"""FLYOUT_MENU_MAP must have keys for 'all', 'user', and 'hidden'."""
|
||
# The map should reference all three view types
|
||
map_section = _JS.split("FLYOUT_MENU_MAP")[1].split("};")[0]
|
||
assert "'all'" in map_section or '"all"' in map_section, (
|
||
"FLYOUT_MENU_MAP must include an 'all' key"
|
||
)
|
||
assert "'user'" in map_section or '"user"' in map_section, (
|
||
"FLYOUT_MENU_MAP must include a 'user' key"
|
||
)
|
||
assert "'hidden'" in map_section or '"hidden"' in map_section, (
|
||
"FLYOUT_MENU_MAP must include a 'hidden' key"
|
||
)
|
||
|
||
|
||
def test_build_flyout_menu_items_function_exists() -> None:
|
||
"""app.js must define a _buildFlyoutMenuItems function."""
|
||
assert "function _buildFlyoutMenuItems" in _JS, (
|
||
"app.js must contain a _buildFlyoutMenuItems function"
|
||
)
|
||
|
||
|
||
def test_build_flyout_uses_menu_map() -> None:
|
||
"""_buildFlyoutMenuItems must reference FLYOUT_MENU_MAP."""
|
||
fn_body = _JS.split("function _buildFlyoutMenuItems")[1].split("\nfunction ")[0]
|
||
assert "FLYOUT_MENU_MAP" in fn_body, (
|
||
"_buildFlyoutMenuItems must reference FLYOUT_MENU_MAP (data-driven, not if/else)"
|
||
)
|
||
|
||
|
||
# ─── Task 5: Flyout click handler + Add to View submenu ──────────────────────
|
||
|
||
|
||
def test_handle_flyout_click_function_exists() -> None:
|
||
"""app.js must define a _handleFlyoutClick function."""
|
||
assert "function _handleFlyoutClick" in _JS, (
|
||
"app.js must contain a _handleFlyoutClick function"
|
||
)
|
||
|
||
|
||
def test_handle_flyout_click_dispatches_actions() -> None:
|
||
"""_handleFlyoutClick must check data-action for dispatching."""
|
||
fn_body = _JS.split("function _handleFlyoutClick")[1].split("\nfunction ")[0]
|
||
assert "data-action" in fn_body or "dataset.action" in fn_body, (
|
||
"_handleFlyoutClick must read data-action from the clicked element"
|
||
)
|
||
|
||
|
||
def test_open_flyout_submenu_function_exists() -> None:
|
||
"""app.js must define a _openFlyoutSubmenu function."""
|
||
assert "function _openFlyoutSubmenu" in _JS, (
|
||
"app.js must contain a _openFlyoutSubmenu function"
|
||
)
|
||
|
||
|
||
def test_submenu_toggles_view_membership() -> None:
|
||
"""_openFlyoutSubmenu must PATCH /api/settings to toggle view membership."""
|
||
fn_body = _JS.split("function _openFlyoutSubmenu")[1].split("\nfunction ")[0]
|
||
assert "views" in fn_body and ("PATCH" in fn_body or "api(" in fn_body), (
|
||
"_openFlyoutSubmenu must PATCH /api/settings to add/remove session from view"
|
||
)
|
||
|
||
|
||
# ─── Task 6: Hide/Unhide/Remove actions ──────────────────────────────────────
|
||
|
||
|
||
def test_do_hide_session_function_exists() -> None:
|
||
"""app.js must define a _doHideSession function."""
|
||
assert "function _doHideSession" in _JS, (
|
||
"app.js must contain a _doHideSession function"
|
||
)
|
||
|
||
|
||
def test_do_unhide_session_function_exists() -> None:
|
||
"""app.js must define a _doUnhideSession function."""
|
||
assert "function _doUnhideSession" in _JS, (
|
||
"app.js must contain a _doUnhideSession function"
|
||
)
|
||
|
||
|
||
def test_do_remove_from_view_function_exists() -> None:
|
||
"""app.js must define a _doRemoveFromView function."""
|
||
assert "function _doRemoveFromView" in _JS, (
|
||
"app.js must contain a _doRemoveFromView function"
|
||
)
|
||
|
||
|
||
def test_hide_session_removes_from_all_views() -> None:
|
||
"""_doHideSession must update both hidden_sessions AND views (remove from all views)."""
|
||
fn_body = _JS.split("function _doHideSession")[1].split("\nfunction ")[0]
|
||
assert "hidden_sessions" in fn_body, (
|
||
"_doHideSession must add session to hidden_sessions"
|
||
)
|
||
assert "views" in fn_body, (
|
||
"_doHideSession must remove session from all views (mutual exclusion)"
|
||
)
|
||
|
||
|
||
# ─── Task 7: Kill session inline confirmation ─────────────────────────────────
|
||
|
||
|
||
def test_do_kill_session_inline_function_exists() -> None:
|
||
"""app.js must define a _doKillSessionInline function."""
|
||
assert "function _doKillSessionInline" in _JS, (
|
||
"app.js must contain a _doKillSessionInline function"
|
||
)
|
||
|
||
|
||
def test_kill_session_no_confirm_dialog() -> None:
|
||
"""killSession must NOT use window.confirm() (replaced by inline confirmation)."""
|
||
fn_body = _JS.split("function killSession")[1].split("\nfunction ")[0]
|
||
assert "confirm(" not in fn_body, (
|
||
"killSession must not use confirm() — replaced by inline flyout confirmation"
|
||
)
|
||
|
||
|
||
def test_do_kill_inline_shows_confirmation_buttons() -> None:
|
||
"""_doKillSessionInline must render Yes/No confirmation buttons."""
|
||
fn_body = _JS.split("function _doKillSessionInline")[1].split("\nfunction ")[0]
|
||
assert "Yes" in fn_body and "No" in fn_body, (
|
||
"_doKillSessionInline must show 'Kill? [Yes] [No]' inline"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Add Sessions panel JS logic (task-9)
|
||
# ============================================================
|
||
|
||
|
||
def test_open_manage_view_panel_function_exists() -> None:
|
||
"""app.js must define an openManageViewPanel function (renamed from openAddSessionsPanel)."""
|
||
assert "function openManageViewPanel" in _JS, (
|
||
"app.js must contain an openManageViewPanel function"
|
||
)
|
||
|
||
|
||
def test_close_manage_view_panel_function_exists() -> None:
|
||
"""app.js must define a closeManageViewPanel function (renamed from closeAddSessionsPanel)."""
|
||
assert "function closeManageViewPanel" in _JS, (
|
||
"app.js must contain a closeManageViewPanel function"
|
||
)
|
||
|
||
|
||
def test_render_manage_view_list_function_exists() -> None:
|
||
"""app.js must define a renderManageViewList function (renamed from renderAddSessionsList)."""
|
||
assert "function renderManageViewList" in _JS, (
|
||
"app.js must contain a renderManageViewList function"
|
||
)
|
||
|
||
|
||
def test_manage_view_uses_immediate_commit() -> None:
|
||
"""renderManageViewList must PATCH immediately on checkbox change (no batch Done)."""
|
||
fn_body = _JS.split("function renderManageViewList")[1].split("\nfunction ")[0]
|
||
assert "PATCH" in fn_body or "api(" in fn_body, (
|
||
"renderManageViewList must fire PATCH on each checkbox change (immediate commit)"
|
||
)
|
||
|
||
|
||
def test_manage_view_shows_device_name() -> None:
|
||
"""renderManageViewList must show device name next to each session."""
|
||
fn_body = _JS.split("function renderManageViewList")[1].split("\nfunction ")[0]
|
||
assert "deviceName" in fn_body or "device" in fn_body, (
|
||
"renderManageViewList must show device name for disambiguation"
|
||
)
|
||
|
||
|
||
# ─── Task 10: Mobile variants ────────────────────────────────────────────────
|
||
|
||
|
||
def test_open_flyout_sheet_function_exists() -> None:
|
||
"""app.js must define a _openFlyoutSheet function for mobile."""
|
||
assert "function _openFlyoutSheet" in _JS, (
|
||
"app.js must contain a _openFlyoutSheet function for mobile bottom sheet"
|
||
)
|
||
|
||
|
||
def test_open_flyout_menu_checks_mobile() -> None:
|
||
"""openFlyoutMenu must check isMobile() to decide between flyout and sheet."""
|
||
fn_body = _JS.split("function openFlyoutMenu")[1].split("\nfunction ")[0]
|
||
assert "isMobile" in fn_body, (
|
||
"openFlyoutMenu must check isMobile() to branch between flyout and sheet"
|
||
)
|
||
|
||
|
||
# ─── Task 11: Add Sessions entry point in grid ───────────────────────────────
|
||
|
||
|
||
def test_render_view_dropdown_has_manage_view_affordance() -> None:
|
||
"""renderViewDropdown must include a 'Manage [ViewName]...' action for user views.
|
||
|
||
The affordance moved from a header button to the dropdown's 'Manage [ViewName]...' item.
|
||
This item should open the Manage View panel for the current user view.
|
||
"""
|
||
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 "manage-view" in body, (
|
||
"renderViewDropdown must include a 'Manage [ViewName]...' action with data-action='manage-view' "
|
||
"for user views — this replaced the old #add-sessions-btn header button"
|
||
)
|
||
|
||
|
||
# ─── Task 12: Session death detection ────────────────────────────────────────
|
||
|
||
|
||
def test_render_grid_closes_stale_flyout() -> None:
|
||
"""renderGrid must close the flyout if the targeted session no longer exists."""
|
||
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||
assert "_flyoutSessionKey" in fn_body or "closeFlyoutMenu" in fn_body, (
|
||
"renderGrid must check if the flyout's target session still exists and close if not"
|
||
)
|
||
|
||
|
||
# ─── Phase 3 COE findings regression tests ──────────────────────────────────
|
||
|
||
|
||
# ── BUG 1: tile click handler guards .tile-options-btn ───────────────────────
|
||
|
||
|
||
def test_tile_click_handler_guards_options_btn() -> None:
|
||
"""Tile click events are handled by session-tile component via tile-click/tile-options events.
|
||
|
||
With session-tile Lit components, the options-button guard is handled inside the component
|
||
itself. renderGrid listens for 'tile-click' and 'tile-options' custom events instead of
|
||
binding raw click handlers with .tile-options-btn guards.
|
||
"""
|
||
render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||
# session-tile components fire custom events; renderGrid must listen for them
|
||
assert "tile-click" in render_grid_body, (
|
||
"renderGrid must listen for 'tile-click' events from session-tile components"
|
||
)
|
||
assert "tile-options" in render_grid_body, (
|
||
"renderGrid must listen for 'tile-options' events from session-tile components"
|
||
)
|
||
|
||
|
||
def test_flyout_delegation_handler_no_stop_propagation() -> None:
|
||
"""bindStaticEventListeners .tile-options-btn delegation must NOT call e.stopPropagation().
|
||
|
||
BUG: e.stopPropagation() at document level is a no-op (document is the top of the
|
||
bubble chain). It created a false sense of correctness while doing nothing.
|
||
"""
|
||
# Extract the tile-options-btn delegation handler block from bindStaticEventListeners
|
||
bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[
|
||
0
|
||
]
|
||
# Find the section with tile-options-btn
|
||
assert "tile-options-btn" in bind_body, (
|
||
"bindStaticEventListeners must have .tile-options-btn delegation"
|
||
)
|
||
# The delegation block should not have stopPropagation
|
||
tile_opts_section = bind_body.split("tile-options-btn")[1].split("});")[0]
|
||
assert "stopPropagation" not in tile_opts_section, (
|
||
"The .tile-options-btn delegation handler must not call e.stopPropagation() — "
|
||
"it is a no-op at document level and creates false sense of correctness"
|
||
)
|
||
|
||
|
||
# ── BUG 2: disclosure has no hover-based show/hide in renderAddSessionsList ──
|
||
|
||
|
||
def test_render_manage_view_list_no_hover_disclosure() -> None:
|
||
"""renderManageViewList must NOT have mouseover/mouseout handlers for disclosure.
|
||
|
||
BUG: The disclosure was shown only on hover via JS mouseover/mouseout. This broke
|
||
on mobile (no hover) and was inconsistent. Fix: disclose statically — remove CSS
|
||
display:none and remove the hover handlers. The disclosure appears whenever the
|
||
hidden item is in the HTML (it's already conditionally rendered for hidden items).
|
||
"""
|
||
fn_body = _JS.split("function renderManageViewList")[1].split("\nfunction ")[0]
|
||
assert "onmouseover" not in fn_body, (
|
||
"renderManageViewList must not use onmouseover to show disclosure — "
|
||
"the disclosure must be statically visible (BUG 2 fix)"
|
||
)
|
||
assert "onmouseout" not in fn_body, (
|
||
"renderManageViewList must not use onmouseout to hide disclosure — "
|
||
"the disclosure must be statically visible (BUG 2 fix)"
|
||
)
|
||
|
||
|
||
# ── BUG 3: mobile kill has confirmation sheet ─────────────────────────────────
|
||
|
||
|
||
def test_open_mobile_kill_confirm_function_exists() -> None:
|
||
"""app.js must define _openMobileKillConfirm for mobile kill confirmation.
|
||
|
||
BUG: Mobile kill previously called killSession() directly with no confirmation.
|
||
Fix: A second bottom sheet confirms before killing.
|
||
"""
|
||
assert "function _openMobileKillConfirm" in _JS, (
|
||
"_openMobileKillConfirm must be defined — mobile kill needs a confirmation sheet"
|
||
)
|
||
|
||
|
||
def test_open_flyout_sheet_kill_calls_confirm_not_direct() -> None:
|
||
"""_openFlyoutSheet must call _openMobileKillConfirm, not killSession() directly.
|
||
|
||
BUG: Direct killSession() call gave no confirmation chance to the user on mobile.
|
||
"""
|
||
fn_body = _JS.split("function _openFlyoutSheet")[1].split("\nfunction ")[0]
|
||
assert "_openMobileKillConfirm" in fn_body, (
|
||
"Mobile kill action in _openFlyoutSheet must call _openMobileKillConfirm, not killSession() directly"
|
||
)
|
||
|
||
|
||
# ── VIOLATION 1: mobile Add to View opens view picker ────────────────────────
|
||
|
||
|
||
def test_open_mobile_view_picker_function_exists() -> None:
|
||
"""app.js must define _openMobileViewPicker for mobile view selection.
|
||
|
||
VIOLATION: Mobile 'Add to View...' opened openAddSessionsPanel() which is the
|
||
session picker for the current view — wrong behaviour. Fix: a view picker that
|
||
lists all user views with checkboxes.
|
||
"""
|
||
assert "function _openMobileViewPicker" in _JS, (
|
||
"_openMobileViewPicker must be defined — mobile 'Add to View...' must list views, not sessions"
|
||
)
|
||
|
||
|
||
def test_open_flyout_sheet_add_to_view_calls_view_picker() -> None:
|
||
"""_openFlyoutSheet must call _openMobileViewPicker, not openAddSessionsPanel.
|
||
|
||
VIOLATION: openAddSessionsPanel() is the session picker for the current view,
|
||
not a view picker for the current session.
|
||
"""
|
||
fn_body = _JS.split("function _openFlyoutSheet")[1].split("\nfunction ")[0]
|
||
assert "_openMobileViewPicker" in fn_body, (
|
||
"Mobile 'Add to View' must call _openMobileViewPicker, not openAddSessionsPanel"
|
||
)
|
||
# Confirm the old wrong call is gone from the mobile handler
|
||
# (openAddSessionsPanel may still exist for the grid affordance — just not here)
|
||
assert (
|
||
"openAddSessionsPanel"
|
||
not in fn_body.split("_openMobileViewPicker")[0].split(
|
||
"action === 'add-to-view'"
|
||
)[-1]
|
||
), "_openFlyoutSheet must not call openAddSessionsPanel for the add-to-view action"
|
||
|
||
|
||
# ── VIOLATION 2: submenu filters current view ────────────────────────────────
|
||
|
||
|
||
def test_open_flyout_submenu_filters_current_view() -> None:
|
||
"""_openFlyoutSubmenu must show ALL user views (unified views submenu, Fix 4).
|
||
|
||
Fix 4: The old behavior filtered out the current view because 'Remove from [ViewName]'
|
||
handled it. Now that 'Remove from [ViewName]' is removed, the submenu shows ALL
|
||
views with toggle checkmarks so the user can multi-toggle without the submenu closing.
|
||
"""
|
||
fn_body = _JS.split("function _openFlyoutSubmenu")[1].split("\nfunction ")[0]
|
||
# Must NOT have the old 'isInUserView' skip logic
|
||
assert "isInUserView" not in fn_body, (
|
||
"_openFlyoutSubmenu must not skip the current view — "
|
||
"Fix 4: unified submenu shows ALL views with toggle checkmarks"
|
||
)
|
||
# Must show checkmarks for views the session is in
|
||
assert "\u2713" in fn_body or "isIn" in fn_body, (
|
||
"_openFlyoutSubmenu must show checkmarks for views the session is already in"
|
||
)
|
||
|
||
|
||
# ── CLEANUP 2: sidebar kill button removed ────────────────────────────────────
|
||
|
||
|
||
def test_build_sidebar_html_no_sidebar_delete_button() -> None:
|
||
"""buildSidebarHTML must NOT include the .sidebar-delete kill button.
|
||
|
||
CLEANUP: The sidebar had a second kill location; flyout is now the only location.
|
||
"""
|
||
fn_body = _JS.split("function buildSidebarHTML")[1].split("\nfunction ")[0]
|
||
assert "sidebar-delete" not in fn_body, (
|
||
"buildSidebarHTML must not render .sidebar-delete button — "
|
||
"kill is available only via the flyout menu (single kill location)"
|
||
)
|
||
|
||
|
||
# ── CLEANUP 3: dead _addSessionToActiveView removed ───────────────────────────
|
||
|
||
|
||
def test_add_session_to_active_view_removed() -> None:
|
||
"""_addSessionToActiveView must be removed — renderAddSessionsList does same inline.
|
||
|
||
CLEANUP: The function was fully implemented but never called. Dead code removed.
|
||
"""
|
||
assert "_addSessionToActiveView" not in _JS, (
|
||
"_addSessionToActiveView must be removed — it was dead code never called; "
|
||
"renderAddSessionsList handles the same logic inline"
|
||
)
|
||
|
||
|
||
# ── CLEANUP 4: ARIA on mobile flyout sheet ────────────────────────────────────
|
||
|
||
|
||
def test_flyout_sheet_panel_has_aria_label() -> None:
|
||
"""_openFlyoutSheet must add aria-label='Session options' to the sheet panel.
|
||
|
||
CLEANUP: The panel had no ARIA labelling for screen readers.
|
||
"""
|
||
fn_body = _JS.split("function _openFlyoutSheet")[1].split("\nfunction ")[0]
|
||
assert "aria-label" in fn_body and "Session options" in fn_body, (
|
||
"_openFlyoutSheet must set aria-label='Session options' on the sheet panel"
|
||
)
|
||
|
||
|
||
def test_flyout_sheet_items_have_role_menuitem() -> None:
|
||
"""_openFlyoutSheet must add role='menuitem' to each sheet item button.
|
||
|
||
CLEANUP: Sheet items lacked ARIA role, breaking screen reader navigation.
|
||
"""
|
||
fn_body = _JS.split("function _openFlyoutSheet")[1].split("\nfunction ")[0]
|
||
assert 'role="menuitem"' in fn_body or "role='menuitem'" in fn_body, (
|
||
"_openFlyoutSheet must set role='menuitem' on each sheet item button"
|
||
)
|
||
|
||
|
||
# ── CLEANUP 5: FLYOUT_MENU_MAP is const ──────────────────────────────────────
|
||
|
||
|
||
def test_flyout_menu_map_is_const() -> None:
|
||
"""FLYOUT_MENU_MAP must be declared with const, not var.
|
||
|
||
CLEANUP: It is an immutable data structure; var was incorrect.
|
||
"""
|
||
assert "const FLYOUT_MENU_MAP" in _JS, (
|
||
"FLYOUT_MENU_MAP must be declared with 'const', not 'var' — it is immutable"
|
||
)
|
||
assert "var FLYOUT_MENU_MAP" not in _JS, (
|
||
"FLYOUT_MENU_MAP must not use 'var' — change to 'const'"
|
||
)
|
||
|
||
|
||
# ── Fix B: ARIA role correction in kill confirm sheet (Phase 3 COE re-verification) ──
|
||
|
||
|
||
# ─── Click-outside race condition guard (new-view dropdown) ─────────────────
|
||
|
||
|
||
def test_click_outside_view_dropdown_guards_against_new_view_input() -> None:
|
||
"""Click-outside handler must not close dropdown when new-view input is being shown.
|
||
|
||
Race condition: showNewViewInput() replaces the '+ New View' button with an
|
||
input via replaceChild. The click event then bubbles up to the document-level
|
||
click-outside handler, where e.target is the OLD button that was just removed
|
||
from the DOM. Since it is no longer in the DOM, dropdown.contains(e.target)
|
||
returns false and the handler calls closeViewDropdown(), making the input
|
||
disappear immediately.
|
||
|
||
Fix: before closing, check whether the dropdown now contains a
|
||
.view-dropdown__new-input element — if so, showNewViewInput() just ran and
|
||
we must not close the dropdown.
|
||
"""
|
||
match = re.search(
|
||
r"// Click-outside closes the header view dropdown\s*\n\s*"
|
||
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "Click-outside handler for header view dropdown not found"
|
||
body = match.group(1)
|
||
assert ".view-dropdown__new-input" in body, (
|
||
"Click-outside handler must guard: if (dropdown.querySelector"
|
||
"('.view-dropdown__new-input')) return; — prevents the race condition "
|
||
"where showNewViewInput() replaces the button with an input (removing "
|
||
"it from the DOM) and the bubbling click event incorrectly closes the dropdown"
|
||
)
|
||
|
||
|
||
def test_kill_confirm_buttons_use_role_button() -> None:
|
||
"""Kill and Cancel buttons inside the alertdialog must use role='button', not 'menuitem'.
|
||
|
||
Fix B: _openMobileKillConfirm() renders buttons inside a role='alertdialog'
|
||
panel. The ARIA spec requires buttons inside an alertdialog to carry
|
||
role='button' (not role='menuitem', which only belongs inside role='menu').
|
||
"""
|
||
# Negative check: role="menuitem" must NOT appear on the confirm-kill button
|
||
assert 'data-action="confirm-kill" role="menuitem"' not in _JS, (
|
||
"Kill button inside alertdialog must not use role='menuitem' — "
|
||
"use role='button' instead (menuitem is only valid inside role='menu')"
|
||
)
|
||
# Negative check: role="menuitem" must NOT appear on the cancel button
|
||
assert 'data-action="cancel" role="menuitem"' not in _JS, (
|
||
"Cancel button inside alertdialog must not use role='menuitem' — "
|
||
"use role='button' instead (menuitem is only valid inside role='menu')"
|
||
)
|
||
# Positive check: role="button" must appear on the confirm-kill button
|
||
assert 'data-action="confirm-kill" role="button"' in _JS, (
|
||
"Kill button inside alertdialog must use role='button'"
|
||
)
|
||
# Positive check: role="button" must appear on the cancel button
|
||
assert 'data-action="cancel" role="button"' in _JS, (
|
||
"Cancel button inside alertdialog must use role='button'"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# UX Refinements from live testing (6 issues)
|
||
# ============================================================
|
||
|
||
CSS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "style.css"
|
||
_CSS: str = CSS_PATH.read_text()
|
||
|
||
|
||
# — Issue 1: Sidebar dropdown "+ New View" ——————————————————————————
|
||
|
||
|
||
def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
|
||
"""renderSidebarViewDropdown must include a '+ New View' action button."""
|
||
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 'data-action="new-view"' in body, (
|
||
'renderSidebarViewDropdown must include a "+ New View" button with data-action="new-view"'
|
||
)
|
||
|
||
|
||
def test_show_sidebar_new_view_input_function_exists() -> None:
|
||
"""showSidebarNewViewInput function must exist in app.js."""
|
||
assert "function showSidebarNewViewInput" in _JS, (
|
||
"showSidebarNewViewInput must be defined in app.js"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_calls_show_sidebar_new_view_input() -> None:
|
||
"""bindStaticEventListeners sidebar dropdown handler must call showSidebarNewViewInput."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "showSidebarNewViewInput" in body, (
|
||
"bindStaticEventListeners must call showSidebarNewViewInput for sidebar new-view action"
|
||
)
|
||
|
||
|
||
# — Issue 2: Remove shortcut numbers, add session counts ———————————————
|
||
|
||
|
||
def test_render_view_dropdown_no_shortcut_spans() -> None:
|
||
"""renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed)."""
|
||
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__shortcut" not in body, (
|
||
"renderViewDropdown must not include view-dropdown__shortcut spans — shortcut numbers removed"
|
||
)
|
||
|
||
|
||
def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None:
|
||
"""renderSidebarViewDropdown must not include view-dropdown__shortcut spans."""
|
||
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__shortcut" not in body, (
|
||
"renderSidebarViewDropdown must not include view-dropdown__shortcut spans — shortcut numbers removed"
|
||
)
|
||
|
||
|
||
def test_render_view_dropdown_shows_user_view_session_count() -> None:
|
||
"""renderViewDropdown must show session count for user views.
|
||
|
||
Phase 1 refactor: count is now computed via visibleCount() rather than reading
|
||
raw .sessions.length. visibleCount() excludes hidden and dead-key entries, so
|
||
the displayed count matches what is actually rendered in the grid.
|
||
"""
|
||
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 "visibleCount" in body, (
|
||
"renderViewDropdown must use visibleCount() for user view session counts "
|
||
"(Phase 1 refactor — raw .sessions.length is replaced by the canonical filter)"
|
||
)
|
||
|
||
|
||
# — Issue 3: Empty new view opens Add Sessions panel ———————————————————
|
||
|
||
|
||
def test_show_new_view_input_calls_open_manage_view_panel() -> None:
|
||
"""showNewViewInput must call openManageViewPanel after creating a new view."""
|
||
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)
|
||
assert "openManageViewPanel" in body, (
|
||
"showNewViewInput must call openManageViewPanel() after creating a new view — "
|
||
"so the user immediately sees the Manage View panel for their empty view"
|
||
)
|
||
|
||
|
||
def test_show_sidebar_new_view_input_calls_open_manage_view_panel() -> None:
|
||
"""showSidebarNewViewInput must call openManageViewPanel after creating a new view."""
|
||
match = re.search(
|
||
r"function showSidebarNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "showSidebarNewViewInput function not found"
|
||
body = match.group(1)
|
||
assert "openManageViewPanel" in body, (
|
||
"showSidebarNewViewInput must call openManageViewPanel() after creating a new view"
|
||
)
|
||
|
||
|
||
# — Issue 4: Flyout submenu "+ New View" ———————————————————————————————
|
||
|
||
|
||
def test_open_flyout_submenu_has_new_view_option() -> None:
|
||
"""_openFlyoutSubmenu must include a '+ New View' option."""
|
||
match = re.search(
|
||
r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_openFlyoutSubmenu function not found"
|
||
body = match.group(1)
|
||
assert "new-view-in-flyout" in body, (
|
||
'_openFlyoutSubmenu must include a "+ New View" option with data-action="new-view-in-flyout"'
|
||
)
|
||
|
||
|
||
def test_open_flyout_submenu_new_view_creates_and_switches() -> None:
|
||
"""_openFlyoutSubmenu '+ New View' handler must create a view and switch to it."""
|
||
match = re.search(
|
||
r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "_openFlyoutSubmenu function not found"
|
||
body = match.group(1)
|
||
assert "new-view-in-flyout" in body, (
|
||
"'new-view-in-flyout' not found in _openFlyoutSubmenu"
|
||
)
|
||
# switchView and PATCH must appear in the function body (the handler for new-view-in-flyout)
|
||
assert "switchView" in body, (
|
||
'_openFlyoutSubmenu must call switchView — the "+ New View" handler needs to switch to the new view'
|
||
)
|
||
assert "PATCH" in body or "api(" in body, (
|
||
"_openFlyoutSubmenu must PATCH /api/settings to create the view"
|
||
)
|
||
|
||
|
||
# — Issue 5: Add Sessions header button ————————————————————————————————
|
||
|
||
|
||
def test_update_add_sessions_button_removed() -> None:
|
||
"""updateAddSessionsButton must be REMOVED — the + Add button is gone (Issue 5)."""
|
||
assert "function updateAddSessionsButton" not in _JS, (
|
||
"updateAddSessionsButton must be removed — the #add-sessions-btn header button "
|
||
"was removed and replaced by the 'Manage [ViewName]...' dropdown item"
|
||
)
|
||
|
||
|
||
def test_switch_view_no_update_add_sessions_button() -> None:
|
||
"""switchView must NOT call updateAddSessionsButton (it was removed in Issue 5)."""
|
||
match = re.search(
|
||
r"function switchView\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "switchView function not found"
|
||
body = match.group(1)
|
||
assert "updateAddSessionsButton" not in body, (
|
||
"switchView must not call updateAddSessionsButton() — it was removed in Issue 5"
|
||
)
|
||
|
||
|
||
def test_render_grid_no_muxplex_app_onclick() -> None:
|
||
"""renderGrid must not use the broken window.MuxplexApp.openManageViewPanel onclick."""
|
||
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||
assert "MuxplexApp.openAddSessionsPanel" not in fn_body, (
|
||
"renderGrid must not use onclick='window.MuxplexApp.openAddSessionsPanel()' — "
|
||
"this was broken; the Manage View entry point moved to the dropdown"
|
||
)
|
||
|
||
|
||
def test_bind_static_event_listeners_no_add_sessions_btn() -> None:
|
||
"""bindStaticEventListeners must NOT bind #add-sessions-btn (button was removed in Issue 5)."""
|
||
match = re.search(
|
||
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "bindStaticEventListeners function not found"
|
||
body = match.group(1)
|
||
assert "add-sessions-btn" not in body, (
|
||
"bindStaticEventListeners must not bind #add-sessions-btn — "
|
||
"the button was removed; Manage View is reached via the dropdown"
|
||
)
|
||
|
||
|
||
# — Issue 6: Tile header flexbox layout ————————————————————————————————
|
||
|
||
|
||
def test_build_tile_html_options_btn_inside_tile_header() -> None:
|
||
"""buildTileHTML must render tile-options-btn inside tile-header (before tile-body)."""
|
||
fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0]
|
||
tile_opts_pos = fn_body.find("tile-options-btn")
|
||
tile_body_pos = fn_body.find("tile-body")
|
||
assert tile_opts_pos >= 0, "tile-options-btn must appear in buildTileHTML"
|
||
assert tile_body_pos >= 0, "tile-body must appear in buildTileHTML"
|
||
assert tile_opts_pos < tile_body_pos, (
|
||
"tile-options-btn must appear before tile-body in the HTML string — "
|
||
"it must be inside tile-header (as an inline flex item), not positioned after tile-body"
|
||
)
|
||
|
||
|
||
def test_tile_options_btn_css_not_absolute() -> None:
|
||
"""CSS .tile-options-btn must not use position:absolute — prevent badge/button overlap."""
|
||
import re as _re
|
||
|
||
match = _re.search(r"\.tile-options-btn\s*\{([^}]*)\}", _CSS, _re.DOTALL)
|
||
assert match, ".tile-options-btn CSS rule not found"
|
||
rule_body = match.group(1)
|
||
assert (
|
||
"position: absolute" not in rule_body and "position:absolute" not in rule_body
|
||
), (
|
||
".tile-options-btn must not use position:absolute — "
|
||
"it should be an inline flex item inside tile-header to prevent device badge overlap"
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# UX Overhaul — 9 refinements from live testing
|
||
# ============================================================
|
||
|
||
|
||
# — Issue 1: Sidebar dropdown "New View" click-dismiss race ——————————————
|
||
|
||
|
||
def test_sidebar_click_outside_has_new_view_input_guard() -> None:
|
||
"""Sidebar click-outside handler must guard against new-view input dismiss race.
|
||
|
||
Race condition: clicking '+ New View' in the sidebar triggers the click-outside
|
||
handler before the input appears. Guard: check for .view-dropdown__new-input
|
||
presence and return early if found.
|
||
"""
|
||
match = re.search(
|
||
r"// Click-outside closes the sidebar view dropdown\s*\n\s*"
|
||
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "Click-outside handler for sidebar view dropdown not found"
|
||
body = match.group(1)
|
||
assert ".view-dropdown__new-input" in body, (
|
||
"Sidebar click-outside handler must guard: "
|
||
"if (sidebarDropdown.querySelector('.view-dropdown__new-input')) return; "
|
||
"— prevents race where the new-view input is dismissed immediately"
|
||
)
|
||
|
||
|
||
# — Issue 4: Dropdown structure — "Manage [ViewName]…" ————————————————
|
||
|
||
|
||
def test_render_view_dropdown_has_manage_view_item_for_user_view() -> None:
|
||
"""renderViewDropdown must include 'Manage [ViewName]...' action for user views."""
|
||
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 "manage-view" in body, (
|
||
"renderViewDropdown must include a 'Manage [ViewName]...' action "
|
||
"with data-action='manage-view' for user views"
|
||
)
|
||
|
||
|
||
def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None:
|
||
"""renderSidebarViewDropdown must include 'Manage [ViewName]...' action for user views."""
|
||
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 "manage-view" in body, (
|
||
"renderSidebarViewDropdown must include a 'Manage [ViewName]...' action "
|
||
"with data-action='manage-view' for user views"
|
||
)
|
||
|
||
|
||
# — Issue 5: No #add-sessions-btn + updateAddSessionsButton removed ————————
|
||
|
||
|
||
def test_domcontentloaded_no_update_add_sessions_button_call() -> None:
|
||
"""DOMContentLoaded handler must NOT call updateAddSessionsButton (it was removed)."""
|
||
assert "updateAddSessionsButton" not in _JS, (
|
||
"updateAddSessionsButton must be completely removed from app.js — "
|
||
"the #add-sessions-btn header button was replaced by the dropdown manage-view item"
|
||
)
|
||
|
||
|
||
# — Issue 6: Manage View settings tab ——————————————————————————————————
|
||
|
||
|
||
def test_views_settings_tab_has_manage_button_per_row() -> None:
|
||
"""renderViewsSettingsTab must include a 'Manage' button per view row."""
|
||
match = re.search(
|
||
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderViewsSettingsTab function not found"
|
||
body = match.group(1)
|
||
assert "manage-view" in body.lower() or "Manage" in body, (
|
||
"renderViewsSettingsTab must include a 'Manage' button per view row "
|
||
"that opens the Manage View panel for that view"
|
||
)
|
||
|
||
|
||
def test_views_settings_tab_has_new_view_button_at_bottom() -> None:
|
||
"""renderViewsSettingsTab must include a '+ New View' button at the bottom."""
|
||
match = re.search(
|
||
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderViewsSettingsTab function not found"
|
||
body = match.group(1)
|
||
assert "New View" in body or "new-view" in body, (
|
||
"renderViewsSettingsTab must include a '+ New View' button at the bottom"
|
||
)
|
||
|
||
|
||
def test_views_settings_tab_no_inline_rename_on_name_click() -> None:
|
||
"""renderViewsSettingsTab must NOT have click-to-rename on the name span.
|
||
|
||
Rename now lives in the Manage View panel, not in the settings tab.
|
||
"""
|
||
match = re.search(
|
||
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderViewsSettingsTab function not found"
|
||
body = match.group(1)
|
||
# The old inline rename code clicked on views-settings-name span to show an input
|
||
assert "views-settings-rename-input" not in body, (
|
||
"renderViewsSettingsTab must not have inline rename via views-settings-rename-input — "
|
||
"rename now lives in the Manage View panel"
|
||
)
|
||
|
||
|
||
# — Issue 7: Dropdown label fix on page reload —————————————————————————
|
||
|
||
|
||
def test_domcontentloaded_calls_render_view_dropdown_after_restore() -> None:
|
||
"""DOMContentLoaded must call renderViewDropdown() after restoreState() completes.
|
||
|
||
Bug: restoreState() sets _activeView but the dropdown label stays as 'All Sessions'.
|
||
Fix: call renderViewDropdown() after restoreState() so the label reflects _activeView.
|
||
"""
|
||
# Find the DOMContentLoaded handler section (from DOMContentLoaded to end of file)
|
||
idx = _JS.find("document.addEventListener('DOMContentLoaded'")
|
||
assert idx >= 0, "DOMContentLoaded handler not found"
|
||
handler_section = _JS[idx:]
|
||
# restoreState must appear before renderViewDropdown in the handler
|
||
restore_idx = handler_section.find("restoreState()")
|
||
assert restore_idx >= 0, "restoreState call not found in DOMContentLoaded section"
|
||
after_restore = handler_section[restore_idx:]
|
||
assert "renderViewDropdown" in after_restore, (
|
||
"DOMContentLoaded must call renderViewDropdown() after restoreState() so the "
|
||
"dropdown label correctly reflects _activeView on page reload"
|
||
)
|
||
|
||
|
||
# ── Sidebar flyout menu (feat: add flyout menu to sidebar session items) ──────
|
||
|
||
|
||
def test_build_sidebar_html_article_has_data_session_key() -> None:
|
||
"""buildSidebarHTML must include data-session-key on the article element."""
|
||
match = re.search(
|
||
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "buildSidebarHTML function not found"
|
||
body = match.group(1)
|
||
assert "data-session-key" in body, (
|
||
"buildSidebarHTML must include data-session-key attribute on the article element "
|
||
"so openFlyoutMenu can look up session data via closest('[data-session-key]')"
|
||
)
|
||
|
||
|
||
def test_build_sidebar_html_has_tile_options_btn() -> None:
|
||
"""buildSidebarHTML must include a .tile-options-btn button in the header."""
|
||
match = re.search(
|
||
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "buildSidebarHTML function not found"
|
||
body = match.group(1)
|
||
assert "tile-options-btn" in body, (
|
||
"buildSidebarHTML must include a button with class 'tile-options-btn' "
|
||
"in the sidebar item header so users can access the flyout menu"
|
||
)
|
||
|
||
|
||
def test_build_sidebar_html_options_btn_has_aria_label() -> None:
|
||
"""buildSidebarHTML tile-options-btn must have aria-label='Session options'."""
|
||
match = re.search(
|
||
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "buildSidebarHTML function not found"
|
||
body = match.group(1)
|
||
assert (
|
||
'aria-label="Session options"' in body or "aria-label='Session options'" in body
|
||
), (
|
||
"buildSidebarHTML tile-options-btn must have aria-label='Session options' "
|
||
"for accessibility (same as tile version)"
|
||
)
|
||
|
||
|
||
def test_build_sidebar_html_options_btn_has_aria_haspopup() -> None:
|
||
"""buildSidebarHTML tile-options-btn must have aria-haspopup='true'."""
|
||
match = re.search(
|
||
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "buildSidebarHTML function not found"
|
||
body = match.group(1)
|
||
assert 'aria-haspopup="true"' in body or "aria-haspopup='true'" in body, (
|
||
"buildSidebarHTML tile-options-btn must have aria-haspopup='true' "
|
||
"for accessibility (same as tile version)"
|
||
)
|
||
|
||
|
||
def test_render_sidebar_click_handler_guards_tile_options_btn() -> None:
|
||
"""renderSidebar click handler must guard against tile-options-btn clicks."""
|
||
match = re.search(
|
||
r"function renderSidebar\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||
_JS,
|
||
re.DOTALL,
|
||
)
|
||
assert match, "renderSidebar function not found"
|
||
body = match.group(1)
|
||
assert "tile-options-btn" in body, (
|
||
"renderSidebar click handler must guard against .tile-options-btn clicks "
|
||
"so clicking ⋮ doesn't also trigger openSession() — "
|
||
"use: if (e.target.closest('.tile-options-btn')) return;"
|
||
)
|