Files
hermes-webui/tests/test_project_chip_ui.py
nesquena-hermes 4b390e115c
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.310 — Release JZ (#3760 — long-press project chips to manage on touch) (#3767)
* feat(sidebar): long-press project chips to open the context menu on touch (#3760)

Project filter chips could only be deleted/renamed via the right-click context
menu (oncontextmenu), which has no touch equivalent — so mobile/tablet users had
no way to delete a project from the sidebar; the list grew forever.

Adds a 500ms long-press gesture mirroring the existing session-item long-press
pattern: touchstart schedules the menu, touchmove cancels on >10px drift,
touchend suppresses the synthetic click when the long-press fired, touchcancel
cleans up. `.project-chip.long-pressing` gives accent + slight-scale feedback;
`touch-action:manipulation` + `user-select:none` + `-webkit-touch-callout:none`
prevent the native callout/selection from competing.

Maintainer fix on top of the contributor PR (multi-touch correctness, flagged in
review): touchstart now clears any in-flight `_lpTimer` before scheduling a new
one (a second finger / stray touchstart previously orphaned the prior timer,
which then fired unsuppressed ~500ms later and popped the menu after the gesture
was cancelled), and the timer body bails if `_lpHandled` is already set so a
stale fire is a no-op — matching the session-item belt-and-suspenders. Also
dropped a stale, unrelated issue reference from the original comment.

Co-authored-by: reinocheong <[email protected]>

* docs(changelog): stamp v0.51.310 — Release JZ (stage-3760 long-press project chips)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 21:36:24 -07:00

232 lines
11 KiB
Python

"""Regression tests for the project chip UI fixes (issue #1085).
Two bugs:
1. The right-click context menu opened by `_showProjectContextMenu` was styled
with `background: var(--panel)`, but `--panel` is NOT defined anywhere in
style.css. CSS falls back to `transparent` for undefined variables, so the
menu appeared see-through and the session list bled through. The fix
replaces `var(--panel)` with `var(--surface)` — the same opaque variable
used by `.session-action-menu` and other floating popovers.
2. The `.project-create-input` (used for both rename and new-project creation)
had `width: 100px` hard-coded, so the field was always exactly 100px wide
regardless of the project name being edited. Fix: bound the field with
`min-width: 40px` / `max-width: 180px` and `width: auto`, plus a
`_resizeProjectInput()` JS helper that measures the current value with a
hidden span and sets the pixel width accordingly.
These are static-source tests — CSS/JS behaviour of a popover and an input
sizer can't be exercised faithfully without a browser, but the patterns
worth pinning are the variable names, the absence of the bad ones, and the
presence of the resize helper at both call sites.
"""
import pathlib
REPO = pathlib.Path(__file__).parent.parent
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
# ── Bug 1: context menu background ────────────────────────────────────────────
class TestContextMenuBackground:
def test_panel_variable_not_defined_in_stylesheet(self):
"""`--panel` is not defined as a CSS custom property anywhere — so
any rule using `var(--panel)` falls back to `transparent`, which is
the actual root cause of the menu bleed-through. This test
documents that fact: if `--panel` is ever defined, the test will
need updating but the fix is still safer using `--surface`."""
# Match either ":root --panel:" or "--panel:" assignments; absence
# confirms the fallback-to-transparent failure mode.
assert "--panel:" not in STYLE_CSS, (
"If --panel is now defined, update this test, but the menu "
"should still use --surface for consistency with other popovers."
)
def test_context_menu_uses_surface_not_panel(self):
"""`_showProjectContextMenu` must set the menu background to
`var(--surface)`, not `var(--panel)`."""
# Locate the menu construction
idx = SESSIONS_JS.find("project-ctx-menu")
assert idx >= 0, "project-ctx-menu className not found in sessions.js"
# Look at the surrounding 800 chars where the cssText is set
window = SESSIONS_JS[idx: idx + 1200]
assert "background:var(--surface)" in window, (
"Project context menu must use background:var(--surface) for an "
"opaque surface — var(--panel) is undefined and falls back to "
"transparent."
)
assert "background:var(--panel)" not in window, (
"Project context menu still uses background:var(--panel) — "
"this CSS variable is not defined and renders transparent."
)
def test_session_action_menu_also_uses_surface_for_consistency(self):
"""Sanity check: the existing .session-action-menu (the analogous
right-click menu for session items) uses `var(--surface)` — so the
fix is consistent with the rest of the codebase."""
assert "session-action-menu" in STYLE_CSS
# Find the rule and confirm it uses --surface
idx = STYLE_CSS.find(".session-action-menu")
assert idx >= 0
rule = STYLE_CSS[idx: idx + 400]
assert "var(--surface)" in rule, (
".session-action-menu should use var(--surface) — kept here as "
"the canonical reference for opaque popover surfaces."
)
# ── Bug 2: project-create-input width ─────────────────────────────────────────
class TestProjectCreateInputWidth:
def test_no_hardcoded_100px_width(self):
"""The fixed `width: 100px` on .project-create-input is gone."""
idx = STYLE_CSS.find(".project-create-input{")
assert idx >= 0, ".project-create-input rule not found in style.css"
rule = STYLE_CSS[idx: idx + 400]
assert "width:100px" not in rule and "width: 100px" not in rule, (
"Fixed 100px width must be replaced with min-width/max-width/"
"width:auto so the input grows with its content."
)
def test_min_and_max_width_present(self):
"""Both min-width and max-width must be set on .project-create-input."""
idx = STYLE_CSS.find(".project-create-input{")
rule = STYLE_CSS[idx: idx + 400]
assert "min-width:40px" in rule, (
f"min-width:40px not found in .project-create-input rule: {rule}"
)
assert "max-width:180px" in rule, (
f"max-width:180px not found in .project-create-input rule: {rule}"
)
assert "width:auto" in rule, (
f"width:auto not found in .project-create-input rule: {rule}"
)
class TestResizeProjectInputHelper:
"""The `_resizeProjectInput` helper must exist and be wired into both
rename and create call sites."""
def test_resize_helper_defined(self):
assert "function _resizeProjectInput(" in SESSIONS_JS, (
"_resizeProjectInput helper not found in sessions.js"
)
def test_resize_helper_uses_hidden_span(self):
"""The standard pattern is to measure with a hidden absolute span
sharing the same font/padding as the input. Font and family are read
via getComputedStyle so the sizer stays calibrated if CSS changes."""
idx = SESSIONS_JS.find("function _resizeProjectInput(")
assert idx >= 0
body = SESSIONS_JS[idx: idx + 900]
assert "position:absolute" in body and "visibility:hidden" in body, (
"_resizeProjectInput should use a hidden absolute span to "
"measure the value's rendered width."
)
assert "getComputedStyle(inp)" in body, (
"_resizeProjectInput should use getComputedStyle to read font " "properties so the sizer stays calibrated if CSS changes."
)
assert "Math.min(180" in body, (
"max bound (180) not applied in _resizeProjectInput"
)
assert "Math.max(40" in body, (
"min bound (40) not applied in _resizeProjectInput"
)
def test_rename_calls_resize_helper(self):
"""`_startProjectRename` must call `_resizeProjectInput` once on
creation and again on every input event."""
idx = SESSIONS_JS.find("function _startProjectRename(")
assert idx >= 0
body = SESSIONS_JS[idx: idx + 1200]
assert "_resizeProjectInput(inp)" in body, (
"_startProjectRename must call _resizeProjectInput so the "
"input width matches the existing project name."
)
# Wired into the input event so it grows as the user types
assert "addEventListener('input'" in body and "_resizeProjectInput" in body, (
"_startProjectRename must wire input events to _resizeProjectInput"
)
def test_create_calls_resize_helper(self):
"""Same for `_startProjectCreate` (new-project entry field)."""
idx = SESSIONS_JS.find("function _startProjectCreate(")
assert idx >= 0
body = SESSIONS_JS[idx: idx + 1200]
assert "_resizeProjectInput(inp)" in body, (
"_startProjectCreate must call _resizeProjectInput on focus"
)
assert "addEventListener('input'" in body, (
"_startProjectCreate must wire input events to _resizeProjectInput"
)
class TestProjectChipLongPressTouch:
"""Mobile long-press to open the project context menu (#3760).
Project chips were deletable only via the right-click context menu, which has
no touch equivalent — so mobile users could never remove a project. A 500ms
long-press now opens the same menu.
"""
def _chip_touch_block(self):
# The chip touch handlers live just after the oncontextmenu wiring in the
# project-chip render loop.
idx = SESSIONS_JS.find("Touch long-press")
assert idx != -1, "project-chip long-press touch block not found"
return SESSIONS_JS[idx: idx + 2300]
def test_long_press_opens_project_context_menu(self):
block = self._chip_touch_block()
assert "addEventListener('touchstart'" in block
assert "setTimeout(" in block and "},500);" in block
assert "_showProjectContextMenu(" in block
# visual feedback + scroll-drift cancel, mirroring the session-item pattern
assert "long-pressing" in block
assert "addEventListener('touchmove'" in block
assert ">10" in block # >10px drift cancels the press
def test_long_press_suppresses_synthetic_click_and_filter_tap(self):
block = self._chip_touch_block()
# touchend must be non-passive so it can preventDefault the synthetic click
assert "addEventListener('touchend'" in block
assert "{passive:false}" in block
assert "e.preventDefault();e.stopPropagation();" in block
# the long-press handler cancels the pending single-tap filter timer
assert "clearTimeout(_pClickTimer)" in block
def test_touchstart_clears_inflight_timer_before_scheduling(self):
"""Regression: a second finger / stray touchstart must not orphan the
prior timer (which would then fire the menu after the gesture was
cancelled). touchstart clears any in-flight _lpTimer before scheduling,
and the timer body bails if the gesture was already consumed.
"""
block = self._chip_touch_block()
# clear-before-schedule at the top of touchstart
assert "if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;}" in block, (
"touchstart must clear any in-flight long-press timer before scheduling "
"a new one (orphaned-timer fix)"
)
# stale-fire guard inside the timer body
assert "if(_lpHandled) return;" in block, (
"the long-press timer body must no-op if the gesture was already consumed"
)
def test_long_pressing_style_feedback_present(self):
assert ".project-chip.long-pressing" in STYLE_CSS
# Target the base .project-chip rule (the one carrying the layout props),
# not an unrelated theme override of the same selector.
base_idx = STYLE_CSS.find(".project-chip{font-size")
assert base_idx != -1, "base .project-chip rule not found"
chip_rule = STYLE_CSS[base_idx: STYLE_CSS.find("}", base_idx) + 1]
# touch tuning so the native callout/selection doesn't compete with the gesture
assert "touch-action:manipulation" in chip_rule
assert "user-select:none" in chip_rule