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'); + }); + }); +});