from pathlib import Path


ROOT = Path(__file__).resolve().parent.parent
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")


def _block(source: str, start_marker: str, end_marker: str) -> str:
    start = source.find(start_marker)
    assert start != -1, f"{start_marker!r} not found"
    end = source.find(end_marker, start)
    assert end != -1, f"{end_marker!r} not found after {start_marker!r}"
    return source[start:end]


def _sessions_block(start_marker: str, end_marker: str) -> str:
    return _block(SESSIONS_JS, start_marker, end_marker)


def test_session_menu_uses_viewport_height_not_fixed_scroll_cap():
    assert "max-height:calc(100vh - 16px)" in STYLE_CSS
    session_menu = STYLE_CSS[STYLE_CSS.find(".session-action-menu{"):STYLE_CSS.find(".session-action-menu.open")]
    assert "max-height:320px" not in session_menu


def test_session_menu_respects_motion_preferences():
    session_menu = STYLE_CSS[STYLE_CSS.find(".session-action-menu{"):STYLE_CSS.find(".session-action-menu.open")]
    assert "will-change:opacity,transform" in session_menu
    assert "function _playSessionActionMenuEntrance(menu){" in SESSIONS_JS
    assert "menu.classList.add('open-animated')" in SESSIONS_JS
    assert "@media (prefers-reduced-motion:reduce)" in STYLE_CSS
    assert ".session-action-menu{animation:none;will-change:auto;}" in STYLE_CSS


def test_session_action_menu_exposes_popup_state_and_focus_contract():
    """A portaled menu must stay discoverable from its sidebar trigger.

    The fixed-position menu intentionally lives under document.body to avoid
    clipping, so a screen reader cannot rely on DOM proximity. Opening it must
    expose the popup relationship and move focus to an actionable item; Escape
    must return focus to the trigger.
    """
    assert SESSIONS_JS.count("menuBtn.setAttribute('aria-expanded','false');") == 2
    assert "menu.setAttribute('role','menu');" in SESSIONS_JS
    assert "menu.setAttribute('aria-label', 'Conversation actions');" in SESSIONS_JS
    assert "opt.setAttribute('role','menuitem');" in SESSIONS_JS

    mount = _sessions_block("function _mountSessionActionMenu(menu, session, anchorEl){", "function _findSessionRenameRow")
    assert "anchorEl.setAttribute('aria-expanded','true');" in mount
    assert "anchorEl.setAttribute('aria-controls',menu.id);" in mount
    assert "firstAction.focus({preventScroll:true});" in mount

    close = _sessions_block("function closeSessionActionMenu", "function _sessionActionMenuShouldIgnoreScrollTarget")
    assert "_sessionActionAnchor.setAttribute('aria-expanded','false');" in close
    assert "_sessionActionAnchor.removeAttribute('aria-controls');" in close
    assert "restoreFocus" in close
    assert "fallbackFocusTarget=restoreFocus?_sessionActionPreviousFocus:null;" in close
    assert "_focusSessionActionMenuRestoreTarget(focusTarget)" in close
    assert "_focusSessionActionMenuRestoreTarget(fallbackFocusTarget)" in close

    escape_handler = _sessions_block("document.addEventListener('keydown',e=>{", "window.addEventListener('resize'")
    assert "closeSessionActionMenu({restoreFocus:true});" in escape_handler


def test_session_action_menu_supports_arrow_key_navigation():
    mount = _sessions_block("function _mountSessionActionMenu(menu, session, anchorEl){", "function _findSessionRenameRow")
    for key in ("'ArrowDown'", "'ArrowUp'", "'Home'", "'End'"):
        assert key in mount
    assert "menu.addEventListener('keydown'" in mount


