Files
hermes-webui/tests/test_issue5497_preferences_model_picker.py
nesquena-hermesandRod Boev c2e9277c46 release: reuse the rich model picker in Preferences (#5502)
Generalizes renderModelDropdown() to serve both the composer and Preferences pickers (backward-compatible defaults keep the composer identical). Folded in Fable UX fixes: label->chip association, downward shadow for the down-opening settings picker, and mobile keyboard-pop suppression across all 3 render paths (open/internal-filter/refresh) with focus preserved while typing. Full gate (4 rounds): Codex SAFE, Fable SHIP-UX, suite 12153/0, browser clean; composer picker verified unchanged.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-07-05 23:22:31 +00:00

140 lines
6.8 KiB
Python

"""Regression coverage for #5497 Preferences default model picker parity."""
from __future__ import annotations
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
match = re.search(rf"function\s+{re.escape(name)}\s*\([^)]*\)\s*\{{", src)
assert match, f"{name} not found"
start = match.end()
depth = 1
pos = start
while pos < len(src) and depth:
if src[pos] == "{":
depth += 1
elif src[pos] == "}":
depth -= 1
pos += 1
assert depth == 0, f"{name} body did not terminate"
return src[start : pos - 1]
def test_preferences_default_model_field_keeps_source_select_and_adds_rich_picker_shell():
field_start = INDEX_HTML.index('id="settingsModelChip"')
field_end = INDEX_HTML.index('data-i18n="settings_desc_model"', field_start)
field = INDEX_HTML[field_start:field_end]
assert 'id="settingsModelChip"' in field
assert 'id="settingsModel"' in field
assert 'id="settingsModelDropdown"' in field
assert 'class="model-dropdown settings-model-dropdown"' in field
assert 'id="mainAdvancedBtn"' in field
assert field.index('id="settingsModelChip"') < field.index('id="mainAdvancedBtn"')
def test_settings_model_select_is_hidden_but_remains_advanced_options_source():
assert ".model-advanced-row #settingsModel" in STYLE_CSS
assert "display:none;" in STYLE_CSS[STYLE_CSS.index(".model-advanced-row #settingsModel") : STYLE_CSS.index(".model-advanced-btn")]
bind_body = _function_body(PANELS_JS, "_bindMainAdvancedOptionsButton")
assert "const modelSel=$('settingsModel');" in bind_body
assert "modelSel.parentElement" in bind_body
assert "mainAdvancedBtn" in bind_body
def test_render_model_dropdown_accepts_settings_select_and_callbacks():
body = _function_body(UI_JS, "renderModelDropdown")
assert "opts.dropdownId||'composerModelDropdown'" in body
assert "opts.selectId||'modelSelect'" in body
assert "opts.selectModel" in body
assert "opts.closeDropdown" in body
assert "selectFromDropdown(m.value" in body
assert "row.onclick=()=>selectModelFromDropdown" not in body
assert "closeDropdown();return;" in body
assert "opts.forceOpenKey||'composer'" in body
assert "renderModelDropdown(opts)" in body
assert "opts.scopeNoteText||" in body
def test_preferences_picker_routes_through_shared_renderer_without_panel_copy():
mount_body = _function_body(UI_JS, "openSettingsModelDropdown")
select_body = _function_body(UI_JS, "selectSettingsModelFromDropdown")
panels_model_block = PANELS_JS[
PANELS_JS.index("const modelSel=$('settingsModel');") : PANELS_JS.index("// Auxiliary models", PANELS_JS.index("const modelSel=$('settingsModel');"))
]
assert "renderModelDropdown({" in mount_body
assert "dropdownId:'settingsModelDropdown'" in mount_body
assert "selectId:'settingsModel'" in mount_body
assert "selectModel:selectSettingsModelFromDropdown" in mount_body
assert "scopeNoteText:t('settings_desc_model')" in mount_body
assert "_ensureModelOptionInDropdown(value,sel,provider)" in select_body
assert "sel.dispatchEvent(new Event('change',{bubbles:true}))" in select_body
assert "syncSettingsModelChip" in select_body
assert "closeSettingsModelDropdown" in panels_model_block
assert "mountSettingsModelPicker" in panels_model_block
assert "_settingsChipSyncBound" in panels_model_block
assert ".model-opt" not in PANELS_JS
assert "model-search-input" not in PANELS_JS
def test_preferences_picker_refreshes_existing_save_and_dirty_contracts():
autosave_body = _function_body(PANELS_JS, "_autosavePreferencesSettings")
save_body = _function_body(PANELS_JS, "saveSettings")
refresh_body = _function_body(UI_JS, "_refreshOpenModelDropdown")
assert "_captureModelDropdownSelection(modelSel)" in autosave_body
assert "_captureModelDropdownSelection($('settingsModel'))" in save_body
assert "await api('/api/default-model',{method:'POST',body:JSON.stringify({model,provider:modelState.model_provider||null})});" in save_body
assert "settingsModelDropdown" in refresh_body
assert "selectModel:selectSettingsModelFromDropdown" in refresh_body
def test_preferences_picker_ux_fixes_label_touchfocus_shadow():
"""Fable SHIP-WITH-UX-FIXES fold-in (#5502):
(1) the field label targets the interactive chip, not the hidden native select;
(2) the search auto-focus is skipped on coarse-pointer (no mobile keyboard pop);
(3) the downward-opening Preferences dropdown casts its shadow downward.
"""
# (1) label association -> the visible/interactive chip
assert 'for="settingsModelChip"' in INDEX_HTML
assert 'for="settingsModel"' not in INDEX_HTML # no longer points at the hidden select
# (2) coarse-pointer guard around the search auto-focus in the settings open path,
# passed through to renderModelDropdown so its OWN initial focus is suppressed too.
open_body = _function_body(UI_JS, "openSettingsModelDropdown")
assert "matchMedia('(pointer: coarse)')" in open_body
assert "autoFocusSearch:!_coarsePointer" in open_body
guard_idx = open_body.index("matchMedia('(pointer: coarse)')")
render_idx = open_body.index("renderModelDropdown(")
assert guard_idx < render_idx, "coarse-pointer must be computed before the render call"
# renderModelDropdown honors the option and defaults it true (composer unchanged).
render_body = _function_body(UI_JS, "renderModelDropdown")
assert "opts.autoFocusSearch!==false" in render_body
assert "if(_autoFocusSearch||_hadFocus) _si.focus();" in render_body
# Focus is still restored during typing (touch user in the search) — the
# suppression must NOT drop focus mid-word on the per-keystroke re-render.
assert "document.activeElement===_si" in render_body
# The OPEN-picker refresh path (late live-model fetch) must also suppress focus
# on touch for the settings branch, or the keyboard pops after opening.
refresh_body = _function_body(UI_JS, "_refreshOpenModelDropdown")
settings_branch = refresh_body[refresh_body.index("settingsModelDropdown"):]
assert "autoFocusSearch:!_coarsePointer" in settings_branch
assert "matchMedia('(pointer: coarse)')" in settings_branch
# (3) downward shadow override on the settings dropdown block
block_start = STYLE_CSS.index(".settings-model-dropdown{")
block = STYLE_CSS[block_start : STYLE_CSS.index("}", block_start) + 1]
assert "box-shadow:0 4px 24px" in block, "settings dropdown should cast its shadow downward"