HomeWidgetRail.test.tsx8.0 KBView on GitHub
/**
 * The home widget rail.
 *
 * The property worth guarding hardest is the QUERY GATE: the metric tiles are pure
 * projections of ONE `statistics.getOverview`, and a rail holding none of them must issue no
 * request at all. The obvious refactor — each tile fetching what it needs — turns a glance
 * surface into several requests and is invisible until someone opens the network tab.
 */

import { fireEvent, render, screen, within } from '@testing-library/react';

const mockSetWidgetIds = jest.fn();
let mockStoredWidgetIds: readonly string[] | undefined;

jest.mock('@/modules/home/hooks/use-home-setting-list', () => ({
  useHomeSettingList: (key=[redacted], fallback: readonly string[]) => ({
    // Only the widget list is driven by these tests; the pipeline's AOP scope reads through
    // the same hook and must fall through to its own (empty) default.
    value: key === 'homeWidgets' ? (mockStoredWidgetIds ?? fallback) : fallback,
    setValue: key === 'homeWidgets' ? mockSetWidgetIds : jest.fn(),
    loaded: true,
    isDefault: mockStoredWidgetIds === undefined,
  }),
}));

// The rail resolves the Pipeline widget's scope itself (one query for the whole rail), so it
// reaches for the filters and the AOP list. Neither is what these tests are about.
jest.mock('@/modules/home/hooks/use-home-pipeline-filters', () => ({
  useHomePipelineFilters: () => ({ filters: {}, setFilters: jest.fn(), loaded: true }),
}));
// The REAL shape of `aop.listAopsForUser` — `{ aops }`, not a bare array. This mock used to
// return `[]`, which is what let a cast to `{ id, name }[]` sail through CI and then take the
// whole rail down in the browser on `aops[0].id`.
jest.mock('@tanstack/react-query', () => ({
  useQuery: () => ({ data: { aops: [{ id: 'aop-deals', name: 'Deals' }] }, isLoading: false }),
}));
jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({ aop: { listAopsForUser: { queryOptions: () => ({}) } } }),
}));

const mockUseHomeOverview = jest.fn();
jest.mock('@/modules/home/hooks/use-home-overview', () => ({
  useHomeOverview: (enabled: boolean, scope?: { aopId?: string | null }) => {
    mockUseHomeOverview(enabled, scope?.aopId ?? null);
    return { overview: undefined, isLoading: false };
  },
}));

// These pull heavy trees of their own (the calendar stack; the agent list and its router).
// Their own behaviour is covered elsewhere; here they stand in as rail contents.
jest.mock('@/modules/home/widgets/HomeMeetingsWidget', () => ({
  HomeMeetingsWidget: () => <div data-testid="widget-meetings">MEETINGS</div>,
}));
jest.mock('@/modules/home/widgets/HomeAgentsWidget', () => ({
  HomeAgentsWidget: () => <div data-testid="widget-agents">AGENTS</div>,
}));
// The metric tiles reach for a query client of their own (Pipeline lists the user's AOPs to
// scope itself). They stand in as frames here; their contents are covered by their own tests.
jest.mock('@/modules/home/widgets/metric-widgets', () => ({
  PipelineWidget: () => <div data-testid="widget-frame" data-widget-title="Pipeline" />,
  StatisticsWidget: () => <div data-testid="widget-frame" data-widget-title="Statistics" />,
}));

import { HomeWidgetRail } from '@/modules/home/widgets/HomeWidgetRail';

beforeEach(() => {
  mockStoredWidgetIds = undefined;
  mockSetWidgetIds.mockClear();
  mockUseHomeOverview.mockClear();
});