def test_mobile_session_menu_opens_from_long_press_and_hides_dots():
    assert "const SESSION_LONG_PRESS_DELAY_MS = 400;" in SESSIONS_JS
    assert "el.classList.add('long-pressing')" in SESSIONS_JS
    assert "if(!_longPressMenuOpened) el.classList.remove('long-pressing')" in SESSIONS_JS
    assert "row.classList.remove('menu-open','long-pressing')" in SESSIONS_JS
    assert "_openSessionActionMenu(s, el)" in SESSIONS_JS
    assert "@media (hover:none) and (pointer:coarse)" in STYLE_CSS
    assert ".session-actions{display:none;}" in STYLE_CSS
    assert "const _beginSessionGesture=(clientX,clientY,pointerType='')=>{" in SESSIONS_JS
    assert "const _scheduleSessionLongPressMenu=()=>{" in SESSIONS_JS
    mobile_touch = STYLE_CSS[STYLE_CSS.find("@media (hover:none) and (pointer:coarse)"):STYLE_CSS.find("@media (max-width: 340px)")]
    assert ".session-item{padding-right:6px;}" in mobile_touch
    assert ".session-item.streaming,.session-item.unread{padding-right:40px;}" in mobile_touch
    assert ".session-item:focus-within,.session-item.menu-open{padding-right:6px;}" in mobile_touch


def test_nested_fork_mobile_menu_uses_long_press_fallback():
    assert "const _scheduleForkLongPressMenu=()=>{" in SESSIONS_JS
    assert "rowEl.classList.add('long-pressing')" in SESSIONS_JS
    assert "_openSessionActionMenu(childSession, rowEl);" in SESSIONS_JS
    assert "rowEl._skipNextChildOpen=true;" in SESSIONS_JS
    assert "if(row._skipNextChildOpen){" in SESSIONS_JS
    assert ".session-child-session-fork.long-pressing{" in STYLE_CSS


def test_open_session_menu_consumes_next_row_activation():
    context_menu = _sessions_block("el.oncontextmenu=(e)=>{", "// Use release events")
    assert SESSIONS_JS.count("el.oncontextmenu=(e)=>{") == 1
    assert "if(e.pointerType==='touch'||e.pointerType==='pen') return;" in context_menu
    assert "_openSessionActionMenu(s, actions||el);" in context_menu
    assert "if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){" in SESSIONS_JS
    assert "closeSessionActionMenu();" in SESSIONS_JS
    assert "e.stopPropagation();" in SESSIONS_JS
    assert "const stopMenuPointer=(e)=>e.stopPropagation();" in SESSIONS_JS
    assert "menuBtn.onpointerdown=stopMenuPointer;" in SESSIONS_JS
    assert "menuBtn.onpointerup=stopMenuPointer;" in SESSIONS_JS
    menu_btn_idx = SESSIONS_JS.find("menuBtn.onpointerdown=stopMenuPointer;")
    menu_click_idx = SESSIONS_JS.find("menuBtn.onclick=(e)=>{", menu_btn_idx)
    assert menu_btn_idx > 0 and menu_click_idx > menu_btn_idx
    assert "const _isSessionActionTarget=(target)=>{" in SESSIONS_JS
    assert "return !!(actions&&target&&actions.contains(target));" in SESSIONS_JS
    assert "if(_isSessionActionTarget(e.target)) return;" in SESSIONS_JS
    assert "if(_isSessionActionTarget(target)){_gestureState='idle';return false;}" in SESSIONS_JS
    assert "if(_longPressMenuOpened){_gestureState='idle';return true;}" in SESSIONS_JS
    finish_idx = SESSIONS_JS.find("const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{")
    dismiss_idx = SESSIONS_JS.find("if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){", finish_idx)
    open_idx = SESSIONS_JS.find("await _openSidebarSession(s)", finish_idx)
    pointerup_idx = SESSIONS_JS.find("el.onpointerup=(e)=>{")
    assert finish_idx > 0 and open_idx > finish_idx
    assert dismiss_idx > finish_idx and dismiss_idx < open_idx
    assert "if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation();" in SESSIONS_JS[pointerup_idx:]


