HERMES-GIELIE/tests/test_issue4662_profile_switch_skeleton_static.py
nesquena-hermes 4abdb54ede Phase 1 (#4662): tests for profile-switch loading skeletons
- behaviour: node-driven, runs the actual showSessionListSkeleton() /
  showWorkspaceTreeSkeleton() builders against a mock DOM and asserts the
  produced structure (3 group labels + 8 single-line rows with title+stamp;
  8 tree rows with glyph+name, 7 file rows with a size bar, 1 dir without).
- static: asserts switchToProfile() shows both skeletons up front before the
  /api/profile/switch POST, parallelizes the list+workspace refresh, restores
  real content on failure; renderSessionListFromCache clears the skeleton flag;
  style.css has the classes, sheen+fade keyframes, reduced-motion disable, and
  light+dark theme tokens.
2026-06-22 18:12:25 +00:00

100 lines
4.4 KiB
Python

"""Static-source assertions for the #4662 profile-switch loading skeletons.
These don't execute JS — they assert the source wiring so the behaviour can't
silently regress:
* switchToProfile() shows both skeletons up front (clears stale content),
parallelizes the independent list+workspace refreshes, and restores real
content on failure so a skeleton never strands.
* renderSessionListFromCache() clears the skeleton-active flag on real render.
* style.css defines the skeleton classes, the sheen + fade keyframes, the
reduced-motion fallback, and dark-mode tokens.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent.resolve()
PANELS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
SESSIONS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
WORKSPACE = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
def _switch_body() -> str:
start = PANELS.index("async function switchToProfile(")
# grab a generous slice (the function is long); next top-level function after it
end = PANELS.index("function openProfileCreate(", start)
return PANELS[start:end]
class TestSwitchWiring:
def test_shows_session_skeleton_up_front(self):
body = _switch_body()
assert "showSessionListSkeleton()" in body
# ...and before the awaited /api/profile/switch POST, so stale rows clear immediately
assert body.index("showSessionListSkeleton()") < body.index("/api/profile/switch")
def test_shows_workspace_skeleton_when_panel_open(self):
body = _switch_body()
assert "showWorkspaceTreeSkeleton()" in body
assert "_workspaceVisibleAtStart" in body
def test_parallelizes_list_and_workspace_refresh(self):
body = _switch_body()
# The non-sessionInProgress branch kicks off both fetches before awaiting.
assert "const listLoad = renderSessionList();" in body
assert "loadDir('.')" in body
assert "await listLoad;" in body
def test_restores_real_content_on_failure(self):
body = _switch_body()
catch = body[body.index("} catch (e) {"):]
assert "renderSessionListFromCache()" in catch, "failed switch must restore real list"
class TestSessionsWiring:
def test_skeleton_flag_cleared_on_real_render(self):
# renderSessionListFromCache clears the skeleton-active flag when it
# writes real rows (so a strand can't persist).
idx = SESSIONS.index("function renderSessionListFromCache(")
body = SESSIONS[idx: idx + 4000]
assert "_sessionListSkeletonActive=false" in body.replace(" ", "")
def test_builder_defines_groups_and_function(self):
assert "const _SESSION_SKELETON_GROUPS" in SESSIONS
assert "function showSessionListSkeleton(" in SESSIONS
class TestWorkspaceWiring:
def test_builder_defined(self):
assert "const _WS_SKELETON_ROWS" in WORKSPACE
assert "function showWorkspaceTreeSkeleton(" in WORKSPACE
class TestSkeletonCss:
def test_core_classes_present(self):
for cls in (".skeleton-list", ".skeleton-row", ".skeleton-bar",
".skeleton-group-label", ".skeleton-tree", ".skeleton-tree-row",
".skeleton-glyph"):
assert cls in CSS, f"missing skeleton CSS class {cls}"
def test_sheen_and_fade_keyframes(self):
assert "@keyframes skeletonSheen" in CSS
assert "@keyframes skeletonFadeIn" in CSS
assert "animation:skeletonSheen" in CSS.replace(" ", "")
def test_reduced_motion_disables_animation(self):
# There must be a prefers-reduced-motion block that turns the skeleton
# sheen animation off (accessibility contract).
compact = CSS.replace(" ", "")
assert "prefers-reduced-motion" in compact
# The reduced-motion rule names .skeleton-bar and sets animation:none.
rm_blocks = [b for b in compact.split("@media(prefers-reduced-motion:reduce){")
if ".skeleton-bar" in b[:400]]
assert rm_blocks, "no reduced-motion block scoping .skeleton-bar"
assert "animation:none" in rm_blocks[0][:400], "reduced-motion must disable the sheen"
def test_theme_tokens_defined_for_light_and_dark(self):
compact = CSS.replace(" ", "")
assert "--skeleton-base:" in compact
assert "--skeleton-sheen:" in compact
assert ":root.dark{--skeleton-base:" in compact