describe('HomeWidgetRail', () => {
  it('starts every user on meetings, pipeline and statistics, in that order', () => {
    render(<HomeWidgetRail />);
    expect(screen.getByTestId('widget-meetings')).toBeInTheDocument();
    const titles = screen
      .getAllByTestId('widget-frame')
      .map((el) => el.getAttribute('data-widget-title'));
    expect(titles).toEqual(['Pipeline', 'Statistics']);
  });

  it('never offers the unlisted agents tile, but still renders it where it is stored', () => {
    // TEMPORARY, while the agents surface is unfinished: nobody can add the tile, and it is
    // not in the defaults, so it reaches only the rails that already name it.
    mockStoredWidgetIds = ['meetings', 'agents'];
    render(<HomeWidgetRail />);
    expect(screen.getByTestId('widget-agents')).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: /Edit widgets/ }));
    const dialog = screen.getByRole('dialog');
    expect(within(dialog).queryByText('Agents')).not.toBeInTheDocument();
  });

  it('issues NO statistics query for a rail holding no metric tile', () => {
    // The gate is the whole reason the metric tiles cost one request between them.
    mockStoredWidgetIds = ['meetings'];
    render(<HomeWidgetRail />);
    expect(mockUseHomeOverview).toHaveBeenCalledWith(false, 'aop-deals');
  });

  it('issues the shared query once a metric widget is in the rail', () => {
    mockStoredWidgetIds = ['meetings', 'pipeline', 'statistics'];
    render(<HomeWidgetRail />);
    expect(mockUseHomeOverview).toHaveBeenCalledWith(true, 'aop-deals');
    // Two metric tiles, ONE call — the hook is what fetches, and there is one of it.
    expect(mockUseHomeOverview).toHaveBeenCalledTimes(1);
  });

  it('scopes the shared query to the deals AOP read off the tRPC result', () => {
    // Regression: `aop.listAopsForUser` returns `{ aops }`. Read as a bare array it is not
    // empty (an object has no `.length`), the fuzzy match finds nothing, and `aops[0].id`
    // threw — killing the whole rail, not just the Pipeline tile.
    mockStoredWidgetIds = ['pipeline'];
    render(<HomeWidgetRail />);
    expect(mockUseHomeOverview).toHaveBeenCalledWith(true, 'aop-deals');
  });

  it('renders stored widgets in the stored order and skips a retired id', () => {
    mockStoredWidgetIds = ['statistics', 'not-a-widget', 'pipeline'];
    render(<HomeWidgetRail />);

    const titles = screen
      .getAllByTestId('widget-frame')
      .map((el) => el.getAttribute('data-widget-title'));
    expect(titles).toEqual(['Statistics', 'Pipeline']);
  });

  it('removes through the SAME dialog that adds, not a per-tile control', () => {
    mockStoredWidgetIds = ['meetings', 'statistics', 'pipeline'];
    render(<HomeWidgetRail />);

    // No ✕ on any card: a destructive control under the cursor on the way to every other
    // one, and "what is on my rail" split across six places instead of one.
    expect(screen.queryByRole('button', { name: /^Remove / })).not.toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: /Edit widgets/ }));
    const dialog = screen.getByRole('dialog');
    fireEvent.click(within(dialog).getByText('Meetings'));
    expect(mockSetWidgetIds).toHaveBeenCalledWith(['statistics', 'pipeline']);
  });

  it('adds from the picker by APPENDING, so existing order survives', () => {
    mockStoredWidgetIds = ['meetings'];
    render(<HomeWidgetRail />);

    fireEvent.click(screen.getByRole('button', { name: /Edit widgets/ }));
    const dialog = screen.getByRole('dialog');
    fireEvent.click(within(dialog).getByText('Pipeline'));

    expect(mockSetWidgetIds).toHaveBeenCalledWith(['meetings', 'pipeline']);
  });

  it('lists every OFFERED widget in the picker, marking the ones already in the rail', () => {
    mockStoredWidgetIds = ['meetings'];
    render(<HomeWidgetRail />);
    fireEvent.click(screen.getByRole('button', { name: /Edit widgets/ }));

    const cards = screen.getAllByTestId('widget-picker-card');
    // Every offered widget, not just the addable ones: the same dialog is how you take one
    // out. Three, not four — `agents` is unlisted.
    expect(cards).toHaveLength(3);
    const meetings = cards.find((c) => c.textContent?.includes('Meetings'));
    expect(meetings).toHaveAttribute('aria-pressed', 'true');
    const pipeline = cards.find((c) => c.textContent?.includes('Pipeline'));
    expect(pipeline).toHaveAttribute('aria-pressed', 'false');
  });

  it('honours an empty rail rather than resurrecting the defaults', () => {
    // Absent means "never configured"; empty means "I removed them all".
    mockStoredWidgetIds = [];
    render(<HomeWidgetRail />);
    expect(screen.queryByTestId('widget-frame')).not.toBeInTheDocument();
    expect(screen.getByRole('button', { name: /Edit widgets/ })).toBeInTheDocument();
  });
});