def test_session_swipes_route_archive_restore_and_delete():
    assert "_gesturePointerType!=='mouse'" in SESSIONS_JS
    assert "_swipeTracking=true" in SESSIONS_JS
    assert "const SESSION_ARCHIVE_SWIPE_THRESHOLD_PX = 128;" in SESSIONS_JS
    assert "const SESSION_DELETE_SWIPE_THRESHOLD_PX = 128;" in SESSIONS_JS
    assert "const SESSION_SWIPE_DURATION_MS = 500;" in SESSIONS_JS
    assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 220;" in SESSIONS_JS
    swipe_block = _sessions_block("const _handleSessionSwipe=(signedDx,signedDy)=>{", "const _commitSessionSwipe=()=>{")
    assert "const actionThreshold=signedDx>0?SESSION_ARCHIVE_SWIPE_THRESHOLD_PX:SESSION_DELETE_SWIPE_THRESHOLD_PX;" in SESSIONS_JS
    assert "if(Math.abs(signedDx)<actionThreshold) return false;" in SESSIONS_JS
    assert "const _updateSessionGesture=(clientX,clientY)=>{" in SESSIONS_JS
    assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS
    assert "if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault();" in SESSIONS_JS
    assert "_beginSessionGesture(touch.clientX,touch.clientY,'touch');" in SESSIONS_JS
    restore_start = swipe_block.find("if(s.archived){")
    archive_start = swipe_block.find("}else{", restore_start)
    delete_start = swipe_block.find("}else if(_canSwipeDeleteSession()){", archive_start)
    restore_branch = swipe_block[restore_start:archive_start]
    archive_branch = swipe_block[archive_start:delete_start]
    delete_branch = swipe_block[delete_start:]
    assert "_settleSessionSwipePaint();" in restore_branch
    assert "_completeSessionSwipePaint(signedDx);" not in restore_branch
    assert "_archiveSession(s,false,()=>_waitForSessionMotion(committedSwipeDuration))" in restore_branch
    archived_visible_start = swipe_block.find("}else if(_showArchived){")
    assert archived_visible_start > restore_start
    archived_visible_branch = swipe_block[archived_visible_start:archive_start]
    assert "_settleSessionSwipePaint();" in archived_visible_branch
    assert "_completeSessionSwipePaint(signedDx);" not in archived_visible_branch
    assert "_archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeDuration))" in archived_visible_branch
    assert archive_branch.find("_completeSessionSwipePaint(signedDx);") < archive_branch.find("_archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeReflowDelay))")
    assert delete_branch.find("deleteSession(s.session_id,async()=>{") < delete_branch.find("_completeSessionSwipePaint(signedDx);")
    assert "showToast('Imported sessions cannot be deleted here.',3000);" in SESSIONS_JS
    assert "let _gestureState='idle';" in SESSIONS_JS
    assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3
    assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS


