From 279548d7562d66b7d66793cc573f41cf940c489e Mon Sep 17 00:00:00 2001 From: blueberry1001 Date: Fri, 18 Sep 2026 17:43:36 +0900 Subject: [PATCH] Fix original contest metadata in recommendations for ADT problems --- .../Recommendations/OriginalContest.test.ts | 53 +++++++++ .../Recommendations/OriginalContest.ts | 25 ++++ .../UserPage/Recommendations/index.test.tsx | 107 ++++++++++++++++++ .../pages/UserPage/Recommendations/index.tsx | 13 ++- 4 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 atcoder-problems-frontend/src/pages/UserPage/Recommendations/OriginalContest.test.ts create mode 100644 atcoder-problems-frontend/src/pages/UserPage/Recommendations/OriginalContest.ts create mode 100644 atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.test.tsx diff --git a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/OriginalContest.test.ts b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/OriginalContest.test.ts new file mode 100644 index 000000000..5414a367e --- /dev/null +++ b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/OriginalContest.test.ts @@ -0,0 +1,53 @@ +import Problem from "../../../interfaces/Problem"; +import { restoreOriginalContest } from "./OriginalContest"; + +const adtProblem: Problem = { + id: "abc212_e", + contest_id: "adt_all_20231220_1", + problem_index: "H", + name: "Safety Journey", +}; +const contestToProblems = new Map([ + ["abc212", [{ ...adtProblem, problem_index: "E" }]], + ["adt_all_20231220_1", [adtProblem]], +]); + +describe("Restore the original contest for recommendations", () => { + it("restores the contest and index of an ABC problem reused in ADT", () => { + expect(restoreOriginalContest(adtProblem, contestToProblems)).toEqual({ + ...adtProblem, + contest_id: "abc212", + problem_index: "E", + }); + expect(adtProblem.contest_id).toBe("adt_all_20231220_1"); + expect(adtProblem.problem_index).toBe("H"); + }); + + it("uses the contest mapping for legacy numeric task IDs", () => { + const problem = { ...adtProblem, id: "abc001_1" }; + const contests = new Map([ + ["abc001", [{ ...problem, problem_index: "A" }]], + ]); + expect(restoreOriginalContest(problem, contests)).toMatchObject({ + contest_id: "abc001", + problem_index: "A", + }); + }); + + it("keeps non-ADT contest associations unchanged", () => { + const problem = { ...adtProblem, contest_id: "another_contest" }; + expect(restoreOriginalContest(problem, contestToProblems)).toBe(problem); + }); + + it("keeps the available metadata while contest mappings are loading", () => { + expect(restoreOriginalContest(adtProblem, undefined)).toBe(adtProblem); + }); + + it("does not infer an original contest without a matching problem", () => { + expect(restoreOriginalContest(adtProblem, new Map())).toBe(adtProblem); + const contests = new Map([ + ["abc212", [{ ...adtProblem, id: "abc212_a", problem_index: "A" }]], + ]); + expect(restoreOriginalContest(adtProblem, contests)).toBe(adtProblem); + }); +}); diff --git a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/OriginalContest.ts b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/OriginalContest.ts new file mode 100644 index 000000000..32793c789 --- /dev/null +++ b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/OriginalContest.ts @@ -0,0 +1,25 @@ +import Problem from "../../../interfaces/Problem"; +import { ContestId } from "../../../interfaces/Status"; + +export const restoreOriginalContest = ( + problem: Problem, + contestToProblems: Map | undefined +): Problem => { + if (!problem.contest_id.startsWith("adt_")) { + return problem; + } + + const contestId = problem.id.slice(0, problem.id.lastIndexOf("_")); + const originalProblem = contestToProblems + ?.get(contestId) + ?.find((candidate) => candidate.id === problem.id); + if (!originalProblem) { + return problem; + } + + return { + ...problem, + contest_id: contestId, + problem_index: originalProblem.problem_index, + }; +}; diff --git a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.test.tsx b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.test.tsx new file mode 100644 index 000000000..c2249b29e --- /dev/null +++ b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.test.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; +import * as APIClient from "../../../api/APIClient"; +import { Recommendations } from "."; + +jest.mock("../../../api/APIClient"); +jest.mock("../../../api/InternalAPIClient", () => ({ + useLoginState: () => ({ data: undefined }), +})); + +beforeEach(() => { + localStorage.clear(); + const problem = { + id: "abc212_e", + contest_id: "adt_all_20231220_1", + problem_index: "H", + name: "Safety Journey", + title: "H. Safety Journey", + 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, + }; + jest.spyOn(APIClient, "useMergedProblemMap").mockReturnValue({ + data: new Map([[problem.id, problem]]), + }); + jest + .spyOn(APIClient, "useContestToProblems") + .mockReturnValue( + new Map([["abc212", [{ ...problem, problem_index: "E" }]]]) + ); + jest.spyOn(APIClient, "useContestMap").mockReturnValue( + new Map([ + [ + "abc212", + { + id: "abc212", + title: "AtCoder Beginner Contest 212", + start_epoch_second: 1627128000, + duration_second: 6000, + rate_change: " ~ 1999", + }, + ], + ]) + ); + jest.spyOn(APIClient, "useProblemModelMap").mockReturnValue( + new Map([ + [ + problem.id, + { + difficulty: 1500, + rawDifficulty: 1500, + discrimination: 0.01, + is_experimental: false, + slope: undefined, + intercept: undefined, + variance: undefined, + }, + ], + ]) + ); + jest.spyOn(APIClient, "useRatingInfo").mockReturnValue({ + rating: 1500, + internalRating: 1500, + participationCount: 10, + }); + jest.spyOn(APIClient, "useUserSubmission").mockReturnValue([ + { + id: 1, + epoch_second: 1, + problem_id: "abc212_a", + contest_id: "abc212", + user_id: "test_user", + language: "C++", + point: 100, + length: 100, + result: "AC", + execution_time: 1, + }, + ]); +}); + +it.each(["All", "ABC"])( + "shows the original contest, problem index and links with the %s filter", + (category) => { + localStorage.setItem("recommendCategoryOption", JSON.stringify(category)); + const html = renderToStaticMarkup( + + + + ); + expect(html).toContain("E. Safety Journey"); + expect(html).toContain("https://atcoder.jp/contests/abc212/tasks/abc212_e"); + expect(html).toContain('href="https://atcoder.jp/contests/abc212"'); + expect(html).not.toContain("H. Safety Journey"); + expect(html).not.toContain("adt_all_20231220_1"); + } +); diff --git a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.tsx b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.tsx index 7c75c4b64..c8364cec1 100644 --- a/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.tsx +++ b/atcoder-problems-frontend/src/pages/UserPage/Recommendations/index.tsx @@ -4,6 +4,7 @@ import { useHistory } from "react-router-dom"; import { Button, ButtonGroup, Row } from "reactstrap"; import { useContestMap, + useContestToProblems, useMergedProblemMap, useProblemModelMap, useRatingInfo, @@ -37,6 +38,7 @@ import { import { classifyContest } from "../../../utils/ContestClassifier"; import { getLikeContestCategory } from "../../../utils/LikeContestUtils"; import { recommendProblems } from "./RecommendProblems"; +import { restoreOriginalContest } from "./OriginalContest"; import { CategoryOption, RecommendController, @@ -80,8 +82,11 @@ export const Recommendations = (props: Props) => { const userSubmissions = useUserSubmission(props.userId) ?? []; const { data: mergedProblemsMap } = useMergedProblemMap(); + const contestToProblems = useContestToProblems(); const problems = mergedProblemsMap - ? Array.from(mergedProblemsMap.values()) + ? Array.from(mergedProblemsMap.values()).map((problem) => + restoreOriginalContest(problem, contestToProblems) + ) : []; const contestMap = useContestMap(); const problemModels = useProblemModelMap(); @@ -183,20 +188,22 @@ export const Recommendations = (props: Props) => { selectRow={isLoggedIn ? selectRowProps : undefined} > (