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 && (
+
+
+
+ )}
+ チーム: {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 && (
-
-
-
- )}
- チーム: {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",