mail-navigation-escape.test.tsx4.2 KBView on GitHub
import { act, renderHook } from '@testing-library/react';
import { HotkeysProvider } from 'react-hotkeys-hook';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { useMailNavigation } from '@/modules/threads/threadList/hooks/use-mail-navigation';
import React from 'react';

/**
 * Escape on the inbox must unselect EVERYTHING — the keyboard selection, the bulk (checkbox)
 * selection, and the ambient conversation pointer that feeds the chat's context badges.
 * Clearing only the keyboard selection left checkboxes ticked and the conversation still
 * riding into the chat, which read as "escape did nothing".
 */
const wrapper = ({ children }: { children: React.ReactNode }) => (
  <HotkeysProvider initiallyActiveScopes={['global', 'navigation']}>{children}</HotkeysProvider>
);

const pressEscape = () =>
  act(() => {
    document.body.dispatchEvent(
      new KeyboardEvent('keydown', { key=[redacted], code: 'Escape', bubbles: true }),
    );
  });

const renderNav = () =>
  renderHook(
    () => useMailNavigation({ items: [{ id: 'thread-1' }], containerRef: { current: null } }),
    { wrapper },
  );

describe('mail list — Escape clears the whole selection', () => {
  beforeEach(() => {
    useCedarStore.setState((s) => ({
      ...s,
      isThreadOpen: false,
      isConversationOpen: false,
      newEmail: null,
      selectedThreadId: 'thread-1',
      focusedIndex: 0,
      bulkSelected: ['thread-1', 'thread-2'],
      conversationSelection: ['conv-1', 'conv-2'],
      activeConversationId: 'conv-1',
      currentThreadList: [{ id: 'thread-1' }] as never[],
    }));
  });

  it('clears the keyboard selection, the bulk selection and the ambient conversation', () => {
    renderNav();
    pressEscape();

    const s = useCedarStore.getState();
    expect(s.selectedThreadId).toBeNull();
    expect(s.focusedIndex).toBeNull();
    expect(s.bulkSelected).toEqual([]);
    expect(s.conversationSelection).toEqual([]);
    expect(s.activeConversationId).toBeNull();
  });

  it('leaves the selection alone while a thread is open (that view owns Escape)', () => {
    useCedarStore.setState((s) => ({ ...s, isThreadOpen: true }));
    renderNav();
    pressEscape();

    const s = useCedarStore.getState();
    expect(s.selectedThreadId).toBe('thread-1');
    expect(s.bulkSelected).toEqual(['thread-1', 'thread-2']);
  });
});

/**
 * Escape is also how you get out of a filtered inbox. It runs second, though: a single stage that
 * cleared both would throw away a filter set on every ordinary deselect.
 */
describe('mail list — Escape clears the filters once nothing is selected', () => {
  const FILTER = { id: 'f-1', type: 'from', value: '<email>', label: 'From: Chris' };

  const setUp = (selection: Partial<ReturnType<typeof useCedarStore.getState>>) =>
    useCedarStore.setState((s) => ({
      ...s,
      isThreadOpen: false,
      isConversationOpen: false,
      newEmail: null,
      selectedThreadId: null,
      focusedIndex: null,
      bulkSelected: [],
      conversationSelection: [],
      activeConversationId: null,
      currentThreadList: [{ id: 'thread-1' }] as never[],
      activeFilters: [FILTER] as never[],
      value: 'renewal',
      ...selection,
    }));

  it('keeps the filters on the press that clears a selection', () => {
    setUp({ selectedThreadId: 'thread-1', bulkSelected: ['thread-1'] });
    renderNav();
    pressEscape();

    const s = useCedarStore.getState();
    expect(s.selectedThreadId).toBeNull();
    expect(s.bulkSelected).toEqual([]);
    // The whole point of the two stages — the filter survives the deselect.
    expect(s.activeFilters).toHaveLength(1);
    expect(s.value).toBe('renewal');
  });

  it('clears the filter chips and the search box when nothing is selected', () => {
    setUp({});
    renderNav();
    pressEscape();

    const s = useCedarStore.getState();
    expect(s.activeFilters).toEqual([]);
    expect(s.value).toBe('');
  });

  it('takes two presses to get from a filtered selection to an unfiltered list', () => {
    setUp({ selectedThreadId: 'thread-1', focusedIndex: 0 });
    renderNav();

    pressEscape();
    expect(useCedarStore.getState().activeFilters).toHaveLength(1);

    pressEscape();
    expect(useCedarStore.getState().activeFilters).toEqual([]);
  });
});