def test_session_swipe_paint_uses_transform_only_exit():
    paint = _sessions_block("const _paintSessionSwipe=(signedDx)=>{", "const _clearSessionSwipePaint=()=>{")
    clear = _sessions_block("const _clearSessionSwipePaint=()=>{", "const _settleSessionSwipePaint=()=>{")
    complete = _sessions_block("const _completeSessionSwipePaint=(signedDx)=>{", "const _handleSessionSwipe=(signedDx,signedDy)=>{")
    assert "--session-swipe-offset" in paint
    assert "--session-swipe-reveal" in paint
    assert "--session-swipe-progress" in paint
    assert "--session-swipe-badge-stretch" in paint
    assert "const reveal=Math.abs(offset);" in paint
    assert "const actionRevealScale=1.15;" in paint
    assert "const iconScale=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint
    assert "const badgeSize=34*iconScale;" in paint
    assert "const iconSize=18*iconScale;" in paint
    assert "const labelScale=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint
    assert "const actionOpacity=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint
    assert "const actionInset=6;" in paint
    assert "const tileGap=6;" in paint
    assert "const stretchStart=72/actionRevealScale;" in paint
    assert "const stretchProgress=Math.max(0,reveal-stretchStart);" in paint
    assert "const badgeStretch=Math.min(Math.max(0,reveal-34),stretchProgress*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap));" in paint
    assert "el.style.setProperty('--session-swipe-badge-size',badgeSize+'px');" in paint
    assert "el.style.setProperty('--session-swipe-icon-size',iconSize+'px');" in paint
    assert "el.style.setProperty('--session-swipe-label-scale',labelScale);" in paint
    assert "el.style.setProperty('--session-swipe-progress',actionOpacity);" in paint
    assert "Math.pow(progress,1.5)" not in paint
    assert "window.innerWidth+'px'" in complete
    assert "el.style.height=rect.height+'px'" in complete
    assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in complete
    assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS
    assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear
    assert "el.style.removeProperty('--session-swipe-badge-size');" in clear
    assert "el.style.removeProperty('--session-swipe-icon-size');" in clear
    assert "el.style.removeProperty('--session-swipe-label-scale');" in clear
    assert "el.style.removeProperty('--session-swipe-badge-stretch');" in clear
    assert ".session-item.swipe-committed,\n  .session-item.swipe-removing{transition:" in STYLE_CSS
    assert "transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0)" in STYLE_CSS
    assert "transform:scale(var(--session-swipe-icon-scale,1))" not in STYLE_CSS
    swipe_start = STYLE_CSS.find(".session-item.swipe-removing{")
    swipe_end = STYLE_CSS.find("}", swipe_start)
    assert swipe_start >= 0 and swipe_end > swipe_start
    swipe_removing = STYLE_CSS[swipe_start:swipe_end]
    assert "height:0" not in swipe_removing
    assert "padding-top:0" not in swipe_removing
    assert "margin-bottom:0" not in swipe_removing
    assert "transform:translate3d(var(--session-swipe-offset,0),var(--session-reflow-offset,0),0)" in STYLE_CSS


def test_session_swipe_actions_use_circular_icon_badges():
    right_start = STYLE_CSS.find(".session-swipe-affordance-right{")
    left_start = STYLE_CSS.find(".session-swipe-affordance-left{")
    badge_start = STYLE_CSS.find(".session-swipe-badge{")
    right = STYLE_CSS[right_start:left_start]
    left = STYLE_CSS[left_start:STYLE_CSS.find(".session-item.swiping-right", left_start)]
    badge = STYLE_CSS[badge_start:STYLE_CSS.find(".session-swipe-badge svg", badge_start)]
    assert "background:transparent" in right
    assert "background:transparent" in left
    assert "align-items:flex-start" in right
    assert "align-items:flex-end" in left
    assert "--session-swipe-action-color:var(--warning)" in right
    assert "--session-swipe-action-color:var(--error)" in left
    assert "width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px))" in badge
    assert "height:var(--session-swipe-badge-size,34px)" in badge
    assert "border-radius:999px" in badge
    assert "background:var(--session-swipe-action-color)" in badge
    assert "overflow:hidden" in badge
    assert ".session-item.archived .session-swipe-affordance-right{--session-swipe-action-color:var(--success);}" in STYLE_CSS
    assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive'))" in SESSIONS_JS
    assert "stack.className='session-swipe-action-stack'" in SESSIONS_JS
    assert "stack.append(badge,text)" in SESSIONS_JS
    stack = STYLE_CSS[STYLE_CSS.find(".session-swipe-action-stack{"):STYLE_CSS.find(".session-swipe-badge{")]
    label = STYLE_CSS[STYLE_CSS.find(".session-swipe-label{"):STYLE_CSS.find(".session-item.dragging")]
    assert "session-swipe-label" in SESSIONS_JS
    assert "width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px))" in stack
    assert "align-items:center" in stack
    assert "min-width:58px" in label
    assert "width:100%" in label
    assert "text-align:center" in label
    assert "color:var(--muted)" in label
    assert "font-size:10px" in label
    assert "transform:scale(var(--session-swipe-label-scale,1))" in label
    assert "transform-origin:top center" in label
    assert ".session-swipe-affordance-right .session-swipe-action-stack{transform:translateX(6px);}" in STYLE_CSS
    assert ".session-swipe-affordance-left .session-swipe-action-stack{transform:translateX(-6px);}" in STYLE_CSS


