diff --git a/atcoder-problems-frontend/src/pages/TablePage/AtCoderRegularTable.tsx b/atcoder-problems-frontend/src/pages/TablePage/AtCoderRegularTable.tsx index 7b7e0753f..122322757 100644 --- a/atcoder-problems-frontend/src/pages/TablePage/AtCoderRegularTable.tsx +++ b/atcoder-problems-frontend/src/pages/TablePage/AtCoderRegularTable.tsx @@ -23,6 +23,10 @@ import { SubmitTimespan } from "../../components/SubmitTimespan"; import { RatingInfo } from "../../utils/RatingInfo"; import { ProblemPoint } from "../../components/Problempoint"; import { classifyContest } from "../../utils/ContestClassifier"; +import { + ListPaginationPanel, + ListPaginationPanelProps, +} from "../../components/ListPaginationPanel"; interface Props { contests: Contest[]; @@ -130,6 +134,22 @@ const AtCoderRegularTableSFC: React.FC = (props) => {

{props.title}

; + }, + }} tableContainerClass="contest-table-responsive contest-regular-table-responsive" > ({ + id: `adt_all_${index}`, + title: `Daily Training ${index}`, + start_epoch_second: index, + duration_second: 3600, + rate_change: "-", +})); +mockContests.push( + ...mockRegularCategories.flatMap(({ category, prefix, digits, rateChange }) => + Array.from({ length: 51 }, (_, index) => ({ + id: prefix + index.toString().padStart(digits, "0"), + title: `${category} ${index}`, + start_epoch_second: 1600000000 + index, + duration_second: 3600, + rate_change: rateChange, + })) + ) +); +const mockContestToProblems = new Map( + mockContests.map((contest, index) => [ + contest.id, + [ + { + id: `problem_${index}`, + contest_id: contest.id, + problem_index: "A", + name: `Problem ${index}`, + first_user_id: null, + first_contest_id: null, + first_submission_id: null, + fastest_user_id: null, + fastest_contest_id: null, + fastest_submission_id: null, + execution_time: null, + shortest_user_id: null, + shortest_contest_id: null, + shortest_submission_id: null, + source_code_length: null, + solver_count: null, + }, + ], + ]) +); +const mockSubmissions: Submission[] = []; +const mockRatingInfo = new RatingInfo(0, 0); + +jest.mock("../../api/APIClient", () => ({ + useContests: () => ({ data: mockContests }), + useContestToMergedProblems: () => mockContestToProblems, + useRatingInfo: () => mockRatingInfo, + useUserSubmission: () => mockSubmissions, + useMultipleUserSubmissions: () => ({ data: mockSubmissions }), + useProblemModelMap: () => new Map(), +})); +jest.mock("../../api/InternalAPIClient", () => ({ + useLoginState: () => ({ data: undefined }), + useProgressResetList: () => ({ data: undefined }), +})); + +describe("Regular contest table pagination", () => { + let container: HTMLDivElement; + + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("contestTableTab", JSON.stringify("ADT")); + mockSubmissions.length = 0; + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + void act(() => { + ReactDOM.unmountComponentAtNode(container); + }); + container.remove(); + }); + + const renderPage = () => { + void act(() => { + ReactDOM.render(, container); + }); + }; + const rows = () => + container.querySelectorAll(".react-bs-container-body tbody tr"); + const click = (selector: string) => { + const target = container.querySelector(selector); + if (!target) { + throw new Error(`Missing element: ${selector}`); + } + void act(() => { + Simulate.click(target); + }); + }; + const switchTab = (title: string) => { + const target = Array.from( + container.querySelectorAll(".table-tab button") + ).find((button) => button.textContent === title); + if (!target) { + throw new Error(`Missing tab: ${title}`); + } + void act(() => { + Simulate.click(target); + }); + }; + + it("limits mounted rows and lets users reach the oldest contests", () => { + renderPage(); + expect(rows()).toHaveLength(20); + expect(rows()[0].textContent).toContain("ADT_ALL_160"); + click('.pagination li[title="2"] .page-link'); + expect(rows()).toHaveLength(20); + expect(rows()[0].textContent).toContain("ADT_ALL_140"); + click('.pagination li[title="9"] .page-link'); + expect(rows()).toHaveLength(1); + expect(rows()[0].textContent).toContain("ADT_ALL_0"); + }); + + it("returns to the newest contests after switching tabs", () => { + renderPage(); + click('.pagination li[title="9"] .page-link'); + switchTab("ABC"); + expect(rows()).toHaveLength(20); + expect(rows()[0].textContent).toContain("ABC050"); + click('.pagination li[title="2"] .page-link'); + switchTab("ADT"); + expect(rows()).toHaveLength(20); + expect(rows()[0].textContent).toContain("ADT_ALL_160"); + switchTab("ABC"); + expect(rows()[0].textContent).toContain("ABC050"); + }); + + it("filters all contests before paginating and handles a disappearing last page", () => { + mockSubmissions.push({ + id: 1, + epoch_second: 1, + problem_id: "problem_160", + contest_id: "adt_all_160", + user_id: "user", + language: "C++", + result: "AC", + point: 100, + length: 100, + execution_time: 1, + }); + renderPage(); + expect(rows()[0].querySelector(".table-success")).not.toBeNull(); + click('.pagination li[title="9"] .page-link'); + const filter = container.querySelector("#hideCompletedContest"); + if (!filter) { + throw new Error("Missing completed contest filter"); + } + void act(() => { + Simulate.change(filter); + }); + expect(rows()).toHaveLength(20); + expect(rows()[0].textContent).toContain("ADT_ALL_159"); + expect(container.querySelector('.pagination li[title="9"]')).toBeNull(); + click('.pagination li[title="8"] .page-link'); + expect(rows()).toHaveLength(20); + expect(rows()[19].textContent).toContain("ADT_ALL_0"); + }); + + it.each(["ADT", ...mockRegularCategories.map(({ category }) => category)])( + "paginates %s contests with the shared List page controls", + (category) => { + localStorage.setItem("contestTableTab", JSON.stringify(category)); + renderPage(); + expect(rows()).toHaveLength(20); + const firstContest = rows()[0].textContent; + click('.pagination li[title="2"] .page-link'); + expect(rows()).toHaveLength(20); + expect(rows()[0].textContent).not.toBe(firstContest); + click('.pagination li[title="1"] .page-link'); + click(".react-bs-table-sizePerPage-dropdown .dropdown-toggle"); + const sizes = Array.from( + container.querySelectorAll( + ".react-bs-table-sizePerPage-dropdown .dropdown-item" + ) + ); + expect(sizes.map((item) => item.textContent)).toEqual([ + "20", + "50", + "100", + "200", + ]); + void act(() => { + Simulate.click(sizes[1]); + }); + expect(rows()).toHaveLength(50); + } + ); +}); diff --git a/atcoder-problems-frontend/src/pages/TablePage/index.tsx b/atcoder-problems-frontend/src/pages/TablePage/index.tsx index 297e37449..9bae282a7 100644 --- a/atcoder-problems-frontend/src/pages/TablePage/index.tsx +++ b/atcoder-problems-frontend/src/pages/TablePage/index.tsx @@ -141,6 +141,7 @@ export const TablePage: React.FC = (props) => { "PAST", ].includes(activeTab) ? (