From 944202ef7770168b984999dbe85ad8f72f7c1cee Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Tue, 6 Oct 2026 04:52:34 -0700 Subject: [PATCH] Test the ranked choice results page and signup preview; fix reason filter - RankedChoiceSignupDecisionsPage: the reason filter used the integer array codec, but reasons are enum names (CONFLICT, FULL, ...), so a reason filter in the URL decoded to nothing and could never be applied. It uses the string array codec now. - Reason descriptions: NO_PENDING_CHOICES and RANKED_CHOICE_USER_CONSTRAINTS both used the noPendingChoices translation, whose text said "user constraints", so the former was mislabelled. Each has its own key now. - A signup round id that doesn't exist crashed the page reading round.timespan; the loader throws a 404 instead. Tests: describeDecision/describeReason, the results page (round naming, executed message, table contents, query variables, URL filters), and MaximumEventSignupsPreview (calendar months, colours, transitions, tooltips, too-long and empty ranges). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013mDr1nhgyzST2iPzCzMcCd --- .../RankedChoiceSignupDecisionsPage.tsx | 10 +- locales/en.json | 3 +- .../MaximumEventSignupsPreview.test.tsx | 151 +++++++++++ .../RankedChoiceSignupDecisionsPage.test.tsx | 240 ++++++++++++++++++ 4 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 test/javascript/SignupRoundsAdmin/MaximumEventSignupsPreview.test.tsx create mode 100644 test/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage.test.tsx diff --git a/app/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage.tsx b/app/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage.tsx index 2d8a2f97827..a499d3df9ca 100644 --- a/app/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage.tsx +++ b/app/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage.tsx @@ -95,7 +95,7 @@ export function describeReason(reason: RankedChoiceDecisionReason, ticketName: s } if (reason === RankedChoiceDecisionReason.RankedChoiceUserConstraints) { - return t('tables.rankedChoiceDecision.reasons.noPendingChoices'); + return t('tables.rankedChoiceDecision.reasons.rankedChoiceUserConstraints'); } if (reason === RankedChoiceDecisionReason.TeamMember) { @@ -148,7 +148,8 @@ export function RankedChoiceReasonCell = async ({ context }) => { +export const loader: LoaderFunction = async ({ context, params: { id } }) => { const client = context.get(apolloClientContext); const { data } = await client.query({ query: SignupRoundsAdminQueryDocument }); + if (!data?.convention.signup_rounds.some((round) => round.id === id)) { + throw new Response('Not Found', { status: 404 }); + } return data; }; diff --git a/locales/en.json b/locales/en.json index 25dca9af0d7..0908731ed4e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1588,7 +1588,8 @@ "full": "event full", "missingTicket": "{{ ticketName }} required", "noMoreSignupsAllowed": "no more signups allowed", - "noPendingChoices": "user constraints", + "noPendingChoices": "no pending choices", + "rankedChoiceUserConstraints": "user constraints", "teamMember": "team member", "waitlistPositionCapExceeded": "waitlist position cap exceeded" } diff --git a/test/javascript/SignupRoundsAdmin/MaximumEventSignupsPreview.test.tsx b/test/javascript/SignupRoundsAdmin/MaximumEventSignupsPreview.test.tsx new file mode 100644 index 00000000000..0068cf91146 --- /dev/null +++ b/test/javascript/SignupRoundsAdmin/MaximumEventSignupsPreview.test.tsx @@ -0,0 +1,151 @@ +import { render, userEvent } from '../testUtils'; +import MaximumEventSignupsPreview from '../../../app/javascript/SignupRoundsAdmin/MaximumEventSignupsPreview'; +import { SignupRoundsAdminQueryData } from '../../../app/javascript/SignupRoundsAdmin/queries.generated'; + +type Round = SignupRoundsAdminQueryData['convention']['signup_rounds'][number]; + +const round = (id: string, start: string | null, maximum: string): Round => ({ + __typename: 'SignupRound', + id, + start, + maximum_event_signups: maximum, + automation_action: 'none' as Round['automation_action'], + ranked_choice_order: null, + rerandomize_lottery_numbers: false, + executed_at: null, +}); + +describe('MaximumEventSignupsPreview', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + const renderPreview = (rounds: Round[]) => + render(); + + // not yet until June 10, one signup from then until June 20, then unlimited + const typicalRounds = () => [ + round('1', null, 'not_yet'), + round('2', '2026-06-10T12:00:00Z', '1'), + round('3', '2026-06-20T12:00:00Z', 'unlimited'), + ]; + + it('shows a calendar month for the range the changes cover', async () => { + const { getAllByRole, getByText } = await renderPreview(typicalRounds()); + + expect(getAllByRole('grid')).toHaveLength(1); + expect(getByText('June 2026')).toBeTruthy(); + }); + + it('shows a month for each month the changes span', async () => { + const { getAllByRole, getByText } = await renderPreview([ + round('1', null, 'not_yet'), + round('2', '2026-06-20T12:00:00Z', '2'), + round('3', '2026-08-05T12:00:00Z', 'unlimited'), + ]); + + expect(getAllByRole('grid')).toHaveLength(3); + expect(getByText('July 2026')).toBeTruthy(); + }); + + it('heads each column with its own weekday, starting with Sunday', async () => { + const { getByRole } = await renderPreview(typicalRounds()); + + const headerCells = Array.from(getByRole('grid').querySelectorAll('thead tr:nth-child(2) td')); + const labels = headerCells.map((cell) => cell.textContent); + expect(labels).toHaveLength(7); + expect(labels).toEqual(['S', 'M', 'T', 'W', 'T', 'F', 'S']); + }); + + it('puts the first of the month under the right weekday', async () => { + const { getByRole } = await renderPreview(typicalRounds()); + + // June 1, 2026 is a Monday, so Sunday's cell in the first week is empty and Monday's is the 1st + const firstWeek = getByRole('grid').querySelector('tbody tr') as HTMLElement; + const cells = Array.from(firstWeek.querySelectorAll('td')); + expect(cells[0].textContent).toBe(''); + expect(cells[1].textContent).toMatch(/^1/); + }); + + it('colours each day by what applies on it', async () => { + const { getByRole } = await renderPreview(typicalRounds()); + + const dayCell = (day: number) => + Array.from(getByRole('grid').querySelectorAll('tbody td')).find((cell) => + cell.textContent?.startsWith(`${day}`), + )!; + expect(dayCell(5).className).toContain('maximum-event-signups-not-yet'); + expect(dayCell(12).className).toContain('maximum-event-signups-1'); + expect(dayCell(25).className).toContain('maximum-event-signups-unlimited'); + }); + + it('marks a day on which the value changes as a transition', async () => { + const { getByRole } = await renderPreview(typicalRounds()); + + const dayCell = (day: number) => + Array.from(getByRole('grid').querySelectorAll('tbody td')).find((cell) => + cell.textContent?.startsWith(`${day}`), + )!; + // (the value changes at noon on the 10th, so it's the 10th whose day ends with the new value in effect) + expect(dayCell(10).className).toContain('transition'); + expect(dayCell(9).className).not.toContain('transition'); + }); + + it('describes what applies on each day for assistive technology', async () => { + const { getAllByText } = await renderPreview(typicalRounds()); + + expect(getAllByText(/No signups yet/).length).toBeGreaterThan(0); + expect(getAllByText(/Up to 1 event/).length).toBeGreaterThan(0); + expect(getAllByText(/Signups fully open/).length).toBeGreaterThan(0); + }); + + it('shows a tooltip about a day when it is hovered over', async () => { + const { getByRole, findAllByText } = await renderPreview(typicalRounds()); + const day = Array.from(getByRole('grid').querySelectorAll('tbody td div.cursor-pointer')).find((cell) => + cell.textContent?.startsWith('9'), + )!; + + await user.hover(day); + + // (a day before a change says what happens at the change) + expect((await findAllByText(/Wednesday, June 10|Tuesday, June 9/)).length).toBeGreaterThan(0); + }); + + it('cycles the colours for larger limits', async () => { + const { getByRole } = await renderPreview([ + round('1', null, '7'), + round('2', '2026-06-10T12:00:00Z', '10'), + round('3', '2026-06-20T12:00:00Z', 'unlimited'), + ]); + + const dayCell = (day: number) => + Array.from(getByRole('grid').querySelectorAll('tbody td')).find((cell) => + cell.textContent?.startsWith(`${day}`), + )!; + // 7 → 4, 10 → 4 again + expect(dayCell(5).className).toContain('maximum-event-signups-4'); + expect(dayCell(12).className).toContain('maximum-event-signups-4'); + }); + + it('says it is too long, instead of a calendar, for a range of more than six months', async () => { + const { queryAllByRole, getByText } = await renderPreview([ + round('1', null, 'not_yet'), + round('2', '2026-01-10T12:00:00Z', '1'), + round('3', '2026-09-10T12:00:00Z', 'unlimited'), + ]); + + expect(queryAllByRole('grid')).toHaveLength(0); + expect(getByText(/too long/i)).toBeTruthy(); + }); + + it('shows nothing when there is no range of dates to show', async () => { + const none = await renderPreview([round('1', null, 'unlimited')]); + expect(none.queryAllByRole('grid')).toHaveLength(0); + none.unmount(); + + const single = await renderPreview([round('1', null, 'not_yet'), round('2', '2026-06-10T12:00:00Z', 'unlimited')]); + expect(single.queryAllByRole('grid')).toHaveLength(0); + }); +}); diff --git a/test/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage.test.tsx b/test/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage.test.tsx new file mode 100644 index 00000000000..0328cbe68b7 --- /dev/null +++ b/test/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage.test.tsx @@ -0,0 +1,240 @@ +import { MockLink } from '@apollo/client/testing'; +import { vi } from 'vitest'; + +import { renderRoute, waitFor } from '../testUtils'; +import { + Component as RankedChoiceSignupDecisionsPage, + describeDecision, + describeReason, + loader, +} from '../../../app/javascript/SignupRoundsAdmin/RankedChoiceSignupDecisionsPage'; +import { + SignupRoundRankedChoiceDecisionsTableQueryData, + SignupRoundRankedChoiceDecisionsTableQueryDocument, + SignupRoundsAdminQueryData, + SignupRoundsAdminQueryDocument, +} from '../../../app/javascript/SignupRoundsAdmin/queries.generated'; +import { RankedChoiceDecisionReason, RankedChoiceDecisionValue } from '../../../app/javascript/graphqlTypes.generated'; +import { getI18n } from 'react-i18next'; + +type Round = SignupRoundsAdminQueryData['convention']['signup_rounds'][number]; +type Entry = + SignupRoundRankedChoiceDecisionsTableQueryData['convention']['signup_round']['ranked_choice_decisions_paginated']['entries'][number]; + +const round = (id: string, start: string | null, overrides: Partial = {}): Round => ({ + __typename: 'SignupRound', + id, + start, + maximum_event_signups: 'unlimited', + automation_action: 'execute_ranked_choice' as Round['automation_action'], + ranked_choice_order: null, + rerandomize_lottery_numbers: false, + executed_at: null, + ...overrides, +}); + +const entry = ( + id: string, + attendee: string, + event: string, + decision: RankedChoiceDecisionValue, + reason: RankedChoiceDecisionReason | null = null, +): Entry => ({ + __typename: 'RankedChoiceDecision', + id, + created_at: '2026-06-05T12:00:00Z', + decision, + reason, + user_con_profile: { __typename: 'UserConProfile', id: `u${id}`, name_without_nickname: attendee }, + target_run: { + __typename: 'Run', + id: `r${id}`, + starts_at: '2026-06-06T18:00:00Z', + title_suffix: null, + event: { __typename: 'Event', id: `e${id}`, title: event }, + }, +}); + +describe('describing decisions and reasons', () => { + const t = getI18n().t.bind(getI18n()); + + it.each([ + [RankedChoiceDecisionValue.Signup, 'sign up'], + [RankedChoiceDecisionValue.Waitlist, 'waitlist'], + [RankedChoiceDecisionValue.SkipChoice, 'skip choice'], + [RankedChoiceDecisionValue.SkipUser, 'skip user'], + ])('describes the decision %s as "%s"', (decision, description) => { + expect(describeDecision(decision, t)).toBe(description); + }); + + it.each([ + [RankedChoiceDecisionReason.Conflict, 'conflict'], + [RankedChoiceDecisionReason.Full, 'event full'], + [RankedChoiceDecisionReason.MissingTicket, 'badge required'], + [RankedChoiceDecisionReason.NoMoreSignupsAllowed, 'no more signups allowed'], + [RankedChoiceDecisionReason.NoPendingChoices, 'no pending choices'], + [RankedChoiceDecisionReason.RankedChoiceUserConstraints, 'user constraints'], + [RankedChoiceDecisionReason.TeamMember, 'team member'], + [RankedChoiceDecisionReason.WaitlistPositionCapExceeded, 'waitlist position cap exceeded'], + ])('describes the reason %s as "%s"', (reason, description) => { + expect(describeReason(reason, 'badge', t)).toBe(description); + }); + + it('gives every reason a different description', () => { + const descriptions = Object.values(RankedChoiceDecisionReason).map((reason) => describeReason(reason, 'badge', t)); + + expect(new Set(descriptions).size).toBe(descriptions.length); + }); +}); + +describe('RankedChoiceSignupDecisionsPage', () => { + const queried = vi.fn(); + let entries: Entry[]; + + beforeEach(() => { + queried.mockReset(); + entries = [ + entry('1', 'Alice Attendee', 'Big Game', RankedChoiceDecisionValue.Signup), + entry('2', 'Bob Brown', 'Full Game', RankedChoiceDecisionValue.Waitlist, RankedChoiceDecisionReason.Full), + ]; + }); + + const rounds = () => [ + round('10', null, { maximum_event_signups: 'not_yet', automation_action: 'none' as Round['automation_action'] }), + round('11', '2026-06-05T10:00:00Z', { executed_at: '2026-06-05T10:00:05Z' }), + round('12', '2026-06-06T10:00:00Z'), + ]; + + const renderPage = (path = '/signup_rounds/11/results') => { + const apolloMocks: MockLink.MockedResponse[] = [ + { + request: { query: SignupRoundsAdminQueryDocument }, + result: { + data: { + __typename: 'Query', + convention: { __typename: 'Convention', id: '1', signup_rounds: rounds() }, + } satisfies SignupRoundsAdminQueryData, + }, + maxUsageCount: 5, + }, + { + request: { + query: SignupRoundRankedChoiceDecisionsTableQueryDocument, + variables: (variables: unknown) => { + queried(variables); + return true; + }, + }, + result: () => ({ + data: { + __typename: 'Query', + convention: { + __typename: 'Convention', + id: '1', + signup_round: { + __typename: 'SignupRound', + id: '11', + ranked_choice_decisions_paginated: { + __typename: 'RankedChoiceDecisionsPagination', + total_pages: 1, + entries, + }, + }, + }, + }, + }), + maxUsageCount: 20, + }, + ]; + + return renderRoute([{ path: '/signup_rounds/:id/results', loader, Component: RankedChoiceSignupDecisionsPage }], { + apolloMocks, + initialEntries: [path], + appRootContextValue: { timezoneName: 'UTC', ticketName: 'badge' }, + }); + }; + + const lastVariables = () => queried.mock.calls[queried.mock.calls.length - 1][0]; + + it('names the round, with when it runs', async () => { + const r = await renderPage(); + + expect(await r.findByRole('heading', { level: 1, name: /Round 1/ })).toBeTruthy(); + expect(r.getByRole('heading', { level: 1 }).textContent).toMatch(/2026/); + }); + + it('numbers rounds from the pre-signup period', async () => { + const r = await renderPage('/signup_rounds/12/results'); + + expect(await r.findByRole('heading', { level: 1, name: /Round 2/ })).toBeTruthy(); + }); + + it('says when the automation ran, if it did', async () => { + const executed = await renderPage(); + expect(await executed.findByText(/Automation ran/)).toBeTruthy(); + executed.unmount(); + + const notExecuted = await renderPage('/signup_rounds/12/results'); + await notExecuted.findByRole('heading', { level: 1, name: /Round 2/ }); + expect(notExecuted.queryByText(/Automation ran/)).toBeNull(); + }); + + it('is a 404 for a round that does not exist', async () => { + const r = await renderPage('/signup_rounds/999/results'); + + await waitFor(() => expect(r.queryByRole('heading', { level: 1 })).toBeNull()); + expect(queried).not.toHaveBeenCalled(); + }); + + describe('the decisions table', () => { + it('shows each decision with the attendee, the event, what was decided and why', async () => { + const r = await renderPage(); + + const aliceRow = (await r.findByText('Alice Attendee')).closest('tr') as HTMLElement; + const bobRow = r.getByText('Bob Brown').closest('tr') as HTMLElement; + const cellTexts = (row: HTMLElement) => Array.from(row.querySelectorAll('td')).map((cell) => cell.textContent); + expect(cellTexts(aliceRow).slice(0, 4)).toEqual(['Alice Attendee', 'Big Game', 'sign up', '']); + expect(cellTexts(bobRow).slice(0, 4)).toEqual(['Bob Brown', 'Full Game', 'waitlist', 'event full']); + }); + + it('describes a missing-ticket reason with the convention’s word for tickets', async () => { + entries = [ + entry( + '3', + 'Cara Clark', + 'Some Game', + RankedChoiceDecisionValue.SkipUser, + RankedChoiceDecisionReason.MissingTicket, + ), + ]; + const r = await renderPage(); + + expect(await r.findByText('badge required')).toBeTruthy(); + }); + + it('asks for the round’s decisions, newest last, showing sign ups and waitlistings by default', async () => { + const r = await renderPage(); + await r.findByText('Alice Attendee'); + + expect(lastVariables()).toMatchObject({ + signupRoundId: '11', + sort: [{ field: 'created_at', desc: false }], + filters: { decision: ['SIGNUP', 'WAITLIST'] }, + }); + }); + + it('takes filters from the URL, including reasons', async () => { + const r = await renderPage('/signup_rounds/11/results?filters.decision=SKIP_USER&filters.reason=CONFLICT,FULL'); + await r.findByText('Alice Attendee'); + + expect(lastVariables().filters).toMatchObject({ decision: ['SKIP_USER'], reason: ['CONFLICT', 'FULL'] }); + }); + + it('offers a CSV export', async () => { + const r = await renderPage(); + await r.findByText('Alice Attendee'); + + expect(r.getByRole('button', { name: /Export/ })).toBeTruthy(); + }); + }); +});