def test_session_removal_reflows_surviving_rows_smoothly():
    assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS
    assert "const _optimisticallyRemovedSessionIds = new Set();" in SESSIONS_JS
    assert "const _sessionSwipeReturnOffsets = new Map();" in SESSIONS_JS
    capture = _sessions_block("function _captureSessionReflowPositions(){", "function _waitForSessionMotion")
    helper = _sessions_block("function _playSessionRowsReflowFromPositions", "function _sessionPrefersReducedMotion")
    assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in capture
    assert "const delta=oldTop-row.getBoundingClientRect().top;" in helper
    assert "const movingRows=[];" in helper
    assert "list.getBoundingClientRect();" in helper
    assert "row.style.transition='none';" in helper
    assert "row.classList.add('session-reflowing')" in helper
    assert "requestAnimationFrame(()=>requestAnimationFrame(()=>{" in helper
    assert SESSIONS_JS.count("_pendingSessionReflowPositions=reflowPositions;") >= 2
    assert "const reflowBefore=animateRefresh?flipBefore:_pendingSessionReflowPositions;" in SESSIONS_JS
    assert "const reflowTimeout=animateRefresh?SESSION_LIST_FLIP_TIMEOUT_MS:SESSION_REFLOW_TIMEOUT_MS;" in SESSIONS_JS
    assert "_playSessionRowsReflowFromPositions(reflowBefore,reflowTimeout,_sessionPrefersReducedMotion);" in SESSIONS_JS
    assert "async function _archiveSession(session, archived=true, beforeListRender=null){" in SESSIONS_JS
    assert "const renderHold=beforeListRender?Promise.resolve().then(beforeListRender):null;" in SESSIONS_JS
    assert "const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);" in SESSIONS_JS
    assert "if(cached) cached.archived=archived;" in SESSIONS_JS
    archive_start = SESSIONS_JS.find("async function _archiveSession(session, archived=true, beforeListRender=null){")
    archive_end = SESSIONS_JS.find("function _openSessionActionMenu", archive_start)
    archive_body = SESSIONS_JS[archive_start:archive_end]
    toast_idx = archive_body.find("showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));")
    hold_idx = archive_body.find("if(renderHold) await renderHold;")
    cache_render_idx = archive_body.find("renderSessionListFromCache();")
    reconcile_idx = archive_body.find("void renderSessionList();")
    assert 0 <= toast_idx < hold_idx < cache_render_idx < reconcile_idx
    assert "if(renderHold) await renderHold;" in SESSIONS_JS
    assert "const serverSessions=_optimisticallyRemovedSessionIds.size" in SESSIONS_JS
    assert "? (sessData.sessions||[]).filter(s=>s&&!_optimisticallyRemovedSessionIds.has(s.session_id))" in SESSIONS_JS
    assert ".session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;}" in STYLE_CSS
    assert "if(_showArchived&&!_sessionPrefersReducedMotion()) _sessionSwipeReturnOffsets.set(session.session_id,'0px');" in SESSIONS_JS
    assert "const swipeReturnOffset=_sessionSwipeReturnOffsets.get(s.session_id);" in SESSIONS_JS
    assert "el.classList.add('session-swipe-returning');" in SESSIONS_JS
    delete_start = SESSIONS_JS.find("async function deleteSession(sid, beforeDelete=null){")
    delete_end = SESSIONS_JS.find("// ── Project helpers", delete_start)
    assert delete_start >= 0 and delete_end > delete_start
    delete_body = SESSIONS_JS[delete_start:delete_end]
    hold_start = delete_body.find("const beforeDeleteHold=beforeDelete?Promise.resolve().then(beforeDelete):null;")
    delete_request = delete_body.find("const deleteRequest=api('/api/session/delete'")
    hold_await = delete_body.find("await beforeDeleteHold;", hold_start)
    optimistic_set = delete_body.find("_optimisticallyRemovedSessionIds.add(sid);")
    optimistic_remove = delete_body.find("_optimisticallyRemoveSessionFromList(sid);", optimistic_set)
    response_await = delete_body.find("const deleteResult=await deleteRequest;")
    rollback = delete_body.find("_optimisticallyRemovedSessionIds.delete(sid);")
    final_render = delete_body.find("void renderSessionList().finally(()=>_optimisticallyRemovedSessionIds.delete(sid));")
    cached_remove = _sessions_block("function _optimisticallyRemoveSessionFromList(sid){", "function _sessionIdFromLocation")
    assert "_allSessions=_allSessions.filter(s=>!s||s.session_id!==sid);" in cached_remove
    assert "renderSessionListFromCache();" in cached_remove
    assert delete_body.find("const reflowPositions=_captureSessionReflowPositions();") < hold_start < delete_request < hold_await < optimistic_set < optimistic_remove < response_await < rollback < final_render
    assert "}, error=>({error}));" in delete_body


