From 4e938448541da596687a37f19198a6b46c0481d2 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 5 Oct 2026 17:04:13 -0700 Subject: [PATCH] Test the schedule grid; use event.key for the run keyboard shortcut Tests for useScheduleGridProvider (run details visibility, skeleton), ScheduleGrid and its runs (details popover, rating, signup status badges, extended counts), and ScheduleGridApp (day tabs and prefetch, refresh, query variables, errors, legends, rating filter). RunDisplay opened its details on Enter/Space by checking the deprecated keyCode; it uses event.key now. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013mDr1nhgyzST2iPzCzMcCd --- .../EventsApp/ScheduleGrid/CategoryLegend.tsx | 2 +- .../ConventionDayTabContainer.tsx | 2 +- .../EventsApp/ScheduleGrid/RunDisplay.tsx | 2 +- .../ScheduleGrid/ScheduleGridApp.test.tsx | 319 ++++++++++++ .../ScheduleGrid/ScheduleGridRuns.test.tsx | 484 ++++++++++++++++++ 5 files changed, 806 insertions(+), 3 deletions(-) create mode 100644 test/javascript/EventsApp/ScheduleGrid/ScheduleGridApp.test.tsx create mode 100644 test/javascript/EventsApp/ScheduleGrid/ScheduleGridRuns.test.tsx diff --git a/app/javascript/EventsApp/ScheduleGrid/CategoryLegend.tsx b/app/javascript/EventsApp/ScheduleGrid/CategoryLegend.tsx index 362b11fccd8..2093b374a8a 100644 --- a/app/javascript/EventsApp/ScheduleGrid/CategoryLegend.tsx +++ b/app/javascript/EventsApp/ScheduleGrid/CategoryLegend.tsx @@ -9,7 +9,7 @@ import { SignupStatus } from './StylingUtils'; import AppRootContext from '../../AppRootContext'; import { SignupAutomationMode, SignupMode } from '../../graphqlTypes.generated'; import RankedChoicePriorityIndicator from '../MySignupQueue/RankedChoicePriorityIndicator'; -import { useSuspenseQuery } from "@apollo/client/react"; +import { useSuspenseQuery } from '@apollo/client/react'; export default function CategoryLegend() { const { data } = useSuspenseQuery(CommonConventionDataQueryDocument); diff --git a/app/javascript/EventsApp/ScheduleGrid/ConventionDayTabContainer.tsx b/app/javascript/EventsApp/ScheduleGrid/ConventionDayTabContainer.tsx index a4f634c0fd1..9025daa9c52 100644 --- a/app/javascript/EventsApp/ScheduleGrid/ConventionDayTabContainer.tsx +++ b/app/javascript/EventsApp/ScheduleGrid/ConventionDayTabContainer.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useContext, ReactNode } from 'react'; import { NavLink, useLocation } from 'react-router'; -import { useApolloClient } from "@apollo/client/react"; +import { useApolloClient } from '@apollo/client/react'; import { getConventionDayTimespans } from '../../TimespanUtils'; import RefreshButton from './RefreshButton'; diff --git a/app/javascript/EventsApp/ScheduleGrid/RunDisplay.tsx b/app/javascript/EventsApp/ScheduleGrid/RunDisplay.tsx index 4b436a18943..0924345cd01 100644 --- a/app/javascript/EventsApp/ScheduleGrid/RunDisplay.tsx +++ b/app/javascript/EventsApp/ScheduleGrid/RunDisplay.tsx @@ -120,7 +120,7 @@ const RunDisplay = forwardRef(function RunDispl run.disableDetailsPopup ? undefined : (keyEvent) => { - if (keyEvent.keyCode === 13 || keyEvent.keyCode === 32) { + if (keyEvent.key === 'Enter' || keyEvent.key === ' ') { keyEvent.preventDefault(); toggle(); } diff --git a/test/javascript/EventsApp/ScheduleGrid/ScheduleGridApp.test.tsx b/test/javascript/EventsApp/ScheduleGrid/ScheduleGridApp.test.tsx new file mode 100644 index 00000000000..3c2dfd2d22a --- /dev/null +++ b/test/javascript/EventsApp/ScheduleGrid/ScheduleGridApp.test.tsx @@ -0,0 +1,319 @@ +import { DateTime } from 'luxon'; +import { MockLink } from '@apollo/client/testing'; +import { vi } from 'vitest'; + +import { renderRoute, userEvent, waitFor } from '../../testUtils'; +import ScheduleGridApp from '../../../../app/javascript/EventsApp/ScheduleGrid'; +import { + ScheduleGridConventionDataQueryData, + ScheduleGridEventsQueryData, + ScheduleGridEventsQueryDocument, +} from '../../../../app/javascript/EventsApp/ScheduleGrid/queries.generated'; +import { CommonConventionDataQueryDocument } from '../../../../app/javascript/EventsApp/queries.generated'; +import { + SchedulingUi, + SignupMode, + SiteMode, + SignupAutomationMode, + TicketMode, + TimezoneMode, +} from '../../../../app/javascript/graphqlTypes.generated'; +import Timespan from '../../../../app/javascript/Timespan'; +import { TIMEZONE_NAME } from './scheduleFixtures'; + +type Event = ScheduleGridEventsQueryData['convention']['events'][number]; + +const zone = { zone: TIMEZONE_NAME }; +const CON = Timespan.finiteFromDateTimes( + DateTime.fromISO('2026-01-02T00:00:00', zone), + DateTime.fromISO('2026-01-04T00:00:00', zone), +); +const FRIDAY = Timespan.finiteFromDateTimes( + DateTime.fromISO('2026-01-02T06:00:00', zone), + DateTime.fromISO('2026-01-03T06:00:00', zone), +); + +const category = (id: string, name: string) => ({ + __typename: 'EventCategory' as const, + id, + name, + scheduling_ui: SchedulingUi.Regular, + default_color: '#336699', + full_color: '#999999', + signed_up_color: '#cc9900', + team_member_name: 'GM', + teamMemberNamePlural: 'GMs', + event_form: { __typename: 'Form' as const, id: `form-${id}`, form_sections: [] }, +}); + +const convention = (overrides: Partial = {}) => ({ + __typename: 'Convention' as const, + id: '1', + name: 'Test Con', + starts_at: '2026-01-02T00:00:00-05:00', + ends_at: '2026-01-04T00:00:00-05:00', + signup_mode: SignupMode.SelfService, + signup_automation_mode: SignupAutomationMode.None, + site_mode: SiteMode.Convention, + timezone_name: TIMEZONE_NAME, + timezone_mode: TimezoneMode.ConventionLocal, + ticket_name: 'ticket', + ticket_mode: TicketMode.Disabled, + event_categories: [category('4', 'Larp'), category('5', 'Panel')], + blockPartial: null, + ...overrides, +}); + +const event = (id: string, title: string, startsAt: string, overrides: Partial = {}): Event => ({ + __typename: 'Event', + id, + title, + length_seconds: 3600, + short_blurb_html: null, + my_rating: null, + can_play_concurrently: false, + form_response_attrs_json_with_rendered_markdown: null, + event_category: { __typename: 'EventCategory', id: '4' }, + registration_policy: null, + runs: [ + { + __typename: 'Run', + id: `r${id}`, + starts_at: startsAt, + schedule_note: null, + title_suffix: null, + confirmed_signup_count: 0, + not_counted_signup_count: 0, + room_names: ['Ballroom'], + grouped_signup_counts: [], + my_signups: [], + my_signup_requests: [], + my_signup_ranked_choices: [], + }, + ], + ...overrides, +}); + +describe('the schedule grid app', () => { + let user: ReturnType; + const queried = vi.fn(); + let eventsResult: () => MockLink.MockedResponse['result']; + + beforeEach(() => { + user = userEvent.setup(); + queried.mockReset(); + eventsResult = () => ({ + data: { + __typename: 'Query', + convention: { __typename: 'Convention', id: '1', events: [event('1', 'Boffer Larp', '2026-01-02T20:00:00Z')] }, + }, + }); + }); + + const renderGrid = ({ + configKey = 'con_schedule', + search = '', + myProfile = false, + conv = convention(), + fetchFormItemIdentifiers = ['system'], + filters, + }: { + configKey?: string; + search?: string; + myProfile?: boolean; + conv?: ReturnType; + fetchFormItemIdentifiers?: string[]; + filters?: { text_search: string }; + } = {}) => { + const apolloMocks: MockLink.MockedResponse[] = [ + { + request: { + query: ScheduleGridEventsQueryDocument, + variables: (variables: unknown) => { + queried(variables); + return true; + }, + }, + result: () => eventsResult() as never, + maxUsageCount: 30, + }, + { + request: { query: CommonConventionDataQueryDocument }, + result: { data: { __typename: 'Query', convention: conv } }, + maxUsageCount: 5, + }, + ]; + return renderRoute( + [ + { + path: '/events/schedule/:day', + loader: () => ({ + matchingTimespan: FRIDAY, + conventionDayTimespans: [FRIDAY], + conventionDayTimespansByUrlPortion: {}, + urlPortionsByTimespanStart: {}, + }), + Component: () => ( + + ), + }, + ], + { + apolloMocks, + initialEntries: [`/events/schedule/friday${search}`], + appRootContextValue: { + timezoneName: TIMEZONE_NAME, + conventionTimespan: CON, + siteMode: SiteMode.Convention, + language: 'en', + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + }, + ); + }; + + const lastVariables = () => queried.mock.calls[queried.mock.calls.length - 1][0]; + + it('says when the grid configuration does not exist', async () => { + const r = await renderGrid({ configKey: 'nonsense' }); + + expect(await r.findByText(/“nonsense” not found/)).toBeTruthy(); + }); + + it('shows the day, and the timezone the times are in', async () => { + const r = await renderGrid(); + + expect(await r.findByRole('heading', { level: 3, name: /Friday/ })).toBeTruthy(); + expect(r.getByText(/All times displayed in Eastern Standard Time/)).toBeTruthy(); + }); + + it('shows the day’s events', async () => { + const r = await renderGrid(); + + expect(await r.findByText('Boffer Larp')).toBeTruthy(); + }); + + describe('what it asks for', () => { + it('is the day’s events, with the filters and the form items to fetch', async () => { + const r = await renderGrid({ filters: { text_search: 'dragon' } }); + await r.findByText('Boffer Larp'); + + const variables = lastVariables(); + expect(DateTime.fromISO(variables.start).toMillis()).toBe(FRIDAY.start.toMillis()); + expect(DateTime.fromISO(variables.finish).toMillis()).toBe(FRIDAY.finish.toMillis()); + expect(variables).toMatchObject({ + fetchFormItemIdentifiers: ['system'], + extendedCounts: false, + filters: { text_search: 'dragon' }, + }); + }); + + it('asks for extended counts only on the grid that shows them', async () => { + const r = await renderGrid({ configKey: 'schedule_with_counts' }); + await r.findByText('Boffer Larp'); + + expect(lastVariables().extendedCounts).toBe(true); + }); + }); + + it('shows the error if the events cannot be loaded', async () => { + eventsResult = () => ({ errors: [{ message: 'Schedule is down' } as never] }); + const r = await renderGrid(); + + expect(await r.findByText(/Schedule is down/)).toBeTruthy(); + }); + + describe('the day tabs', () => { + it('has one for each convention day, linking to it and keeping the filters', async () => { + const r = await renderGrid({ search: '?filters.hide_conflicts=false' }); + await r.findByText('Boffer Larp'); + + const tabs = r.getAllByRole('link', { name: /Friday|Saturday|Sunday/ }); + expect(tabs.map((tab) => tab.getAttribute('href'))).toEqual([ + '/events/schedule/friday?filters.hide_conflicts=false', + '/events/schedule/saturday?filters.hide_conflicts=false', + ]); + }); + + it('fetches a day ahead of time when it is hovered over', async () => { + const r = await renderGrid(); + await r.findByText('Boffer Larp'); + const callsBefore = queried.mock.calls.length; + + await user.hover(r.getAllByRole('link', { name: /Saturday/ })[0]); + + await waitFor(() => expect(queried.mock.calls.length).toBeGreaterThan(callsBefore)); + expect(DateTime.fromISO(lastVariables().start).toMillis()).toBe(FRIDAY.finish.toMillis()); + }); + + it('can be refreshed, asking the server again', async () => { + const r = await renderGrid(); + await r.findByText('Boffer Larp'); + const callsBefore = queried.mock.calls.length; + + await user.click(r.getByRole('button', { name: 'Refresh' })); + + await waitFor(() => expect(queried.mock.calls.length).toBeGreaterThan(callsBefore)); + }); + }); + + describe('the legends', () => { + it('shows the categories legend, and the note about rooms, on the by-room grid', async () => { + const r = await renderGrid({ configKey: 'con_schedule_by_room' }); + + expect(await r.findByText(/Events that use multiple rooms will appear multiple times/)).toBeTruthy(); + expect(await r.findByText('Event categories')).toBeTruthy(); + }); + + it('shows the fullness legend on the grid with counts', async () => { + const r = await renderGrid({ configKey: 'schedule_with_counts' }); + + expect(await r.findByText('Event fullness colors')).toBeTruthy(); + expect(r.getByText('Attendee counts')).toBeTruthy(); + }); + + it('lists each category in the categories legend', async () => { + const r = await renderGrid(); + + await r.findByText('Event categories'); + expect(await r.findByText('Panel')).toBeTruthy(); + }); + }); + + describe('for someone with a profile', () => { + it('hides disliked events by default', async () => { + eventsResult = () => ({ + data: { + __typename: 'Query', + convention: { + __typename: 'Convention', + id: '1', + events: [ + event('1', 'Liked Larp', '2026-01-02T20:00:00Z', { my_rating: 1 }), + event('2', 'Disliked Larp', '2026-01-02T22:00:00Z', { my_rating: -1 }), + ], + }, + }, + }); + const r = await renderGrid({ myProfile: true, search: '?filters.my_rating=1,0&filters.hide_conflicts=false' }); + + expect(await r.findByText('Liked Larp')).toBeTruthy(); + expect(r.queryByText('Disliked Larp')).toBeNull(); + }); + }); + + describe('the grid with counts', () => { + it('shows counts for each hour', async () => { + const r = await renderGrid({ configKey: 'schedule_with_counts' }); + + await r.findByText('Boffer Larp'); + expect(document.querySelectorAll('.schedule-grid-hour-extended-counts').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/javascript/EventsApp/ScheduleGrid/ScheduleGridRuns.test.tsx b/test/javascript/EventsApp/ScheduleGrid/ScheduleGridRuns.test.tsx new file mode 100644 index 00000000000..67fc8cc7683 --- /dev/null +++ b/test/javascript/EventsApp/ScheduleGrid/ScheduleGridRuns.test.tsx @@ -0,0 +1,484 @@ +import { DateTime } from 'luxon'; +import { MockLink } from '@apollo/client/testing'; +import { renderHook, act } from '@testing-library/react'; +import { vi } from 'vitest'; +import { useMemo } from 'react'; + +import { render, userEvent, waitFor, within } from '../../testUtils'; +import ScheduleGrid from '../../../../app/javascript/EventsApp/ScheduleGrid/ScheduleGrid'; +import { + ScheduleGridContext, + useScheduleGridProvider, +} from '../../../../app/javascript/EventsApp/ScheduleGrid/ScheduleGridContext'; +import { ScheduleEvent } from '../../../../app/javascript/EventsApp/ScheduleGrid/Schedule'; +import { RateEventDocument } from '../../../../app/javascript/EventRatings/mutations.generated'; +import { ScheduleGridConfig } from '../../../../app/javascript/EventsApp/ScheduleGrid/ScheduleGridConfig'; +import AppRootContext, { appRootContextDefaultValue } from '../../../../app/javascript/AppRootContext'; +import { + SignupRankedChoiceState, + SignupRequestState, + SignupState, + TimezoneMode, +} from '../../../../app/javascript/graphqlTypes.generated'; +import Timespan from '../../../../app/javascript/Timespan'; +import { TIMEZONE_NAME, buildCategory, buildEvent, categoryConfig, roomConfig } from './scheduleFixtures'; + +// Friday 2026-01-02 in New York; the convention day runs from 6am to 6am +const DAY = Timespan.finiteFromDateTimes( + DateTime.fromISO('2026-01-02T06:00:00', { zone: TIMEZONE_NAME }), + DateTime.fromISO('2026-01-03T06:00:00', { zone: TIMEZONE_NAME }), +); +const THREE_PM = '2026-01-02T20:00:00Z'; +const FOUR_PM = '2026-01-02T21:00:00Z'; +const SIX_PM = '2026-01-02T23:00:00Z'; + +const larp = buildCategory('Larp'); +const panel = buildCategory('Panel'); +const convention = { + starts_at: '2026-01-02T00:00:00-05:00', + ends_at: '2026-01-04T00:00:00-05:00', + timezone_name: TIMEZONE_NAME, + timezone_mode: TimezoneMode.ConventionLocal, + event_categories: [larp, panel], +}; + +describe('useScheduleGridProvider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const events = () => [ + buildEvent({ + id: 'e1', + title: 'First', + category: larp, + runs: [{ id: 'r1', startsAt: THREE_PM }], + }), + buildEvent({ + id: 'e2', + title: 'Overlaps first', + category: larp, + runs: [{ id: 'r2', startsAt: FOUR_PM }], + }), + buildEvent({ + id: 'e3', + title: 'Later', + category: panel, + runs: [{ id: 'r3', startsAt: '2026-01-03T01:00:00Z' }], + }), + ]; + + // (null means leave that argument out, since undefined would just pick up the default) + const renderProvider = ({ + config = categoryConfig, + conv = convention, + evts = events(), + myRatingFilter, + hideConflicts, + }: { + config?: ScheduleGridConfig | null; + conv?: typeof convention | null; + evts?: ScheduleEvent[] | null; + myRatingFilter?: number[]; + hideConflicts?: boolean; + } = {}) => + renderHook( + () => + useScheduleGridProvider( + config ?? undefined, + conv ?? undefined, + evts ?? undefined, + myRatingFilter, + hideConflicts, + ), + { wrapper }, + ); + + const spec = (runId: string, scheduleBlockId = 'block1') => ({ runId, scheduleBlockId }); + + it('builds a schedule of the events, with their categories merged in', () => { + const { result } = renderProvider(); + + expect(result.current.schedule.getRun('r1')).toBeTruthy(); + expect(result.current.schedule.getEventForRun('r3')?.event_category.name).toBe('Panel'); + expect(result.current.config).toBe(categoryConfig); + }); + + it.each([ + ['a config', { config: null }], + ['a convention', { conv: null }], + ['events', { evts: null }], + ])('is an empty skeleton without %s', (_what, args) => { + const { result } = renderProvider(args); + + expect(result.current.schedule.getRun('r1')).toBeUndefined(); + }); + + it('falls back to the skeleton’s config and convention', () => { + const { result } = renderProvider({ config: null, conv: null, evts: null }); + + expect(result.current.config.key).toBe('skeleton'); + expect(result.current.convention.timezone_mode).toBe(TimezoneMode.UserLocal); + }); + + it('hands the rating filter and conflict setting to the schedule', () => { + const { result } = renderProvider({ myRatingFilter: [1], hideConflicts: true }); + + expect(result.current.schedule.myRatingFilter).toEqual([1]); + expect(result.current.schedule.hideConflicts).toBe(true); + }); + + describe('run details visibility', () => { + it('starts with nothing shown', () => { + const { result } = renderProvider(); + + expect(result.current.isRunDetailsVisible(spec('r1'))).toBe(false); + expect(result.current.visibleRunDetails.size).toBe(0); + }); + + it('shows the details of a run, and hides them again when toggled again', () => { + const { result } = renderProvider(); + + act(() => { + result.current.toggleRunDetailsVisibility(spec('r1')); + }); + expect(result.current.isRunDetailsVisible(spec('r1'))).toBe(true); + + act(() => { + result.current.toggleRunDetailsVisibility(spec('r1')); + }); + expect(result.current.isRunDetailsVisible(spec('r1'))).toBe(false); + }); + + it('tracks the same run separately for each block it appears in', () => { + const { result } = renderProvider(); + + act(() => { + result.current.toggleRunDetailsVisibility(spec('r1', 'blockA')); + }); + + expect(result.current.isRunDetailsVisible(spec('r1', 'blockA'))).toBe(true); + expect(result.current.isRunDetailsVisible(spec('r1', 'blockB'))).toBe(false); + }); + + it('closes the details of runs that overlap the one being opened', () => { + const { result } = renderProvider(); + act(() => { + result.current.toggleRunDetailsVisibility(spec('r1')); + }); + + act(() => { + result.current.toggleRunDetailsVisibility(spec('r2')); + }); + + expect(result.current.isRunDetailsVisible(spec('r2'))).toBe(true); + expect(result.current.isRunDetailsVisible(spec('r1'))).toBe(false); + }); + + it('leaves open the details of runs that do not overlap', () => { + const { result } = renderProvider(); + act(() => { + result.current.toggleRunDetailsVisibility(spec('r1')); + }); + + act(() => { + result.current.toggleRunDetailsVisibility(spec('r3')); + }); + + expect(result.current.isRunDetailsVisible(spec('r1'))).toBe(true); + expect(result.current.isRunDetailsVisible(spec('r3'))).toBe(true); + }); + }); +}); + +describe('the schedule grid', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + function GridHarness({ evts, config = categoryConfig }: { evts: ScheduleEvent[]; config?: ScheduleGridConfig }) { + const value = useScheduleGridProvider(config, convention, evts); + const stable = useMemo(() => value, [value]); + return ( + + + + ); + } + + const renderGrid = ( + evts: ScheduleEvent[], + { + config, + myProfile = false, + apolloMocks, + }: { config?: ScheduleGridConfig; myProfile?: boolean; apolloMocks?: MockLink.MockedResponse[] } = {}, + ) => + render(, { + apolloMocks, + appRootContextValue: { + timezoneName: TIMEZONE_NAME, + conventionTimespan: Timespan.finiteFromDateTimes( + DateTime.fromISO('2026-01-02T00:00:00', { zone: TIMEZONE_NAME }), + DateTime.fromISO('2026-01-04T00:00:00', { zone: TIMEZONE_NAME }), + ), + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + }); + + const simpleEvents = () => [ + buildEvent({ + id: 'e1', + title: 'Boffer Larp', + category: larp, + runs: [{ id: 'r1', startsAt: THREE_PM, rooms: ['Ballroom'] }], + }), + buildEvent({ + id: 'e2', + title: 'Big Panel', + category: panel, + runs: [{ id: 'r2', startsAt: SIX_PM, rooms: ['Salon'] }], + }), + ]; + + it('shows each run, titled', async () => { + const { findByText, getByText } = await renderGrid(simpleEvents()); + + expect(await findByText('Boffer Larp')).toBeTruthy(); + expect(getByText('Big Panel')).toBeTruthy(); + }); + + it('labels each hour along the top', async () => { + const { container } = await renderGrid(simpleEvents()); + + await waitFor(() => expect(container.querySelectorAll('.small.text-muted').length).toBeGreaterThan(5)); + }); + + it('puts a header on each row when grouping by room', async () => { + const { findByText } = await renderGrid(simpleEvents(), { config: roomConfig }); + + expect(await findByText('Ballroom')).toBeTruthy(); + expect(await findByText('Salon')).toBeTruthy(); + }); + + describe('run details', () => { + const openDetails = async (r: Awaited>, title = 'Boffer Larp') => { + await user.click(await r.findByRole('button', { name: new RegExp(title) })); + return waitFor(() => { + const popover = r.container.querySelector('.schedule-grid-run-details-popover'); + expect(popover).toBeTruthy(); + return popover as HTMLElement; + }); + }; + + it('opens on click, showing when and where, and a link to the event’s page for that run', async () => { + const r = await renderGrid(simpleEvents()); + + const details = await openDetails(r); + + expect(details.textContent).toContain('Boffer Larp'); + expect(details.textContent).toContain('3:00pm'); + expect(details.textContent).toContain('Ballroom'); + const link = Array.from(details.querySelectorAll('a')).find((a) => a.textContent?.includes('Go to event')); + expect(link?.getAttribute('href')).toBe('/events/e1-boffer-larp#run-r1'); + }); + + it('opens with the keyboard', async () => { + const r = await renderGrid(simpleEvents()); + const run = await r.findByRole('button', { name: /Boffer Larp/ }); + + run.focus(); + await user.keyboard('{Enter}'); + + await waitFor(() => expect(r.container.querySelector('.schedule-grid-run-details-popover')).toBeTruthy()); + }); + + it('closes with its close button, or by clicking the run again', async () => { + const r = await renderGrid(simpleEvents()); + const details = await openDetails(r); + + await user.click(within(details).getByRole('button', { name: 'Close' })); + await waitFor(() => expect(r.container.querySelector('.schedule-grid-run-details-popover')).toBeNull()); + + await openDetails(r); + await user.click(r.getByRole('button', { name: /Boffer Larp/ })); + await waitFor(() => expect(r.container.querySelector('.schedule-grid-run-details-popover')).toBeNull()); + }); + + it('shows the title suffix next to the title', async () => { + const evts = simpleEvents(); + evts[0].runs[0].title_suffix = 'Late Night'; + const r = await renderGrid(evts); + + const details = await openDetails(r); + + expect(details.textContent).toContain('Late Night'); + }); + + it('only offers rating the event to someone with a profile', async () => { + const visitor = await renderGrid(simpleEvents()); + const visitorDetails = await openDetails(visitor); + expect(visitorDetails.querySelectorAll('.float-end')).toHaveLength(0); + visitor.unmount(); + + const member = await renderGrid(simpleEvents(), { myProfile: true }); + const memberDetails = await openDetails(member); + expect(memberDetails.querySelectorAll('.float-end').length).toBeGreaterThan(0); + }); + + it('sends the rating chosen for the event', async () => { + const sent = vi.fn(); + const r = await renderGrid(simpleEvents(), { + myProfile: true, + apolloMocks: [ + { + request: { + query: RateEventDocument, + variables: (variables: unknown) => { + sent(variables); + return true; + }, + }, + result: { + data: { + rateEvent: { __typename: 'RateEventPayload', event: { __typename: 'Event', id: 'e1', my_rating: 1 } }, + }, + }, + }, + ], + }); + const details = await openDetails(r); + + await user.click( + within(details) + .getAllByRole('button') + .filter((b) => b.getAttribute('aria-label') !== 'Close')[0], + ); + + await waitFor(() => expect(sent).toHaveBeenCalled()); + expect(sent.mock.calls[0][0]).toMatchObject({ eventId: 'e1' }); + }); + }); + + describe('signup status', () => { + const withStatus = (apply: (event: ScheduleEvent) => void) => { + const evts = simpleEvents(); + apply(evts[0]); + return evts; + }; + const statusConfig: ScheduleGridConfig = { ...categoryConfig, showSignedUp: true, showSignupStatusBadge: true }; + + it('marks a run the user is confirmed in', async () => { + const r = await renderGrid( + withStatus((e) => { + e.runs[0].my_signups = [{ __typename: 'Signup', id: 's1', state: SignupState.Confirmed }]; + }), + { config: statusConfig, myProfile: true }, + ); + + expect(await r.findByTitle('Confirmed')).toBeTruthy(); + }); + + it('marks a run the user is waitlisted for', async () => { + const r = await renderGrid( + withStatus((e) => { + e.runs[0].my_signups = [{ __typename: 'Signup', id: 's1', state: SignupState.Waitlisted }]; + }), + { config: statusConfig, myProfile: true }, + ); + + expect(await r.findByTitle('Waitlisted')).toBeTruthy(); + }); + + it('marks a run the user has a pending request for', async () => { + const r = await renderGrid( + withStatus((e) => { + e.runs[0].my_signup_requests = [{ __typename: 'SignupRequest', id: 'q1', state: SignupRequestState.Pending }]; + }), + { config: statusConfig, myProfile: true }, + ); + + expect(await r.findByTitle(/request pending/i)).toBeTruthy(); + }); + + it('shows ranked choice priorities for a run in the queue, in priority order', async () => { + const r = await renderGrid( + withStatus((e) => { + e.runs[0].my_signup_ranked_choices = [ + { __typename: 'SignupRankedChoice', id: 'c2', state: SignupRankedChoiceState.Pending, priority: 2 }, + { __typename: 'SignupRankedChoice', id: 'c1', state: SignupRankedChoiceState.Pending, priority: 1 }, + { __typename: 'SignupRankedChoice', id: 'c3', state: SignupRankedChoiceState.SignedUp, priority: 3 }, + ]; + }), + { config: statusConfig, myProfile: true }, + ); + + const run = await r.findByRole('button', { name: /Boffer Larp/ }); + expect(run.textContent?.replace(/[^0-9]/g, '')).toBe('12'); + }); + + it('shows the rating of a run the user has no signup in', async () => { + const r = await renderGrid( + withStatus((e) => { + e.my_rating = 1; + }), + { config: statusConfig, myProfile: true }, + ); + + const run = await r.findByRole('button', { name: /Boffer Larp/ }); + expect(run.querySelector('.me-1')).toBeTruthy(); + }); + + it('shows no badge when the config does not ask for them', async () => { + const r = await renderGrid( + withStatus((e) => { + e.runs[0].my_signups = [{ __typename: 'Signup', id: 's1', state: SignupState.Confirmed }]; + }), + { myProfile: true }, + ); + + await r.findByText('Boffer Larp'); + expect(r.queryByTitle('Confirmed')).toBeNull(); + }); + }); + + describe('extended counts', () => { + it('shows confirmed, not-counted and waitlisted counts on each run', async () => { + const evts = simpleEvents(); + evts[0].runs[0].grouped_signup_counts = [ + { + __typename: 'GroupedSignupCount', + count: 4, + counted: true, + state: SignupState.Confirmed, + team_member: false, + bucket: null, + }, + { + __typename: 'GroupedSignupCount', + count: 2, + counted: false, + state: SignupState.Confirmed, + team_member: true, + bucket: null, + }, + { + __typename: 'GroupedSignupCount', + count: 3, + counted: true, + state: SignupState.Waitlisted, + team_member: false, + bucket: null, + }, + ]; + const r = await renderGrid(evts, { config: { ...categoryConfig, showExtendedCounts: true } }); + + const run = await r.findByRole('button', { name: /Boffer Larp/ }); + expect(run.querySelector('.event-extended-counts')?.textContent).toBe('4/2/3'); + }); + }); +});