Files
hermes-webui/tests/test_issue4717_empty_profile_skeleton.py
T
nesquena-hermes 505465a81e feat(#4717): empty-profile loading skeleton (phase 1.5 of #4662)
The profile-switch skeleton (#4671) always rendered a fixed content shape; for
a brand-new/empty profile that flashed a misleading 'loading conversations'
skeleton that resolved to an empty list. Track each profile's last-seen session
count (localStorage, per profile name, recorded after each non-all-profiles
render) and, when switching into a profile KNOWN to have zero sessions, render a
quiet empty-state placeholder instead of the content skeleton. Unknown profiles
keep the content skeleton (safe default); skipped under an active project/source
filter (the count is an unfiltered total). prefers-reduced-motion + light/dark
aware. Frontend-only. 7 source-structural tests; Phase-1 skeleton tests still
green.
2026-06-23 05:44:57 +00:00

84 lines
3.8 KiB
Python

"""Phase 1.5 (#4717): empty/zero-conversation profile shows a quiet empty-state
skeleton on switch instead of a misleading content skeleton.
These are source-level structural guards (the skeleton logic is browser JS; the
behavioural check is a manual browser pass documented in the PR). They pin that
the per-profile count cache + the empty-branch wiring exist and are connected, so
a refactor can't silently drop the empty-profile affordance.
"""
import re
from pathlib import Path
import api.routes as routes # noqa: F401 (ensures repo import path is set up)
SESSIONS_JS = Path(__file__).resolve().parent.parent / "static" / "sessions.js"
PANELS_JS = Path(__file__).resolve().parent.parent / "static" / "panels.js"
STYLE_CSS = Path(__file__).resolve().parent.parent / "static" / "style.css"
def _sessions():
return SESSIONS_JS.read_text(encoding="utf-8")
def test_per_profile_count_cache_helpers_exist():
src = _sessions()
assert "SESSION_PROFILE_COUNTS_KEY" in src
assert "function _recordSessionProfileCount(" in src
assert "function _knownSessionProfileCount(" in src
def test_count_is_recorded_after_render():
"""The render path must persist the active profile's session count so the
next switch can consult it — only for an unfiltered, single-profile view."""
src = _sessions()
assert "_recordSessionProfileCount(_allSessionsScope.profile, _allSessions.length)" in src
# Gated on !all-profiles AND !filter-active so only an unfiltered total is cached
# (mirrors the read-side gate; a filtered subset must not cache a misleading 0).
record_idx = src.index("_recordSessionProfileCount(_allSessionsScope.profile")
window = src[record_idx - 400:record_idx]
assert "_showAllProfiles" in window, "count record must be gated on !_showAllProfiles"
assert "_recordFilterActive" in window, "count record must be gated on !filterActive (write/read parity)"
def test_skeleton_accepts_target_profile_and_has_empty_branch():
src = _sessions()
assert "function showSessionListSkeleton(targetProfile)" in src
# Empty branch keys off a KNOWN zero count (null/unknown keeps content skeleton).
assert "_knownSessionProfileCount(targetProfile)" in src
assert "knownCount === 0" in src
assert "skeleton-list-empty" in src
def test_empty_branch_skipped_when_filter_active():
"""A project/source filter makes the unfiltered per-profile count unreliable,
so the empty branch must be suppressed when a filter is active."""
src = _sessions()
assert "filterActive" in src
# The guard combines the known-zero count with !filterActive.
assert "knownCount === 0 && !filterActive" in src
def test_switch_call_site_passes_target_profile():
src = PANELS_JS.read_text(encoding="utf-8")
assert "showSessionListSkeleton(name)" in src, "profile switch must pass the target profile to the skeleton"
def test_empty_skeleton_css_respects_reduced_motion():
css = STYLE_CSS.read_text(encoding="utf-8")
assert ".skeleton-empty-hint" in css
# The empty hint must have a reduced-motion override (no animation).
rm_blocks = re.findall(r"@media\s*\(prefers-reduced-motion:reduce\)\s*\{[^}]*skeleton-empty-hint[^}]*\}", css)
assert rm_blocks, "skeleton-empty-hint must have a prefers-reduced-motion:reduce override"
def test_virtual_scroll_teardown_runs_for_skeleton():
"""The skeleton builder must tear down virtual-scroll state (the #4662
Codex-gate guard against stale-row repaint) — done once up front so it
applies to both the content and empty-state branches."""
src = _sessions()
idx = src.index("function showSessionListSkeleton(")
body = src[idx: idx + 2000]
assert "cancelAnimationFrame(_sessionVirtualScrollRaf)" in body
assert "delete list.dataset.sessionVirtualTotal" in body
assert "delete list.dataset.sessionVirtualActiveAnchor" in body