def test_ios_touch_events_drive_session_swipes():
    assert "el.addEventListener('touchstart'" in SESSIONS_JS
    assert "el.addEventListener('touchmove'" in SESSIONS_JS
    assert "el.addEventListener('touchcancel',_clearPointerDragState" in SESSIONS_JS
    assert "el.addEventListener('touchend'" in SESSIONS_JS
    assert "const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{" in SESSIONS_JS
    assert "{passive:false}" in SESSIONS_JS
    assert "if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault();" in SESSIONS_JS
    assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3
    assert "if(_finishSessionGesture(touch.clientX,touch.clientY,e.target,'touch')) e.stopPropagation();" in SESSIONS_JS
    assert "window.PointerEvent" not in SESSIONS_JS


def test_touch_session_rows_preserve_vertical_scroll():
    assert ".session-item{padding:8px 8px;" in STYLE_CSS
    item_rule = STYLE_CSS[STYLE_CSS.find(".session-item{padding:8px 8px;"):STYLE_CSS.find("}", STYLE_CSS.find(".session-item{padding:8px 8px;"))]
    assert "touch-action:pan-y" in item_rule
    assert "user-select:none" in item_rule
    assert "-webkit-user-select:none" in item_rule
    assert "-webkit-touch-callout:none" in item_rule


def test_session_gesture_finish_ignores_idle_state():
    """Test that _finishSessionGesture bails early when _gestureState is 'idle'.
    
    This prevents unintended session switches when a drag starts outside the
    session row and ends on top of it. The gesture must have begun with a
    pointerdown on the row itself (_gestureState='pressing'), otherwise the
    pointerup should be ignored.
    
    See: https://github.com/nesquena/hermes-webui/issues/5462
    """
    # Find the _finishSessionGesture function
    fn_start = SESSIONS_JS.find("const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{")
    assert fn_start != -1, "_finishSessionGesture function not found"
    
    # Extract the first ~200 characters of the function body
    fn_body_start = SESSIONS_JS[fn_start:fn_start+200]
    
    # Verify the idle-state guard is present at the beginning
    assert "if(_gestureState==='idle') return false;" in fn_body_start, \
        "Missing idle-state guard at the beginning of _finishSessionGesture"
    
    # Also verify that the fork row handler has the same guard (consistency check)
    # Fork handler pattern: rowEl.onpointerup followed by pointerType/button check, then idle guard
    fork_handler_start = SESSIONS_JS.find("rowEl.onpointerup=(e)=>{")
    assert fork_handler_start != -1, "Fork row pointerup handler not found"
    fork_handler_snippet = SESSIONS_JS[fork_handler_start:fork_handler_start+200]
    assert "if(_gestureState==='idle') return;" in fork_handler_snippet, \
        "Fork row pointerup handler should have idle guard"
