diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts new file mode 100644 index 000000000..417078237 --- /dev/null +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -0,0 +1,194 @@ +import { describe, test, expect, vi } from 'vitest'; + +import { ContestType } from '$lib/types/contest'; +import type { TaskResult, TaskResults } from '$lib/types/task'; + +import { contestTableProviders } from '$lib/utils/contest_table_provider'; +import { taskResultsForContestTableProvider } from './test_cases/contest_table_provider'; + +// Mock the imported functions +vi.mock('$lib/utils/contest', () => ({ + classifyContest: vi.fn((contestId: string) => { + if (contestId.startsWith('abc')) { + return ContestType.ABC; + } + + return ContestType.OTHERS; + }), + + getContestNameLabel: vi.fn((contestId: string) => { + if (contestId.startsWith('abc')) { + return `ABC ${contestId.replace('abc', '')}`; + } + + return contestId; + }), +})); + +vi.mock('$lib/utils/task', () => ({ + getTaskTableHeaderName: vi.fn((_: ContestType, task: TaskResult) => { + return `${task.task_table_index}`; + }), +})); + +describe('ContestTableProviderBase and implementations', () => { + const mockTaskResults: TaskResults = taskResultsForContestTableProvider; + + const getContestRound = (contestId: string): number => { + const roundString = contestId.replace(/^\D+/, ''); + const round = parseInt(roundString, 10); + + if (isNaN(round)) { + throw new Error(`Invalid contest ID format: ${contestId}`); + } + + return round; + }; + + describe('ABC latest 20 rounds provider', () => { + test('expects to filter tasks to include only ABC contests', () => { + const provider = contestTableProviders.abcLatest20Rounds; + const filtered = provider.filter(mockTaskResults); + + expect(filtered.every((task) => task.contest_id.startsWith('abc'))).toBeTruthy(); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'arc100' })); + }); + + test('expects to limit results to the latest 20 rounds', () => { + const provider = contestTableProviders.abcLatest20Rounds; + + const largeDataset = [...mockTaskResults]; + const filtered = provider.filter(largeDataset); + const uniqueContests = new Set(filtered.map((task) => task.contest_id)); + expect(uniqueContests.size).toBe(20); + + // Verify these are the latest 20 rounds + const contestRounds = Array.from(uniqueContests) + .map((id) => getContestRound(id)) + .sort((a, b) => b - a); // Sort in descending order + + // Validate if the rounds are sequential and latest + const latestRound = Math.max(...contestRounds); + const expectedRounds = Array.from({ length: 20 }, (_, i) => latestRound - i); + expect(contestRounds).toEqual(expectedRounds); + }); + + test('expects to generate correct table structure', () => { + const provider = contestTableProviders.abcLatest20Rounds; + const filtered = provider.filter(mockTaskResults); + const table = provider.generateTable(filtered); + + expect(table).toHaveProperty('abc378'); + expect(table.abc378).toHaveProperty('G'); + expect(table.abc378.G).toEqual( + expect.objectContaining({ contest_id: 'abc378', task_id: 'abc378_g' }), + ); + + expect(table).toHaveProperty('abc397'); + expect(table.abc397).toHaveProperty('G'); + expect(table.abc397.G).toEqual( + expect.objectContaining({ contest_id: 'abc397', task_id: 'abc397_g' }), + ); + }); + + test('expects to get correct metadata', () => { + const provider = contestTableProviders.abcLatest20Rounds; + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('AtCoder Beginner Contest 最新 20 回'); + expect(metadata.buttonLabel).toBe('ABC 最新 20 回'); + expect(metadata.ariaLabel).toBe('Filter ABC latest 20 rounds'); + }); + + test('expects to format contest round label correctly', () => { + const provider = contestTableProviders.abcLatest20Rounds; + const label = provider.getContestRoundLabel('abc378'); + + expect(label).toBe('378'); + }); + }); + + describe('ABC319 onwards provider', () => { + test('expects to filter tasks to include only ABC319 and later', () => { + const provider = contestTableProviders.abc319Onwards; + const filtered = provider.filter(mockTaskResults); + + expect(filtered.every((task) => task.contest_id.startsWith('abc'))).toBeTruthy(); + expect( + filtered.every((task) => { + const round = getContestRound(task.contest_id); + return round >= 319 && round <= 999; + }), + ).toBeTruthy(); + }); + + test('expects to get correct metadata', () => { + const provider = contestTableProviders.abc319Onwards; + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('AtCoder Beginner Contest 319 〜 '); + expect(metadata.buttonLabel).toBe('ABC 319 〜 '); + expect(metadata.ariaLabel).toBe('Filter contests from ABC 319 onwards'); + }); + + test('expects to format contest round label correctly', () => { + const provider = contestTableProviders.abc319Onwards; + const label = provider.getContestRoundLabel('abc397'); + + expect(label).toBe('397'); + }); + }); + + describe('ABC212 to ABC318 provider', () => { + test('expects to filter tasks to include only ABC between 212 and 318', () => { + const provider = contestTableProviders.fromAbc212ToAbc318; + const filtered = provider.filter(mockTaskResults); + + expect(filtered.every((task) => task.contest_id.startsWith('abc'))).toBeTruthy(); + expect( + filtered.every((task) => { + const round = getContestRound(task.contest_id); + return round >= 212 && round <= 318; + }), + ).toBeTruthy(); + }); + + test('expects to get correct metadata', () => { + const provider = contestTableProviders.fromAbc212ToAbc318; + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('AtCoder Beginner Contest 212 〜 318'); + expect(metadata.buttonLabel).toBe('ABC 212 〜 318'); + expect(metadata.ariaLabel).toBe('Filter contests from ABC 212 to ABC 318'); + }); + + test('expects to format contest round label correctly', () => { + const provider = contestTableProviders.fromAbc212ToAbc318; + const label = provider.getContestRoundLabel('abc318'); + + expect(label).toBe('318'); + }); + }); + + describe('Common provider functionality', () => { + test('expects to get contest round IDs correctly', () => { + const provider = contestTableProviders.abcLatest20Rounds; + // Use a subset of the mock data that covers the relevant contest IDs + const filtered = mockTaskResults.filter((task) => + ['abc397', 'abc319', 'abc318'].includes(task.contest_id), + ); + + const roundIds = provider.getContestRoundIds(filtered); + + expect(roundIds).toEqual(['abc397', 'abc319', 'abc318']); + }); + + test('expects to get header IDs for tasks correctly', () => { + const provider = contestTableProviders.abcLatest20Rounds; + const filtered = mockTaskResults.filter((task) => task.contest_id === 'abc319'); + const headerIds = provider.getHeaderIdsForTask(filtered); + + expect(headerIds).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G']); + }); + }); +}); diff --git a/src/test/lib/utils/test_cases/contest_table_provider.ts b/src/test/lib/utils/test_cases/contest_table_provider.ts new file mode 100644 index 000000000..73991cd36 --- /dev/null +++ b/src/test/lib/utils/test_cases/contest_table_provider.ts @@ -0,0 +1,231 @@ +import type { TaskResult, TaskResults } from '$lib/types/task'; + +// Default task result with minimal initialization. +// Most fields are empty strings as they're not relevant for these tests. +// The updated_at field is set to Unix epoch as we only care about task_table_index +// and task_id for header name testing. + +/** + * Default initial values for a TaskResult object. + * + * This constant provides empty or falsy default values for all properties + * of a TaskResult. It's marked as readonly to prevent modifications. + * The `updated_at` date is set to Unix epoch (January 1, 1970) as a + * clearly identifiable default timestamp. + * + * @type {Readonly} An immutable default TaskResult object + */ +const defaultTaskResult: Readonly = { + is_ac: false, + user_id: '', + status_name: '', + status_id: '', + submission_status_image_path: '', + submission_status_label_name: '', + contest_id: '', + task_table_index: '', + task_id: '', + title: '', + grade: '', + updated_at: new Date(0), // Use the Unix epoch as the default value. +}; + +/** Represents a fully accepted submission status */ +const AC = 'ac'; +/** Represents a submission that was accepted with reference to the editorial */ +const AC_WITH_EDITORIAL = 'ac_with_editorial'; +/** Represents a challenge is underway */ +const TRYING = 'wa'; +/** Represents an unchallenged */ +const PENDING = 'ns'; + +/** + * Creates a new TaskResult using defaultTaskResult as a base, overriding the taskId and taskTableIndex. + * @param contestId - The ID of the contest (e.g., 'abc212') + * @param taskId - The ID of the task (e.g., 'abc212_a') + * @param taskTableIndex - The index of the task in the table (e.g., 'A', 'B', 'Ex') + * @param statusName - submission status of the task (e.g., 'ac', 'ac_with_editorial', 'wa', 'ns) + * @returns A new TaskResult object with the specified taskId and taskTableIndex + */ +function createTaskResultWithTaskTableIndex( + contestId: string, + taskId: string, + taskTableIndex: string, + statusName: string, +): TaskResult { + return { + ...defaultTaskResult, + contest_id: contestId, + task_id: taskId, + task_table_index: taskTableIndex, + status_name: statusName, + is_ac: statusName === AC || statusName === AC_WITH_EDITORIAL, + }; +} + +// Define a structure for contest tasks +/** + * Creates task results for a given contest based on provided task configurations. + * + * @param contestId - The unique identifier of the contest + * @param taskConfigs - Array of task configurations with task table indices and status names + * @param taskConfigs.taskTableIndex - The table index identifier for the task + * @param taskConfigs.statusName - The status name to assign to the task + * @returns An array of task results created from the given configurations + */ +const createContestTasks = ( + contestId: string, + taskConfigs: Array<{ taskTableIndex: string; statusName: string }>, +) => { + return taskConfigs.map((config) => { + const taskId = `${contestId}_${config.taskTableIndex.toLowerCase()}`; + + return createTaskResultWithTaskTableIndex( + contestId, + taskId, + config.taskTableIndex, + config.statusName, + ); + }); +}; + +// ABC212 - ABC232: 8 tasks (A, B, C, D, E, F, G and H) +// Mix of different submission statuses to test various filtering and display scenarios. +const [abc212_a, abc212_b, abc212_f, abc212_g, abc212_h] = createContestTasks('abc212', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC }, + { taskTableIndex: 'F', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'G', statusName: TRYING }, + { taskTableIndex: 'H', statusName: PENDING }, +]); +const [abc213_h] = createContestTasks('abc213', [{ taskTableIndex: 'H', statusName: PENDING }]); +const [abc232_h] = createContestTasks('abc232', [{ taskTableIndex: 'H', statusName: TRYING }]); + +// ABC233 - ABC318: 8 tasks (A, B, C, D, E, F, G and Ex) +const [abc233_a, abc233_b, abc233_ex] = createContestTasks('abc233', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: TRYING }, + { taskTableIndex: 'Ex', statusName: PENDING }, +]); +const [abc234_ex] = createContestTasks('abc234', [{ taskTableIndex: 'Ex', statusName: AC }]); +const [abc317_ex] = createContestTasks('abc317', [{ taskTableIndex: 'Ex', statusName: TRYING }]); +const [abc318_ex] = createContestTasks('abc318', [{ taskTableIndex: 'Ex', statusName: PENDING }]); + +// ABC319 - : 7 tasks (A, B, C, D, E, F and G) +const [abc319_a, abc319_b, abc319_c, abc319_d, abc319_e, abc319_f, abc319_g] = createContestTasks( + 'abc319', + [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC }, + { taskTableIndex: 'C', statusName: AC }, + { taskTableIndex: 'D', statusName: AC }, + { taskTableIndex: 'E', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'F', statusName: TRYING }, + { taskTableIndex: 'G', statusName: PENDING }, + ], +); + +/** + * Creates an array of contest task results with sequential contest numbers. + * + * @param startContestNumber - The first contest number in the sequence + * @param contestCount - The number of contests to generate + * @param taskIndex - The task index (e.g., 'A', 'B', 'C') to use for all contests + * @returns An array of task results with alternating statuses (AC, AC_WITH_EDITORIAL, TRYING, PENDING) + * in a repeating pattern. Each task has the format `abc{contestNumber}_{taskIndex.toLowerCase()}`. + * + * @example + * Creates 3 contest results starting from ABC123 with task index D + * const contests = createContestsRange(123, 3, 'D'); + */ +function createContestsRange(startContestNumber: number, contestCount: number, taskIndex: string) { + return Array.from({ length: contestCount }, (_, i) => { + const contestNumber = startContestNumber + i; + const contestId = `abc${contestNumber}`; + const taskId = `${contestId}_${taskIndex.toLowerCase()}`; + // Alternating statuses for variety + let statusName; + + if (i % 4 === 0) { + statusName = AC; + } else if (i % 4 === 1) { + statusName = AC_WITH_EDITORIAL; + } else if (i % 4 === 2) { + statusName = TRYING; + } else { + statusName = PENDING; + } + + return createTaskResultWithTaskTableIndex(contestId, taskId, 'G', statusName); + }); +} + +const [ + abc376_g, + abc377_g, + abc378_g, + abc379_g, + abc380_g, + abc381_g, + abc382_g, + abc383_g, + abc384_g, + abc385_g, + abc386_g, + abc387_g, + abc388_g, + abc389_g, + abc390_g, + abc391_g, + abc392_g, + abc393_g, + abc394_g, + abc395_g, + abc396_g, + abc397_g, +] = createContestsRange(376, 22, 'G'); + +export const taskResultsForContestTableProvider: TaskResults = [ + abc212_a, + abc212_b, + abc212_f, + abc212_g, + abc212_h, + abc213_h, + abc232_h, + abc233_a, + abc233_b, + abc233_ex, + abc234_ex, + abc317_ex, + abc318_ex, + abc319_a, + abc319_b, + abc319_c, + abc319_d, + abc319_e, + abc319_f, + abc319_g, + abc376_g, + abc377_g, + abc378_g, + abc379_g, + abc380_g, + abc381_g, + abc382_g, + abc383_g, + abc384_g, + abc385_g, + abc386_g, + abc387_g, + abc388_g, + abc389_g, + abc390_g, + abc391_g, + abc392_g, + abc393_g, + abc394_g, + abc395_g, + abc396_g, + abc397_g, +];