From 17bab46e2cbcf5a35317e2be66b3ae452c79e616 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 5 Oct 2026 16:57:18 -0700 Subject: [PATCH] Test the event catalog, run list and schedule app; fix catalog bugs - EventListEvents: the convention-day headings (shown when sorted by convention order) could never appear. The loop assigned previousConventionDay before testing whether the day had changed, so the second test was always false. - EventList: and even with that fixed, the day headings need the convention's start and end, which the events query doesn't fetch, so the list of convention days was always empty. The page now passes the loader's convention merged with the query's. - EventListCategoryDropdown: categories selected in the URL decode to numbers but were compared to string ids, so a selected category never matched and the button always said "All event types". Tests: EventCard, EventListEvents, the EventList page (default queries, URL filters and sort, search, sort/category dropdowns, filterable form items, errors), useFilterableFormItems, RunList (day/time grouping, rating and conflict filters) and ScheduleApp (view choice and persistence, abilities, filters). Adds event catalog fixtures. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013mDr1nhgyzST2iPzCzMcCd --- .../EventList/EventListCategoryDropdown.tsx | 4 +- .../EventList/EventListEvents.tsx | 5 +- .../EventCatalog/EventList/index.tsx | 3 +- .../EventCatalog/EventCards.test.tsx | 363 ++++++++++++++++++ .../EventsApp/EventCatalog/EventList.test.tsx | 259 +++++++++++++ test/javascript/EventsApp/RunList.test.tsx | 241 ++++++++++++ .../javascript/EventsApp/ScheduleApp.test.tsx | 254 ++++++++++++ .../EventsApp/useFilterableFormItems.test.ts | 87 +++++ test/javascript/fixtures/eventCatalog.ts | 146 +++++++ 9 files changed, 1357 insertions(+), 5 deletions(-) create mode 100644 test/javascript/EventsApp/EventCatalog/EventCards.test.tsx create mode 100644 test/javascript/EventsApp/EventCatalog/EventList.test.tsx create mode 100644 test/javascript/EventsApp/RunList.test.tsx create mode 100644 test/javascript/EventsApp/ScheduleApp.test.tsx create mode 100644 test/javascript/EventsApp/useFilterableFormItems.test.ts create mode 100644 test/javascript/fixtures/eventCatalog.ts diff --git a/app/javascript/EventsApp/EventCatalog/EventList/EventListCategoryDropdown.tsx b/app/javascript/EventsApp/EventCatalog/EventList/EventListCategoryDropdown.tsx index 1cece55ad7e..f7d52a7ae0b 100644 --- a/app/javascript/EventsApp/EventCatalog/EventList/EventListCategoryDropdown.tsx +++ b/app/javascript/EventsApp/EventCatalog/EventList/EventListCategoryDropdown.tsx @@ -29,7 +29,9 @@ function EventListCategoryDropdown({ onChange: onChangeProp, }: EventListCategoryDropdownProps): React.JSX.Element { const [interacted, setInteracted] = useState(false); - const currentCategories = eventCategories.filter((category) => (value || []).includes(category.id)); + // (values decoded from the URL are numbers, so compare them as strings) + const selectedCategoryIds = (value || []).map((id) => id.toString()); + const currentCategories = eventCategories.filter((category) => selectedCategoryIds.includes(category.id)); const { t } = useTranslation(); let categoryDescription = t('events.categoryDropdown.allCategoriesLabel'); diff --git a/app/javascript/EventsApp/EventCatalog/EventList/EventListEvents.tsx b/app/javascript/EventsApp/EventCatalog/EventList/EventListEvents.tsx index c0fb11e25a7..2579657f2a6 100644 --- a/app/javascript/EventsApp/EventCatalog/EventList/EventListEvents.tsx +++ b/app/javascript/EventsApp/EventCatalog/EventList/EventListEvents.tsx @@ -53,11 +53,10 @@ function EventListEvents({ const conventionDay = conventionDayTimespans.find((timespan) => timespan.includesTime(DateTime.fromISO(runs[0].starts_at, { zone: timezoneName })), ); - if (conventionDay && (previousConventionDay == null || !previousConventionDay.isSame(conventionDay))) { - previousConventionDay = conventionDay; - } + // a heading goes above the first event that starts on each convention day if (conventionDay && (previousConventionDay == null || !previousConventionDay.isSame(conventionDay))) { eventIds.set(event.id, conventionDay.start); + previousConventionDay = conventionDay; } } } diff --git a/app/javascript/EventsApp/EventCatalog/EventList/index.tsx b/app/javascript/EventsApp/EventCatalog/EventList/index.tsx index 2622b865302..23f8a86f04e 100644 --- a/app/javascript/EventsApp/EventCatalog/EventList/index.tsx +++ b/app/javascript/EventsApp/EventCatalog/EventList/index.tsx @@ -237,7 +237,8 @@ function EventList(): React.JSX.Element { {data && ( <> ({ + ...event, + event_category: eventCategory, +}); + +describe('EventCard', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + const renderCard = ( + event: CatalogEventEntry, + { + eventCategory = category, + canReadSchedule = true, + sortBy, + myProfile, + apolloMocks, + }: { + eventCategory?: CatalogCategory; + canReadSchedule?: boolean; + sortBy?: { id: string; desc: boolean }[]; + myProfile?: boolean; + apolloMocks?: MockLink.MockedResponse[]; + } = {}, + ) => + render( + , + { + appRootContextValue: { + ...appRootContextValue, + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + apolloMocks, + }, + ); + + it('shows the title as a link to the event, the category and the blurb', async () => { + const { getByRole, getByText } = await renderCard(buildCatalogEvent({ id: '9', title: 'Big Game' })); + + expect(getByRole('link', { name: 'Big Game' }).getAttribute('href')).toBe('/events/9-big-game'); + expect(getByText('Tabletop RPG')).toBeTruthy(); + expect(getByText('A fine game')).toBeTruthy(); + }); + + describe('when it runs', () => { + it('says it is unscheduled when there are no runs', async () => { + const { getByText } = await renderCard(buildCatalogEvent()); + + expect(getByText('Unscheduled')).toBeTruthy(); + }); + + it('does not show run times to someone who cannot read the schedule', async () => { + const { queryByText } = await renderCard( + buildCatalogEvent({ runs: [buildCatalogRun('1', '2026-06-05T18:00:00Z')] }), + { canReadSchedule: false }, + ); + + expect(queryByText('Unscheduled')).toBeNull(); + expect(document.body.textContent).not.toMatch(/6:00/); + }); + + it('lists up to four runs in time order', async () => { + const { container } = await renderCard( + buildCatalogEvent({ + runs: [buildCatalogRun('2', '2026-06-06T18:00:00Z'), buildCatalogRun('1', '2026-06-05T14:00:00Z')], + }), + ); + + const lead = container.querySelector('.lead') as HTMLElement; + const text = lead.textContent ?? ''; + expect(text.indexOf('2:00pm')).toBeGreaterThan(-1); + expect(text.indexOf('2:00pm')).toBeLessThan(text.indexOf('6:00pm')); + expect(text).toMatch(/ and /); + }); + + it('gives only the time for a second run on the same day', async () => { + const { container } = await renderCard( + buildCatalogEvent({ + runs: [buildCatalogRun('1', '2026-06-05T14:00:00Z'), buildCatalogRun('2', '2026-06-05T18:00:00Z')], + }), + ); + + const text = (container.querySelector('.lead') as HTMLElement).textContent ?? ''; + // (the date shows once per size variant, so a second run's date would add more) + expect(text.match(/June 5, 2026/g)).toHaveLength(1); + expect(text.match(/Jun 5, 2026/g)).toHaveLength(1); + expect(text).toMatch(/and 6:00pm/); + }); + + it('summarises more than four runs by count and first start', async () => { + const runs = [1, 2, 3, 4, 5].map((n) => buildCatalogRun(String(n), `2026-06-0${n}T14:00:00Z`)); + const { container } = await renderCard(buildCatalogEvent({ runs })); + + expect((container.querySelector('.lead') as HTMLElement).textContent).toMatch(/^5 runs starting /); + }); + }); + + describe('the team', () => { + it('lists displayed team members in surname order, with the category’s name for the role', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ + team_members: [ + buildCatalogTeamMember('1', 'Zed Zimmer'), + buildCatalogTeamMember('2', 'Amy Adams'), + buildCatalogTeamMember('3', 'Hidden Person', { display: false }), + ], + }), + ); + + expect(getByText('GMs:')).toBeTruthy(); + const content = getByText('GMs:').parentElement?.textContent ?? ''; + expect(content).toBe('GMs: Amy Adams, Zed Zimmer'); + }); + + it('uses the singular for one team member', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ team_members: [buildCatalogTeamMember('1', 'Amy Adams')] }), + ); + + expect(getByText('GM:')).toBeTruthy(); + }); + + it('shows avatars for team members who have them enabled', async () => { + const { container } = await renderCard( + buildCatalogEvent({ team_members: [buildCatalogTeamMember('1', 'Amy Adams', { gravatar: true })] }), + ); + + expect(container.querySelector('img')).toBeTruthy(); + }); + + it('shows no team line without displayed team members', async () => { + const { queryByText } = await renderCard(buildCatalogEvent()); + + expect(queryByText(/GMs?:/)).toBeNull(); + }); + }); + + describe('the author', () => { + const team = [buildCatalogTeamMember('1', 'Amy Adams'), buildCatalogTeamMember('2', 'Bob Brown')]; + + it('is shown when it is not just the team', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ team_members: team, form_response_attrs: { author: 'Someone Else' } }), + ); + + expect(getByText('Someone Else')).toBeTruthy(); + }); + + it('is left out when it is exactly the team', async () => { + const { queryByText } = await renderCard( + buildCatalogEvent({ team_members: team, form_response_attrs: { author: 'Amy Adams and Bob Brown' } }), + ); + + expect(queryByText(/Author/)).toBeNull(); + }); + + it('is shown when it names the team plus others', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ + team_members: team, + form_response_attrs: { author: 'Amy Adams and Bob Brown and Carol Clark' }, + }), + ); + + expect(getByText(/Carol Clark/)).toBeTruthy(); + }); + + it('pluralizes the label for several authors', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ form_response_attrs: { author: 'Carol Clark, Dan Davis' } }), + ); + + expect(getByText(/Authors:/)).toBeTruthy(); + }); + }); + + it('shows the organization', async () => { + const { getByText } = await renderCard(buildCatalogEvent({ form_response_attrs: { organization: 'Acme Games' } })); + + expect(getByText('Acme Games')).toBeTruthy(); + }); + + describe('items exposed in the catalog', () => { + const exposed = buildFreeTextItem({ identifier: 'system', caption: 'System' }); + const hidden = buildFreeTextItem({ identifier: 'secret', caption: 'Secret' }); + const richCategory = () => + buildCatalogCategory({}, [ + buildCatalogFormItem(exposed, 'Game system'), + buildCatalogFormItem({ ...hidden, id: 'item-secret' }, 'Secret thing', false), + ]); + + it('are shown with their public description and value', async () => { + const { getByText } = await renderCard( + buildCatalogEvent({ form_response_attrs: { system: 'D&D', secret: 'hush' } }), + { eventCategory: richCategory() }, + ); + + expect(getByText('Game system:')).toBeTruthy(); + expect(getByText(/D&D/)).toBeTruthy(); + }); + + it('are left out when not exposed, or when there is no value', async () => { + const { queryByText } = await renderCard(buildCatalogEvent({ form_response_attrs: { secret: 'hush' } }), { + eventCategory: richCategory(), + }); + + expect(queryByText('Game system:')).toBeNull(); + expect(queryByText('Secret thing:')).toBeNull(); + expect(queryByText(/hush/)).toBeNull(); + }); + }); + + describe('when it was added', () => { + it('is shown when sorting by order added', async () => { + const { getByText } = await renderCard(buildCatalogEvent({ created_at: '2026-01-02T12:00:00Z' }), { + sortBy: [{ id: 'created_at', desc: true }], + }); + + expect(getByText(/Added .*Jan/)).toBeTruthy(); + }); + + it('is not shown otherwise', async () => { + const { queryByText } = await renderCard(buildCatalogEvent(), { sortBy: [{ id: 'title', desc: false }] }); + + expect(queryByText(/Added/)).toBeNull(); + }); + }); + + describe('rating', () => { + it('is offered only to people with a profile', async () => { + const without = await renderCard(buildCatalogEvent()); + expect(without.queryByRole('button', { name: /favorite|like|rating/i })).toBeNull(); + without.unmount(); + + const withProfile = await renderCard(buildCatalogEvent(), { myProfile: true }); + expect(withProfile.getAllByRole('button').length).toBeGreaterThan(0); + }); + + it('sends the chosen rating for the event', async () => { + const sent = vi.fn(); + const { getAllByRole } = await renderCard(buildCatalogEvent({ id: '9' }), { + myProfile: true, + apolloMocks: [ + { + request: { + query: RateEventDocument, + variables: (variables: unknown) => { + sent(variables); + return true; + }, + }, + result: { + data: { + rateEvent: { __typename: 'RateEventPayload', event: { __typename: 'Event', id: '9', my_rating: 1 } }, + }, + }, + }, + ], + }); + + // the buttons are in the order of the ratings (like, dislike, ...) + await user.click(getAllByRole('button')[0]); + + await vi.waitFor(() => expect(sent).toHaveBeenCalled()); + expect(sent.mock.calls[0][0]).toMatchObject({ eventId: '9' }); + }); + }); +}); + +describe('EventListEvents', () => { + const renderList = ( + entries: CatalogEventEntry[], + { + sortBy, + fetchMoreIfNeeded = vi.fn(), + }: { sortBy?: { id: string; desc: boolean }[]; fetchMoreIfNeeded?: () => void } = {}, + ) => + render( + withCategory(e)), + }} + sortBy={sortBy} + canReadSchedule + fetchMoreIfNeeded={fetchMoreIfNeeded} + />, + { appRootContextValue }, + ); + + const friday = (id: string, title: string, time = '14:00') => + buildCatalogEvent({ id, title, runs: [buildCatalogRun(`r${id}`, `2026-06-05T${time}:00Z`)] }); + const saturday = (id: string, title: string) => + buildCatalogEvent({ id, title, runs: [buildCatalogRun(`r${id}`, '2026-06-06T14:00:00Z')] }); + + it('shows a card for each event, in the order given', async () => { + const { getAllByRole } = await renderList([friday('1', 'First'), friday('2', 'Second')]); + + expect(getAllByRole('heading', { level: 4 }).map((heading) => heading.textContent)).toEqual(['First', 'Second']); + }); + + describe('in convention order', () => { + const sortBy = [{ id: 'first_scheduled_run_start', desc: false }]; + + it('puts a heading for each convention day above its first event', async () => { + const { getAllByRole } = await renderList([friday('1', 'First'), friday('2', 'Second'), saturday('3', 'Third')], { + sortBy, + }); + + const headings = getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent); + expect(headings).toEqual([expect.stringMatching(/Friday/), expect.stringMatching(/Saturday/)]); + }); + + it('puts the first day’s heading above the very first event', async () => { + const { container } = await renderList([friday('1', 'First')], { sortBy }); + + const first = container.firstElementChild as HTMLElement; + expect(first.tagName).toBe('H3'); + }); + }); + + it('puts no day headings in when sorted any other way', async () => { + const { queryAllByRole } = await renderList([friday('1', 'First'), saturday('2', 'Second')], { + sortBy: [{ id: 'title', desc: false }], + }); + + expect(queryAllByRole('heading', { level: 3 })).toEqual([]); + }); +}); diff --git a/test/javascript/EventsApp/EventCatalog/EventList.test.tsx b/test/javascript/EventsApp/EventCatalog/EventList.test.tsx new file mode 100644 index 00000000000..04e23745082 --- /dev/null +++ b/test/javascript/EventsApp/EventCatalog/EventList.test.tsx @@ -0,0 +1,259 @@ +import { MockLink } from '@apollo/client/testing'; +import { vi } from 'vitest'; + +import { renderRoute, userEvent, waitFor } from '../../testUtils'; +import { Component as EventList, loader } from '../../../../app/javascript/EventsApp/EventCatalog/EventList'; +import { EventListEventsQueryDocument } from '../../../../app/javascript/EventsApp/EventCatalog/EventList/queries.generated'; +import { CommonConventionDataQueryDocument } from '../../../../app/javascript/EventsApp/queries.generated'; +import { buildMultipleChoiceItem } from '../../fixtures/formItems'; +import { + buildCatalogCategory, + buildCatalogConvention, + buildCatalogEvent, + buildCatalogFormItem, + buildCatalogRun, + buildEventListData, + CatalogEventEntry, +} from '../../fixtures/eventCatalog'; + +describe('EventList (the event catalog)', () => { + let user: ReturnType; + const queried = vi.fn(); + + beforeEach(() => { + user = userEvent.setup(); + queried.mockReset(); + }); + + const tabletop = () => buildCatalogCategory({ id: '4', name: 'Tabletop RPG' }); + const larp = () => buildCatalogCategory({ id: '5', name: 'Larp' }); + + const renderPage = ({ + entries = [buildCatalogEvent({ id: '1', title: 'Alpha' })], + path = '/events', + myProfile = false, + canReadSchedule = true, + categories = [tabletop(), larp()], + listResult, + }: { + entries?: CatalogEventEntry[]; + path?: string; + myProfile?: boolean; + canReadSchedule?: boolean; + categories?: ReturnType[]; + listResult?: MockLink.MockedResponse['result']; + } = {}) => { + const apolloMocks: MockLink.MockedResponse[] = [ + { + request: { query: CommonConventionDataQueryDocument }, + result: { data: { __typename: 'Query', convention: buildCatalogConvention(categories) } }, + maxUsageCount: 20, + }, + { + request: { + query: EventListEventsQueryDocument, + variables: (variables: unknown) => { + queried(variables); + return true; + }, + }, + result: listResult ?? { data: buildEventListData(entries, { canReadSchedule }) }, + maxUsageCount: 50, + }, + ]; + return renderRoute( + [ + { path: '/events', loader, Component: EventList }, + { path: '/events/:id', Component: () =>
Event page
}, + ], + { + apolloMocks, + initialEntries: [path], + appRootContextValue: { + timezoneName: 'UTC', + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + }, + ); + }; + + const lastVariables = () => queried.mock.calls[queried.mock.calls.length - 1][0]; + + it('lists the events with a heading and tabs for the list and table views', async () => { + const r = await renderPage({ + entries: [buildCatalogEvent({ id: '1', title: 'Alpha' }), buildCatalogEvent({ id: '2', title: 'Beta' })], + }); + + expect(await r.findByRole('link', { name: 'Alpha' })).toBeTruthy(); + expect(r.getByRole('link', { name: 'Beta' })).toBeTruthy(); + expect(r.getByRole('heading', { level: 1, name: 'Event Catalog' })).toBeTruthy(); + expect(r.getByRole('link', { name: 'Table view' }).getAttribute('href')).toBe('/events/table'); + }); + + describe('what it asks for by default', () => { + it('sorts by title with every category, for a visitor', async () => { + const r = await renderPage(); + await r.findByRole('link', { name: 'Alpha' }); + + expect(lastVariables()).toMatchObject({ + page: 1, + pageSize: 20, + sort: [{ field: 'title', desc: false }], + filters: { category: [] }, + }); + }); + + it('puts favorites first for someone with a profile, showing liked and unrated events', async () => { + const r = await renderPage({ myProfile: true }); + await r.findByRole('link', { name: 'Alpha' }); + + expect(lastVariables()).toMatchObject({ + sort: [ + { field: 'my_rating', desc: true }, + { field: 'title', desc: false }, + ], + filters: { my_rating: [1, 0], category: [] }, + }); + }); + + it('offers the rating filter only to people with a profile', async () => { + const visitor = await renderPage(); + await visitor.findByRole('link', { name: 'Alpha' }); + expect(visitor.queryByText('Show:')).toBeNull(); + visitor.unmount(); + + const member = await renderPage({ myProfile: true }); + expect(await member.findByText('Show:')).toBeTruthy(); + }); + }); + + describe('the URL', () => { + it('sets the sort order', async () => { + const r = await renderPage({ path: '/events?sort.created_at=desc' }); + await r.findByRole('link', { name: 'Alpha' }); + + expect(lastVariables().sort).toEqual([{ field: 'created_at', desc: true }]); + }); + + it('sets filters, decoding the lists', async () => { + const r = await renderPage({ path: '/events?filters.category=4,5&filters.text_search=dragon' }); + await r.findByRole('link', { name: 'Alpha' }); + + expect(lastVariables().filters).toMatchObject({ category: [4, 5], text_search: 'dragon' }); + }); + }); + + describe('searching', () => { + it('asks again with the text typed, and keeps it in the URL', async () => { + const r = await renderPage(); + await r.findByRole('link', { name: 'Alpha' }); + + await user.type(r.getByLabelText('Search'), 'dragon'); + + await waitFor(() => expect(lastVariables().filters).toMatchObject({ text_search: 'dragon' })); + }); + }); + + describe('sorting', () => { + it('offers convention order only to people who can read the schedule', async () => { + const allowed = await renderPage(); + await allowed.findByRole('link', { name: 'Alpha' }); + await user.click(allowed.getByRole('button', { name: /Sort by/ })); + expect(await allowed.findByRole('button', { name: 'Convention order' })).toBeTruthy(); + allowed.unmount(); + + const denied = await renderPage({ canReadSchedule: false }); + await denied.findByRole('link', { name: 'Alpha' }); + await user.click(denied.getByRole('button', { name: /Sort by/ })); + expect(await denied.findByRole('button', { name: 'Order added (newest first)' })).toBeTruthy(); + expect(denied.queryByRole('button', { name: 'Convention order' })).toBeNull(); + }); + + it('asks again in the chosen order', async () => { + const r = await renderPage(); + await r.findByRole('link', { name: 'Alpha' }); + + await user.click(r.getByRole('button', { name: /Sort by/ })); + await user.click(await r.findByRole('button', { name: 'Order added (newest first)' })); + + await waitFor(() => expect(lastVariables().sort).toEqual([{ field: 'created_at', desc: true }])); + }); + }); + + describe('convention order', () => { + it('puts a heading for each day above its first event', async () => { + const r = await renderPage({ + path: '/events?sort.first_scheduled_run_start=asc', + entries: [ + buildCatalogEvent({ id: '1', title: 'Alpha', runs: [buildCatalogRun('r1', '2026-06-05T14:00:00Z')] }), + buildCatalogEvent({ id: '2', title: 'Beta', runs: [buildCatalogRun('r2', '2026-06-06T14:00:00Z')] }), + ], + }); + + await r.findByRole('link', { name: 'Alpha' }); + expect(r.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent)).toEqual([ + expect.stringMatching(/Friday/), + expect.stringMatching(/Saturday/), + ]); + }); + }); + + describe('categories', () => { + it('describes the selection, all event types by default', async () => { + const r = await renderPage(); + + expect(await r.findByRole('button', { name: 'All event types' })).toBeTruthy(); + }); + + it('names a single selected category', async () => { + const r = await renderPage({ path: '/events?filters.category=5' }); + + expect(await r.findByRole('button', { name: 'Larp events' })).toBeTruthy(); + }); + + it('asks again for just the categories chosen', async () => { + const r = await renderPage(); + await user.click(await r.findByRole('button', { name: 'All event types' })); + + await user.click(await r.findByRole('checkbox', { name: 'Larp' })); + + await waitFor(() => expect(lastVariables().filters.category).toEqual([4])); + }); + }); + + describe('filterable form items', () => { + const system = buildMultipleChoiceItem({ + identifier: 'system', + caption: 'System', + choices: [ + { caption: 'D&D', value: 'dnd' }, + { caption: 'Fate', value: 'fate' }, + ], + }); + + it('gets a dropdown, and its identifier is fetched with the events', async () => { + const r = await renderPage({ + categories: [buildCatalogCategory({ id: '4' }, [buildCatalogFormItem(system, 'Game system')])], + }); + + expect(await r.findByRole('button', { name: 'Game system' })).toBeTruthy(); + expect(lastVariables().fetchFormItemIdentifiers).toEqual(['system']); + }); + + it('is left out for items that are not exposed in the catalog', async () => { + const r = await renderPage({ + categories: [buildCatalogCategory({ id: '4' }, [buildCatalogFormItem(system, 'Game system', false)])], + }); + await r.findByRole('link', { name: 'Alpha' }); + + expect(r.queryByRole('button', { name: 'Game system' })).toBeNull(); + expect(lastVariables().fetchFormItemIdentifiers).toEqual([]); + }); + }); + + it('shows the error if the events cannot be fetched', async () => { + const r = await renderPage({ listResult: { errors: [{ message: 'Search is down' } as never] } }); + + expect(await r.findByText(/Search is down/)).toBeTruthy(); + }); +}); diff --git a/test/javascript/EventsApp/RunList.test.tsx b/test/javascript/EventsApp/RunList.test.tsx new file mode 100644 index 00000000000..d6f281a0233 --- /dev/null +++ b/test/javascript/EventsApp/RunList.test.tsx @@ -0,0 +1,241 @@ +import { MockLink } from '@apollo/client/testing'; +import { Suspense } from 'react'; +import { createRef } from 'react'; +import { vi } from 'vitest'; + +import { renderRoute, waitFor } from '../testUtils'; +import RunList from '../../../app/javascript/EventsApp/RunList'; +import { + ScheduleGridEventsQueryData, + ScheduleGridEventsQueryDocument, +} from '../../../app/javascript/EventsApp/ScheduleGrid/queries.generated'; +import { SignupState } from '../../../app/javascript/graphqlTypes.generated'; +import { buildCatalogCategory, buildCatalogConvention } from '../fixtures/eventCatalog'; + +type ScheduleEvent = ScheduleGridEventsQueryData['convention']['events'][number]; +type ScheduleRun = ScheduleEvent['runs'][number]; + +const run = (id: string, startsAt: string, overrides: Partial = {}): ScheduleRun => ({ + __typename: 'Run', + 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, +}); + +const event = ( + id: string, + title: string, + runs: ScheduleRun[], + overrides: Partial = {}, +): ScheduleEvent => ({ + __typename: 'Event', + id, + title, + length_seconds: 2 * 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, + ...overrides, +}); + +describe('RunList', () => { + const queried = vi.fn(); + const convention = { ...buildCatalogConvention([buildCatalogCategory({ id: '4' })]), blockPartial: null }; + + const renderList = async ( + events: ScheduleEvent[], + { + search = '', + myProfile = false, + filters, + }: { search?: string; myProfile?: boolean; filters?: { text?: string } } = {}, + ) => { + const apolloMocks: MockLink.MockedResponse[] = [ + { + request: { + query: ScheduleGridEventsQueryDocument, + variables: (variables: unknown) => { + queried(variables); + return true; + }, + }, + result: { data: { __typename: 'Query', convention: { __typename: 'Convention', id: '1', events } } }, + maxUsageCount: 10, + }, + ]; + + return renderRoute( + [ + { + path: '/events/schedule', + Component: () => ( + Loading schedule}> + ()} + /> + + ), + }, + ], + { + apolloMocks, + initialEntries: [`/events/schedule${search}`], + appRootContextValue: { + timezoneName: 'UTC', + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + }, + ); + }; + + beforeEach(() => queried.mockReset()); + + it('asks for the events with the filters and the form item identifiers', async () => { + const { findByText } = await renderList([event('1', 'Alpha', [run('r1', '2026-06-05T14:00:00Z')])], { + filters: { text: 'dragon' }, + }); + + await findByText(/Alpha/); + expect(queried).toHaveBeenCalledWith( + expect.objectContaining({ filters: { text_search: 'dragon' }, fetchFormItemIdentifiers: ['system'] }), + ); + }); + + it('groups runs under a heading for each day', async () => { + const { findAllByRole } = await renderList([ + event('1', 'Alpha', [run('r1', '2026-06-05T14:00:00Z'), run('r3', '2026-06-06T14:00:00Z')]), + event('2', 'Beta', [run('r2', '2026-06-05T18:00:00Z')]), + ]); + + const headings = (await findAllByRole('heading', { level: 3 })).map((heading) => heading.textContent); + expect(headings).toEqual([expect.stringMatching(/Friday/), expect.stringMatching(/Saturday/)]); + }); + + it('shows a time heading, once, for runs starting together, ordered by title', async () => { + const { findByText, getAllByText, container } = await renderList([ + event('1', 'Zulu', [run('r1', '2026-06-05T14:00:00Z')]), + event('2', 'alpha', [run('r2', '2026-06-05T14:00:00Z')]), + event('3', 'Later', [run('r3', '2026-06-05T18:00:00Z')]), + ]); + + await findByText(/alpha/); + expect(getAllByText(/2:00pm/, { selector: 'div.mt-2' })).toHaveLength(1); + expect(getAllByText(/6:00pm/, { selector: 'div.mt-2' })).toHaveLength(1); + const text = container.textContent ?? ''; + expect(text.indexOf('alpha')).toBeLessThan(text.indexOf('Zulu')); + expect(text.indexOf('Zulu')).toBeLessThan(text.indexOf('Later')); + }); + + it('shows each run’s title with when it ends', async () => { + const { findByText } = await renderList([event('1', 'Alpha', [run('r1', '2026-06-05T14:00:00Z')])]); + + expect(await findByText(/Alpha - until 4:00pm/)).toBeTruthy(); + }); + + it('shows nothing for no runs', async () => { + const { queryAllByRole, queryByText } = await renderList([event('1', 'Unscheduled', [])]); + + await waitFor(() => expect(queryByText('Loading schedule')).toBeNull()); + expect(queryAllByRole('heading', { level: 3 })).toEqual([]); + }); + + describe('for someone with a profile', () => { + const events = () => [ + event('1', 'Liked', [run('r1', '2026-06-05T14:00:00Z')], { my_rating: 1 }), + event('2', 'Unrated', [run('r2', '2026-06-05T14:00:00Z')], { my_rating: null }), + event('3', 'Disliked', [run('r3', '2026-06-05T14:00:00Z')], { my_rating: -1 }), + ]; + + it('hides disliked events by default, and says how many are hidden', async () => { + const { findByText, queryByText } = await renderList(events(), { + myProfile: true, + search: '?filters.my_rating=1,0&filters.hide_conflicts=false', + }); + + expect(await findByText(/Liked - until/)).toBeTruthy(); + expect(queryByText(/Disliked - until/)).toBeNull(); + expect(await findByText('+1 hidden')).toBeTruthy(); + }); + + it('follows the rating filter in the URL', async () => { + const { findByText, queryByText } = await renderList(events(), { + myProfile: true, + search: '?filters.my_rating=-1&filters.hide_conflicts=false', + }); + + expect(await findByText(/Disliked - until/)).toBeTruthy(); + expect(queryByText(/Liked - until/)).toBeNull(); + expect(await findByText('+2 hidden')).toBeTruthy(); + }); + + it('hides runs that overlap one the user is signed up for when asked to hide conflicts, but not the signed-up run', async () => { + const { findByText, queryByText } = await renderList( + [ + event( + '1', + 'Signed Up', + [ + run('r1', '2026-06-05T14:00:00Z', { + my_signups: [{ __typename: 'Signup', id: 's1', state: SignupState.Confirmed }], + }), + ], + { my_rating: 1 }, + ), + event('2', 'Overlapping', [run('r2', '2026-06-05T15:00:00Z')], { my_rating: 1 }), + event('3', 'Clear', [run('r3', '2026-06-05T20:00:00Z')], { my_rating: 1 }), + ], + { myProfile: true, search: '?filters.my_rating=1&filters.hide_conflicts=true' }, + ); + + expect(await findByText(/Clear - until/)).toBeTruthy(); + expect(await findByText(/Signed Up - until/)).toBeTruthy(); + expect(queryByText(/Overlapping - until/)).toBeNull(); + expect(await findByText('+1 hidden')).toBeTruthy(); + }); + + it('shows overlapping runs when not hiding conflicts', async () => { + const { findByText } = await renderList( + [ + event( + '1', + 'Signed Up', + [ + run('r1', '2026-06-05T14:00:00Z', { + my_signups: [{ __typename: 'Signup', id: 's1', state: SignupState.Confirmed }], + }), + ], + { my_rating: 1 }, + ), + event('2', 'Overlapping', [run('r2', '2026-06-05T15:00:00Z')], { my_rating: 1 }), + ], + { myProfile: true, search: '?filters.my_rating=1&filters.hide_conflicts=false' }, + ); + + expect(await findByText(/Overlapping - until/)).toBeTruthy(); + }); + }); + + it('does not filter by rating for someone who is not signed in', async () => { + const { findByText } = await renderList([ + event('1', 'Disliked', [run('r1', '2026-06-05T14:00:00Z')], { my_rating: -1 }), + ]); + + expect(await findByText(/Disliked - until/)).toBeTruthy(); + }); +}); diff --git a/test/javascript/EventsApp/ScheduleApp.test.tsx b/test/javascript/EventsApp/ScheduleApp.test.tsx new file mode 100644 index 00000000000..8525eefa21b --- /dev/null +++ b/test/javascript/EventsApp/ScheduleApp.test.tsx @@ -0,0 +1,254 @@ +import { vi } from 'vitest'; +import { Suspense } from 'react'; +import { MockLink } from '@apollo/client/testing'; +import { DateTime } from 'luxon'; + +import { renderRoute, userEvent, waitFor } from '../testUtils'; +import { Component as ScheduleApp } from '../../../app/javascript/EventsApp/ScheduleApp'; +import { ScheduleGridConventionDataQueryData } from '../../../app/javascript/EventsApp/ScheduleGrid/queries.generated'; +import { ScheduleGridEventsQueryDocument } from '../../../app/javascript/EventsApp/ScheduleGrid/queries.generated'; +import { appRootContextDefaultValue, AppRootContextValue } from '../../../app/javascript/AppRootContext'; +import { SiteMode } from '../../../app/javascript/graphqlTypes.generated'; +import Timespan from '../../../app/javascript/Timespan'; +import { buildMultipleChoiceItem } from '../fixtures/formItems'; +import { buildCatalogCategory, buildCatalogConvention, buildCatalogFormItem } from '../fixtures/eventCatalog'; + +// The grids are big and have their own tests; here they're a marker saying which one was asked for +vi.mock('../../../app/javascript/EventsApp/ScheduleGrid', () => ({ + default: ({ configKey, fetchFormItemIdentifiers }: { configKey: string; fetchFormItemIdentifiers: string[] }) => ( +
+ grid: {configKey} ({fetchFormItemIdentifiers.join(',')}) +
+ ), +})); + +const SHORT_CON = Timespan.finiteFromDateTimes( + DateTime.fromISO('2026-06-05T00:00:00Z'), + DateTime.fromISO('2026-06-07T23:00:00Z'), +); +const LONG_CON = Timespan.finiteFromDateTimes( + DateTime.fromISO('2026-06-01T00:00:00Z'), + DateTime.fromISO('2026-06-20T23:00:00Z'), +); + +describe('ScheduleApp', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + const loaderData = ( + categories = [buildCatalogCategory({ id: '4' })], + ): { data: ScheduleGridConventionDataQueryData } => ({ + data: { + __typename: 'Query', + currentAbility: { __typename: 'Ability', can_create_cms_partials: false }, + convention: { + ...buildCatalogConvention(categories), + blockPartial: null, + }, + }, + }); + + const renderPage = ({ + ability = {}, + conventionTimespan = SHORT_CON, + siteMode = SiteMode.Convention, + categories, + search = '', + myProfile = false, + }: { + ability?: Partial; + conventionTimespan?: Timespan; + siteMode?: SiteMode; + categories?: ReturnType[]; + search?: string; + myProfile?: boolean; + } = {}) => { + const apolloMocks: MockLink.MockedResponse[] = [ + { + request: { query: ScheduleGridEventsQueryDocument, variables: () => true }, + result: { data: { __typename: 'Query', convention: { __typename: 'Convention', id: '1', events: [] } } }, + maxUsageCount: 10, + }, + ]; + return renderRoute( + [ + { + path: '/events/schedule', + loader: () => loaderData(categories), + Component: () => ( + Loading}> + + + ), + }, + ], + { + apolloMocks, + initialEntries: [`/events/schedule${search}`], + appRootContextValue: { + timezoneName: 'UTC', + conventionTimespan, + siteMode, + currentAbility: { + ...appRootContextDefaultValue.currentAbility, + can_read_schedule: true, + can_read_schedule_with_counts: false, + ...ability, + }, + ...(myProfile ? { myProfile: { __typename: 'UserConProfile', id: '2', name: 'Me' } as never } : {}), + }, + }, + ); + }; + + type Rendered = Awaited>; + + const viewNames = async (r: Rendered) => { + await user.click(await r.findByRole('button', { name: /View type/ })); + return r + .getAllByRole('button') + .filter((button) => button.classList.contains('dropdown-item')) + .map((b) => b.textContent); + }; + + it('has a breadcrumb for the event schedule', async () => { + const r = await renderPage(); + + expect(await r.findByText('Event Schedule')).toBeTruthy(); + }); + + describe('choosing the view', () => { + it('starts on the first grid on a wide screen', async () => { + const r = await renderPage(); + + expect(await r.findByText(/grid: con_schedule \(/)).toBeTruthy(); + }); + + it('starts on the list on a narrow screen', async () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(500); + const r = await renderPage(); + + expect(await r.findByRole('button', { name: /View type/ })).toHaveTextContent(/List view/); + expect(r.queryByText(/grid:/)).toBeNull(); + }); + + it('goes back to the view that was chosen last time', async () => { + window.localStorage.setItem('schedule:view', 'con_schedule_by_room'); + const r = await renderPage(); + + expect(await r.findByText(/grid: con_schedule_by_room/)).toBeTruthy(); + }); + + it('ignores a remembered view that does not exist', async () => { + window.localStorage.setItem('schedule:view', 'nonsense'); + const r = await renderPage(); + + expect(await r.findByText(/grid: con_schedule \(/)).toBeTruthy(); + }); + + it('lets the view be chosen, and remembers it', async () => { + const r = await renderPage(); + await r.findByText(/grid: con_schedule \(/); + + await user.click(await r.findByRole('button', { name: /View type/ })); + await user.click(await r.findByRole('button', { name: 'List view' })); + + expect(window.localStorage.getItem('schedule:view')).toBe('list'); + await waitFor(() => expect(r.queryByText(/grid:/)).toBeNull()); + }); + + it('does not offer the grid with counts to someone who cannot read the schedule with counts', async () => { + const r = await renderPage(); + + const names = await viewNames(r); + + expect(names).toContain('List view'); + expect(names).not.toContain('Grid view with counts'); + }); + + it('offers the grid with counts to someone who can', async () => { + const r = await renderPage({ ability: { can_read_schedule_with_counts: true } }); + + expect(await viewNames(r)).toContain('Grid view with counts'); + }); + + it('offers only the list for a convention a week or longer, which needs dates', async () => { + const r = await renderPage({ conventionTimespan: LONG_CON }); + + await waitFor(() => expect(r.queryByRole('button', { name: /View type: List view/ })).toBeNull()); + expect(r.queryByText(/grid:/)).toBeNull(); + }); + + it('falls back to the list if the remembered view is a grid that is not available', async () => { + window.localStorage.setItem('schedule:view', 'schedule_with_counts'); + const r = await renderPage({ ability: { can_read_schedule_with_counts: false } }); + + await waitFor(() => expect(r.queryByText(/grid: schedule_with_counts/)).toBeNull()); + expect(await r.findByText(/grid: con_schedule \(/)).toBeTruthy(); + }); + }); + + describe('authorization', () => { + it('says so, instead of showing a schedule, to someone who cannot read it', async () => { + const r = await renderPage({ ability: { can_read_schedule: false } }); + + expect(await r.findByText(/not authorized/)).toBeTruthy(); + expect(r.queryByText(/grid:/)).toBeNull(); + }); + + it('moves someone off the counts grid to a plain one if they can only read the plain schedule', async () => { + window.localStorage.setItem('schedule:view', 'schedule_with_counts'); + const r = await renderPage({ ability: { can_read_schedule: true, can_read_schedule_with_counts: false } }); + + expect(await r.findByText(/grid: con_schedule \(/)).toBeTruthy(); + }); + }); + + describe('filters', () => { + const system = buildMultipleChoiceItem({ + identifier: 'system', + caption: 'System', + choices: [ + { caption: 'D&D', value: 'dnd' }, + { caption: 'Fate', value: 'fate' }, + ], + }); + const withSystem = () => [buildCatalogCategory({ id: '4' }, [buildCatalogFormItem(system, 'Game system')])]; + + it('offers a dropdown for each filterable form item, and fetches those items with the schedule', async () => { + const r = await renderPage({ categories: withSystem() }); + + expect(await r.findByRole('button', { name: 'Game system' })).toBeTruthy(); + expect(await r.findByText(/grid: con_schedule \(system\)/)).toBeTruthy(); + }); + + it('offers none when no form items are exposed', async () => { + const r = await renderPage(); + await r.findByText(/grid:/); + + expect(r.queryByRole('button', { name: 'Game system' })).toBeNull(); + }); + + it('offers the personal filters on the grids that have them, not on the one with counts', async () => { + const withFilters = await renderPage(); + await withFilters.findByText(/grid: con_schedule \(/); + // (the filters dropdown is the only thing with a Select all) + expect(withFilters.getByRole('button', { name: 'Select all' })).toBeTruthy(); + withFilters.unmount(); + + window.localStorage.setItem('schedule:view', 'schedule_with_counts'); + const withCounts = await renderPage({ ability: { can_read_schedule_with_counts: true } }); + await withCounts.findByText(/grid: schedule_with_counts/); + expect(withCounts.queryByRole('button', { name: 'Select all' })).toBeNull(); + }); + }); +}); diff --git a/test/javascript/EventsApp/useFilterableFormItems.test.ts b/test/javascript/EventsApp/useFilterableFormItems.test.ts new file mode 100644 index 00000000000..61dacf8301a --- /dev/null +++ b/test/javascript/EventsApp/useFilterableFormItems.test.ts @@ -0,0 +1,87 @@ +import { vi } from 'vitest'; + +import { + getFilterableEventFormItems, + mergeFormItemsAcrossForms, +} from '../../../app/javascript/EventsApp/useFilterableFormItems'; +import { MultipleChoiceFormItem } from '../../../app/javascript/FormAdmin/FormItemUtils'; +import { buildFreeTextItem, buildMultipleChoiceItem } from '../fixtures/formItems'; +import { buildCatalogCategory, buildCatalogConvention, buildCatalogFormItem } from '../fixtures/eventCatalog'; + +const choices = (item: unknown) => (item as MultipleChoiceFormItem).rendered_properties.choices.map((c) => c.value); + +describe('getFilterableEventFormItems', () => { + const system = (id: string, values: string[]) => + buildCatalogFormItem( + { + ...buildMultipleChoiceItem({ + identifier: 'system', + choices: values.map((value) => ({ caption: value.toUpperCase(), value })), + }), + id, + }, + 'Game system', + ); + + it('is empty when nothing is exposed in the catalog', () => { + const hidden = buildCatalogFormItem(buildFreeTextItem({ identifier: 'secret' }), 'Secret', false); + + expect(getFilterableEventFormItems(buildCatalogConvention([buildCatalogCategory({}, [hidden])]))).toEqual([]); + }); + + it('includes the exposed items, once each, across categories', () => { + const convention = buildCatalogConvention([ + buildCatalogCategory({ id: '4' }, [buildCatalogFormItem(buildFreeTextItem({ identifier: 'theme' }), 'Theme')]), + buildCatalogCategory({ id: '5' }, [ + buildCatalogFormItem({ ...buildFreeTextItem({ identifier: 'theme' }), id: 'other-theme' }, 'Theme'), + ]), + ]); + + const items = getFilterableEventFormItems(convention); + + expect(items.map((item) => item.identifier)).toEqual(['theme']); + }); + + it('merges the choices of a multiple choice item that several categories have, without duplicates', () => { + const convention = buildCatalogConvention([ + buildCatalogCategory({ id: '4' }, [system('a', ['dnd', 'fate'])]), + buildCatalogCategory({ id: '5' }, [system('b', ['fate', 'gurps'])]), + ]); + + const [item] = getFilterableEventFormItems(convention); + + expect(choices(item)).toEqual(['dnd', 'fate', 'gurps']); + }); +}); + +describe('mergeFormItemsAcrossForms', () => { + const form = (...items: ReturnType[]) => ({ form_sections: [{ form_items: items }] }); + + it('keeps items with different identifiers separate', () => { + const merged = mergeFormItemsAcrossForms([ + form( + buildCatalogFormItem(buildFreeTextItem({ identifier: 'one' }), 'One'), + buildCatalogFormItem({ ...buildFreeTextItem({ identifier: 'two' }), id: 'two' }, 'Two'), + ), + ]); + + expect(merged.map((item) => item.identifier)).toEqual(['one', 'two']); + }); + + it('warns and takes the first when items of different types share an identifier', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const merged = mergeFormItemsAcrossForms([ + form(buildCatalogFormItem(buildFreeTextItem({ identifier: 'clash' }), 'Clash')), + form(buildCatalogFormItem({ ...buildMultipleChoiceItem({ identifier: 'clash' }), id: 'mc' }, 'Clash')), + ]); + + expect(merged).toHaveLength(1); + expect(merged[0].item_type).toBe('free_text'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Don't know how to merge")); + warn.mockRestore(); + }); + + it('is empty for no forms', () => { + expect(mergeFormItemsAcrossForms([])).toEqual([]); + }); +}); diff --git a/test/javascript/fixtures/eventCatalog.ts b/test/javascript/fixtures/eventCatalog.ts new file mode 100644 index 00000000000..4f25e4f6a8a --- /dev/null +++ b/test/javascript/fixtures/eventCatalog.ts @@ -0,0 +1,146 @@ +import { EventListEventsQueryData } from '../../../app/javascript/EventsApp/EventCatalog/EventList/queries.generated'; +import { CommonConventionDataFragment } from '../../../app/javascript/EventsApp/queries.generated'; +import { TypedFormItem } from '../../../app/javascript/FormAdmin/FormItemUtils'; +import { + FormItemExposeIn, + SchedulingUi, + SignupAutomationMode, + SignupMode, + SiteMode, + TicketMode, + TimezoneMode, +} from '../../../app/javascript/graphqlTypes.generated'; + +export type CatalogCategory = CommonConventionDataFragment['event_categories'][number]; +export type CatalogEventEntry = EventListEventsQueryData['convention']['events_paginated']['entries'][number]; +export type CatalogTeamMember = CatalogEventEntry['team_members'][number]; + +// form items that are shown in the catalog (item.expose_in) +export function buildCatalogFormItem(item: TypedFormItem, publicDescription: string, exposeIn = true) { + return { + __typename: 'FormItem' as const, + id: item.id, + public_description: publicDescription, + default_value: null, + position: 1, + identifier: item.identifier ?? null, + item_type: item.item_type, + rendered_properties: JSON.stringify(item.rendered_properties), + visibility: item.visibility, + writeability: item.writeability, + expose_in: exposeIn ? [FormItemExposeIn.EventCatalog] : null, + }; +} + +export function buildCatalogCategory( + overrides: Partial = {}, + formItems: ReturnType[] = [], +): CatalogCategory { + return { + __typename: 'EventCategory', + id: '4', + name: 'Tabletop RPG', + scheduling_ui: SchedulingUi.Regular, + default_color: '#336699', + full_color: '#999999', + signed_up_color: '#cc9900', + team_member_name: 'GM', + teamMemberNamePlural: 'GMs', + event_form: { + __typename: 'Form', + id: `form-${overrides.id ?? '4'}`, + form_sections: [{ __typename: 'FormSection', id: `section-${overrides.id ?? '4'}`, form_items: formItems }], + }, + ...overrides, + }; +} + +export function buildCatalogConvention( + categories: CatalogCategory[] = [buildCatalogCategory()], + overrides: Partial = {}, +): CommonConventionDataFragment { + return { + __typename: 'Convention', + id: '1', + name: 'Test Con', + starts_at: '2026-06-05T00:00:00Z', + ends_at: '2026-06-07T23:00:00Z', + signup_mode: SignupMode.SelfService, + signup_automation_mode: SignupAutomationMode.None, + site_mode: SiteMode.Convention, + timezone_name: 'UTC', + timezone_mode: TimezoneMode.ConventionLocal, + ticket_name: 'ticket', + ticket_mode: TicketMode.Disabled, + event_categories: categories, + ...overrides, + }; +} + +export function buildCatalogTeamMember( + id: string, + name: string, + { lastName = name.split(' ').slice(-1)[0], display = true, gravatar = false } = {}, +): CatalogTeamMember { + return { + __typename: 'TeamMember', + id, + display_team_member: display, + user_con_profile: { + __typename: 'UserConProfile', + id: `ucp-${id}`, + last_name: lastName, + name_without_nickname: name, + gravatar_enabled: gravatar, + gravatar_url: 'https://example.com/avatar.png', + }, + }; +} + +export function buildCatalogEvent( + overrides: Partial & { form_response_attrs?: Record } = {}, +): CatalogEventEntry { + const { form_response_attrs: formResponseAttrs, ...rest } = overrides; + return { + __typename: 'Event', + id: '9', + title: 'Big Game', + created_at: '2026-01-02T12:00:00Z', + short_blurb_html: '

A fine game

', + form_response_attrs_json_with_rendered_markdown: JSON.stringify(formResponseAttrs ?? {}), + my_rating: null, + length_seconds: 3 * 3600, + event_category: { __typename: 'EventCategory', id: '4' }, + runs: [], + team_members: [], + registration_policy: null, + ...rest, + }; +} + +export function buildCatalogRun(id: string, startsAt: string) { + return { __typename: 'Run' as const, id, starts_at: startsAt }; +} + +export function buildEventListData( + entries: CatalogEventEntry[], + { canReadSchedule = true, currentPage = 1, totalPages = 1 } = {}, +): EventListEventsQueryData { + return { + __typename: 'Query', + currentAbility: { __typename: 'Ability', can_read_schedule: canReadSchedule }, + convention: { + __typename: 'Convention', + id: '1', + timezone_mode: TimezoneMode.ConventionLocal, + events_paginated: { + __typename: 'EventsPagination', + total_entries: entries.length, + total_pages: totalPages, + current_page: currentPage, + per_page: 20, + entries, + }, + }, + }; +}