diff --git a/__test__/pages/scoring/[code].test.tsx b/__test__/pages/scoring/[code].test.tsx new file mode 100644 index 0000000..4fb8c78 --- /dev/null +++ b/__test__/pages/scoring/[code].test.tsx @@ -0,0 +1,489 @@ +import "@testing-library/jest-dom"; + +import { render, screen } from "@testing-library/react"; +import mockRouter from "next-router-mock"; +import { useForm } from "react-hook-form"; +import { Mock, vi } from "vitest"; + +import useAnswers from "@/hooks/answer"; +import useAuth from "@/hooks/auth"; +import useProblem from "@/hooks/problem"; +import ScoringProblem from "@/pages/scoring/[code]"; +import { Answer, testAnswer } from "@/types/Answer"; +import { testProblem } from "@/types/Problem"; +import { testAdminUser, testUser } from "@/types/User"; + +vi.mock("next/router", () => require("next-router-mock")); +vi.mock("react-hook-form", () => ({ + useForm: vi.fn(), +})); +vi.mock("@/hooks/auth"); +vi.mock("@/hooks/problem"); +vi.mock("@/hooks/answer"); +vi.mock("@/components/Navbar", () => ({ + __esModule: true, + default: () =>
, +})); +vi.mock("@/components/LoadingPage", () => ({ + __esModule: true, + default: () =>
, +})); +vi.mock("@/components/ScoringAnswerForm", () => ({ + __esModule: true, + default: ({ answer }: { answer: Answer }) => ( +
+ ), +})); + +beforeEach(() => { + // toHaveBeenCalledTimes がテストごとにリセットされるようにする + vi.clearAllMocks(); + + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn(), + }); +}); + +describe("ScoringProblem", () => { + test("未ログインで、NotFound が表示される", async () => { + // setup + (useAuth as Mock).mockReturnValue({ + user: null, + }); + (useProblem as Mock).mockReturnValue({ + problem: null, + isLoading: false, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [], + }); + render(); + + // when + expect( + screen.queryByText("This page could not be found.") + ).toBeInTheDocument(); + + // then + expect(screen.queryByTestId("navbar")).not.toBeInTheDocument(); + expect(useAuth).toHaveBeenCalledTimes(1); + expect(useProblem).toHaveBeenCalledTimes(1); + expect(useAnswers).toHaveBeenCalledTimes(1); + }); + + test("ログイン済みで問題が取得できない場合、NotFound が表示される", async () => { + // setup + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: null, + isLoading: false, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [], + }); + render(); + + // when + expect( + screen.queryByText("This page could not be found.") + ).toBeInTheDocument(); + + // then + expect(screen.queryByTestId("navbar")).not.toBeInTheDocument(); + expect(useAuth).toHaveBeenCalledTimes(1); + expect(useProblem).toHaveBeenCalledTimes(1); + expect(useAnswers).toHaveBeenCalledTimes(1); + }); + + test("参加者でアクセスした場合、NotFound が表示される", async () => { + // setup + (useAuth as Mock).mockReturnValue({ + user: testUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + isLoading: false, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [], + }); + render(); + + // when + expect( + screen.queryByText("This page could not be found.") + ).toBeInTheDocument(); + + // then + expect(screen.queryByTestId("navbar")).not.toBeInTheDocument(); + expect(useAuth).toHaveBeenCalledTimes(1); + expect(useProblem).toHaveBeenCalledTimes(1); + expect(useAnswers).toHaveBeenCalledTimes(1); + }); + + test("isReadOnly 権限でアクセスした場合、NotFound が表示される", async () => { + // setup + (useAuth as Mock).mockReturnValue({ + user: { ...testAdminUser, is_read_only: true }, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + isLoading: false, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [], + }); + render(); + + // when + expect( + screen.queryByText("This page could not be found.") + ).toBeInTheDocument(); + + // then + expect(screen.queryByTestId("navbar")).not.toBeInTheDocument(); + expect(useAuth).toHaveBeenCalledTimes(1); + expect(useProblem).toHaveBeenCalledTimes(1); + expect(useAnswers).toHaveBeenCalledTimes(1); + }); + + test("問題が読み込み中の時ローディング画面が表示される", async () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue(0), + }); + (useAuth as Mock).mockReturnValue({ + user: { ...testAdminUser, is_read_only: true }, + }); + (useProblem as Mock).mockReturnValue({ + problem: null, + isLoading: true, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [], + }); + render(); + + // when + expect(screen.queryByTestId("loading")).toBeInTheDocument(); + + // then + expect(screen.queryByTestId("navbar")).toBeInTheDocument(); + expect(useAuth).toHaveBeenCalledTimes(1); + expect(useProblem).toHaveBeenCalledTimes(1); + expect(useAnswers).toHaveBeenCalledTimes(1); + }); + + test("未採点一覧表が表示される", () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("0"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [testAnswer], + }); + + // when + render(); + + // then + const cells = screen.queryAllByRole("cell"); + expect(cells[0]).toHaveTextContent(""); + expect(cells[1]).toHaveTextContent("-"); + expect(cells[2]).toHaveTextContent("-"); + }); + + test("15分未満の問題がある場合、未採点の ~15分 に表示される", () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("0"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: { ...testProblem, unchecked: 1 }, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [testAnswer], + }); + + // when + render(); + + // then + const cells = screen.queryAllByRole("cell"); + expect(cells[0]).toHaveTextContent("1"); + expect(cells[1]).toHaveTextContent("-"); + expect(cells[2]).toHaveTextContent("-"); + }); + + test("15分以上かつ19分以下の問題がある場合、未採点の 15~19分 に表示される", () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("0"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: { ...testProblem, unchecked_near_overdue: 1 }, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [testAnswer], + }); + + // when + render(); + + // then + const cells = screen.queryAllByRole("cell"); + expect(cells[0]).toHaveTextContent(""); + expect(cells[1]).toHaveTextContent("1"); + expect(cells[2]).toHaveTextContent("-"); + }); + + test("20分以上の問題がある場合、未採点の 20分~ に表示される", () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("0"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: { ...testProblem, unchecked_overdue: 1 }, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [testAnswer], + }); + + // when + render(); + + // then + const cells = screen.queryAllByRole("cell"); + expect(cells[0]).toHaveTextContent(""); + expect(cells[1]).toHaveTextContent("-"); + expect(cells[2]).toHaveTextContent("1"); + }); + + test("「すべて」を選択している場合かつ未採点の場合採点フォームが表示される", async () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("0"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [testAnswer], + }); + + // when + render(); + + // then + expect(screen.queryByTestId("scoring-answer-form")).toBeInTheDocument(); + }); + + test("「すべて」を選択している場合かつ採点済みの場合採点フォームが表示される", async () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("0"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [{ ...testAnswer, point: 100 }], + }); + + // when + render(); + + // then + expect(screen.queryByTestId("scoring-answer-form")).toBeInTheDocument(); + }); + + test("「採点済みのみ」を選択している場合かつ未採点の場合採点フォームが表示される", async () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("1"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [testAnswer], + }); + + // when + render(); + + // then + expect(screen.queryByTestId("scoring-answer-form")).not.toBeInTheDocument(); + }); + + test("「採点済みのみ」を選択している場合かつ採点済みの場合採点フォームが表示される", async () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("1"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [{ ...testAnswer, point: 100 }], + }); + + // when + render(); + + // then + expect(screen.queryByTestId("scoring-answer-form")).toBeInTheDocument(); + }); + + test("「未採点のみ」を選択している場合かつ未採点の場合採点フォームが表示される", async () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("2"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [testAnswer], + }); + + // when + render(); + + // then + expect(screen.queryByTestId("scoring-answer-form")).toBeInTheDocument(); + }); + + test("「未採点のみ」を選択している場合かつ採点済みの場合採点フォームが表示される", async () => { + // setup + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue("2"), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [{ ...testAnswer, point: 100 }], + }); + + // when + render(); + + // then + expect(screen.queryByTestId("scoring-answer-form")).not.toBeInTheDocument(); + }); + + test("answerId を指定した場合指定の回答が表示される", async () => { + // setup + mockRouter.query = { code: "abc", answer_id: "1" }; + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue(null), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [ + { ...testAnswer, id: "1" }, + { ...testAnswer, id: "2" }, + { ...testAnswer, id: "3" }, + { ...testAnswer, id: "4" }, + ], + }); + + // when + render(); + + screen.debug(); + + // then + expect(screen.queryByTestId("scoring-answer-form")).toHaveAttribute( + "data-key", + "1" + ); + }); + + test("解答フォームが正しい順番で表示される", async () => { + // setup + mockRouter.query = { answer_id: undefined }; + (useForm as Mock).mockReturnValue({ + register: vi.fn(), + watch: vi.fn().mockReturnValue(null), + }); + (useAuth as Mock).mockReturnValue({ + user: testAdminUser, + }); + (useProblem as Mock).mockReturnValue({ + problem: testProblem, + }); + (useAnswers as Mock).mockReturnValue({ + answers: [ + { ...testAnswer, id: "3", created_at: "2021-01-03" }, + { ...testAnswer, id: "1", created_at: "2021-01-01" }, + { ...testAnswer, id: "4", created_at: "2021-01-01" }, + { ...testAnswer, id: "2", created_at: "2021-01-02" }, + ], + }); + + // when + render(); + + // then + const forms = screen.queryAllByTestId("scoring-answer-form"); + expect(forms[0]).toHaveAttribute("data-key", "3"); + expect(forms[1]).toHaveAttribute("data-key", "2"); + expect(forms[2]).toHaveAttribute("data-key", "1"); + expect(forms[3]).toHaveAttribute("data-key", "4"); + }); +}); diff --git a/__test__/pages/scoring/index.test.tsx b/__test__/pages/scoring/index.test.tsx index 1f1670d..0c1ae88 100644 --- a/__test__/pages/scoring/index.test.tsx +++ b/__test__/pages/scoring/index.test.tsx @@ -20,6 +20,7 @@ vi.mock("@/components/LoadingPage", () => ({ __esModule: true, default: () =>
, })); + beforeEach(() => { // toHaveBeenCalledTimes がテストごとにリセットされるようにする vi.clearAllMocks(); @@ -292,7 +293,7 @@ describe("Scoring", () => { user: testAdminUser, }); (useProblems as Mock).mockReturnValue({ - problems: [{ ...testProblem, author_id: "other" }], + problems: [{ ...testProblem }], isLoading: false, }); render(); diff --git a/components/ScoringAnswerForm.tsx b/components/ScoringAnswerForm.tsx new file mode 100644 index 0000000..21cef48 --- /dev/null +++ b/components/ScoringAnswerForm.tsx @@ -0,0 +1,119 @@ +import Image from "next/image"; + +import { useForm } from "react-hook-form"; + +import ICTSCCard from "@/components/Card"; +import MarkdownPreview from "@/components/MarkdownPreview"; +import useAnswers from "@/hooks/answer"; +import useApi from "@/hooks/api"; +import useProblems from "@/hooks/problems"; +import { Answer } from "@/types/Answer"; +import { Problem } from "@/types/Problem"; + +type AnswerFormProps = { + problem: Problem; + answer: Answer; +}; + +type AnswerFormInputs = { + point: number; +}; + +function ScoringAnswerForm({ problem, answer }: AnswerFormProps) { + const { client } = useApi(); + const { mutate } = useAnswers(problem.id); + const { mutate: mutateProblem } = useProblems(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + point: answer.point ?? undefined, + }, + }); + + const onSubmit = async (data: AnswerFormInputs) => { + await client.patch(`problems/${problem.id}/answers/${answer.id}`, { + problem_id: problem.id, + answer_id: answer.id, + // parseInt するとダブルクォートが取り除かれる + point: parseInt(data.point.toString(), 10), + }); + + await mutate(); + await mutateProblem(); + }; + + // yyyy/mm/dd hh:mm:ss + const createdAt = new Date(Date.parse(answer.created_at)).toLocaleDateString( + "ja-JP", + { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + } + ); + + return ( + +
+
+ {answer.point !== null && ( +
+ checked +
+ )} + チーム: {answer.user_group.name}({answer.user_group.organization}) +
+
{createdAt}
+
+ +
+
+ + +
+
+ {errors.point?.type === "required" && ( + + 点数を入力して下さい + + )} + {errors.point?.type === "min" && ( + + 点数が低すぎます0以上の値を指定して下さい + + )} + {errors.point?.type === "max" && ( + + 点数が高すぎます{problem.point}以下の値を指定して下さい + + )} +
+ + ); +} + +export default ScoringAnswerForm; diff --git a/pages/scoring/[code].tsx b/pages/scoring/[code].tsx index aa68b1d..e5337ab 100644 --- a/pages/scoring/[code].tsx +++ b/pages/scoring/[code].tsx @@ -1,5 +1,4 @@ import Error from "next/error"; -import Image from "next/image"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; @@ -10,119 +9,11 @@ import MarkdownPreview from "@/components/MarkdownPreview"; import ProblemConnectionInfo from "@/components/ProblemConnectionInfo"; import ProblemMeta from "@/components/ProblemMeta"; import ProblemTitle from "@/components/ProblemTitle"; +import ScoringAnswerForm from "@/components/ScoringAnswerForm"; import useAnswers from "@/hooks/answer"; -import useApi from "@/hooks/api"; import useAuth from "@/hooks/auth"; -import { useProblem, useProblems } from "@/hooks/problem"; +import useProblem from "@/hooks/problem"; import BaseLayout from "@/layouts/BaseLayout"; -import { Answer } from "@/types/Answer"; -import { Problem } from "@/types/Problem"; - -type AnswerFormProps = { - problem: Problem; - answer: Answer; -}; - -type AnswerFormInputs = { - point: number; -}; - -function AnswerForm({ problem, answer }: AnswerFormProps) { - const { client } = useApi(); - const { mutate } = useAnswers(problem.id); - const { mutate: mutateProblem } = useProblems(); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - defaultValues: { - point: answer.point ?? undefined, - }, - }); - - const onSubmit = async (data: AnswerFormInputs) => { - await client.patch(`problems/${problem.id}/answers/${answer.id}`, { - problem_id: problem.id, - answer_id: answer.id, - // parseInt するとダブルクォートが取り除かれる - point: parseInt(data.point.toString(), 10), - }); - - await mutate(); - await mutateProblem(); - }; - - // yyyy/mm/dd hh:mm:ss - const createdAt = new Date(Date.parse(answer.created_at)).toLocaleDateString( - "ja-JP", - { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - } - ); - - return ( - -
-
- {answer.point !== null && ( -
- checked -
- )} - チーム: {answer.user_group.name}({answer.user_group.organization}) -
-
{createdAt}
-
- -
-
- - -
-
- {errors.point?.type === "required" && ( - - 点数を入力して下さい - - )} - {errors.point?.type === "min" && ( - - 点数が低すぎます0以上の値を指定して下さい - - )} - {errors.point?.type === "max" && ( - - 点数が高すぎます{problem.point}以下の値を指定して下さい - - )} -
- - ); -} type Input = { answerFilter: string; @@ -248,7 +139,11 @@ function ScoringProblem() { return answer.id === answerId; }) .map((answer) => ( - + ))}
diff --git a/types/Answer.tsx b/types/Answer.tsx index 6754ab0..f498cac 100644 --- a/types/Answer.tsx +++ b/types/Answer.tsx @@ -12,8 +12,8 @@ export type Answer = { export const testAnswer: Answer = { id: "1", - body: "test", - point: 100, + body: "テスト解答本文", + point: null, problem_id: "1", user_group: testUserGroup, created_at: "2021-01-01",