diff options
Diffstat (limited to 'frontend/app/components')
| -rw-r--r-- | frontend/app/components/Gaming/DataTable.test.tsx | 70 | ||||
| -rw-r--r-- | frontend/app/components/Gaming/DataTable.tsx | 45 | ||||
| -rw-r--r-- | frontend/app/components/Gaming/RankingTable.tsx | 81 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApp.tsx | 25 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx | 107 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx | 62 |
6 files changed, 303 insertions, 87 deletions
diff --git a/frontend/app/components/Gaming/DataTable.test.tsx b/frontend/app/components/Gaming/DataTable.test.tsx new file mode 100644 index 0000000..2a4446c --- /dev/null +++ b/frontend/app/components/Gaming/DataTable.test.tsx @@ -0,0 +1,70 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import DataTable, { DataTableCell, formatUnixTimestamp } from "./DataTable"; + +afterEach(() => { + cleanup(); +}); + +describe("DataTable", () => { + test("renders headers", () => { + render( + <DataTable headers={["A", "B", "C"]}> + <tr> + <DataTableCell>1</DataTableCell> + <DataTableCell>2</DataTableCell> + <DataTableCell>3</DataTableCell> + </tr> + </DataTable>, + ); + expect(screen.getByText("A")).toBeDefined(); + expect(screen.getByText("B")).toBeDefined(); + expect(screen.getByText("C")).toBeDefined(); + }); + + test("renders body cells", () => { + render( + <DataTable headers={["H"]}> + <tr> + <DataTableCell>cell content</DataTableCell> + </tr> + </DataTable>, + ); + expect(screen.getByText("cell content")).toBeDefined(); + }); + + test("renders multiple rows", () => { + render( + <DataTable headers={["Name"]}> + <tr> + <DataTableCell>Alice</DataTableCell> + </tr> + <tr> + <DataTableCell>Bob</DataTableCell> + </tr> + </DataTable>, + ); + expect(screen.getByText("Alice")).toBeDefined(); + expect(screen.getByText("Bob")).toBeDefined(); + }); +}); + +describe("formatUnixTimestamp", () => { + test("formats timestamp correctly", () => { + // 2026-03-01 12:30 JST (UTC+9) = 2026-03-01 03:30 UTC + const timestamp = Date.UTC(2026, 2, 1, 3, 30, 0) / 1000; + const result = formatUnixTimestamp(timestamp); + // Result depends on local timezone; just check the format pattern + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); + + test("pads single-digit months and days", () => { + // Use a date where month and day are single digits + const timestamp = Date.UTC(2026, 0, 5, 0, 0, 0) / 1000; + const result = formatUnixTimestamp(timestamp); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); +}); diff --git a/frontend/app/components/Gaming/DataTable.tsx b/frontend/app/components/Gaming/DataTable.tsx new file mode 100644 index 0000000..098f4a2 --- /dev/null +++ b/frontend/app/components/Gaming/DataTable.tsx @@ -0,0 +1,45 @@ +import type React from "react"; + +type Props = { + headers: React.ReactNode[]; + children: React.ReactNode; +}; + +export default function DataTable({ headers, children }: Props) { + return ( + <div className="overflow-x-auto border-2 border-brand-600 rounded-xl"> + <table className="min-w-full divide-y divide-gray-400 border-collapse"> + <thead className="bg-gray-50"> + <tr> + {headers.map((header, i) => ( + <th + key={i} + scope="col" + className="px-6 py-3 text-left font-medium text-gray-800" + > + {header} + </th> + ))} + </tr> + </thead> + <tbody className="bg-white divide-y divide-gray-300">{children}</tbody> + </table> + </div> + ); +} + +export function DataTableCell({ children }: { children: React.ReactNode }) { + return ( + <td className="px-6 py-4 whitespace-nowrap text-gray-900">{children}</td> + ); +} + +export function formatUnixTimestamp(timestamp: number): string { + const date = new Date(timestamp * 1000); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} diff --git a/frontend/app/components/Gaming/RankingTable.tsx b/frontend/app/components/Gaming/RankingTable.tsx index 4bfdad3..4ba3987 100644 --- a/frontend/app/components/Gaming/RankingTable.tsx +++ b/frontend/app/components/Gaming/RankingTable.tsx @@ -1,34 +1,8 @@ import { useAtomValue } from "jotai"; -import React from "react"; import { rankingAtom } from "../../states/watch"; import type { SupportedLanguage } from "../../types/SupportedLanguage"; import CodePopover from "./CodePopover"; - -function TableHeaderCell({ children }: { children: React.ReactNode }) { - return ( - <th scope="col" className="px-6 py-3 text-left font-medium text-gray-800"> - {children} - </th> - ); -} - -function TableBodyCell({ children }: { children: React.ReactNode }) { - return ( - <td className="px-6 py-4 whitespace-nowrap text-gray-900">{children}</td> - ); -} - -function formatUnixTimestamp(timestamp: number) { - const date = new Date(timestamp * 1000); - - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, "0"); - const day = date.getDate().toString().padStart(2, "0"); - const hours = date.getHours().toString().padStart(2, "0"); - const minutes = date.getMinutes().toString().padStart(2, "0"); - - return `${year}-${month}-${day} ${hours}:${minutes}`; -} +import DataTable, { DataTableCell, formatUnixTimestamp } from "./DataTable"; type Props = { problemLanguage: SupportedLanguage; @@ -38,38 +12,25 @@ export default function RankingTable({ problemLanguage }: Props) { const ranking = useAtomValue(rankingAtom); return ( - <div className="overflow-x-auto border-2 border-brand-600 rounded-xl"> - <table className="min-w-full divide-y divide-gray-400 border-collapse"> - <thead className="bg-gray-50"> - <tr> - <TableHeaderCell>順位</TableHeaderCell> - <TableHeaderCell>プレイヤー</TableHeaderCell> - <TableHeaderCell>スコア</TableHeaderCell> - <TableHeaderCell>提出時刻</TableHeaderCell> - <TableHeaderCell>コード</TableHeaderCell> - </tr> - </thead> - <tbody className="bg-white divide-y divide-gray-300"> - {ranking.map((entry, index) => ( - <tr key={entry.player.user_id}> - <TableBodyCell>{index + 1}</TableBodyCell> - <TableBodyCell> - {entry.player.display_name} - {entry.player.label && ` (${entry.player.label})`} - </TableBodyCell> - <TableBodyCell>{entry.score}</TableBodyCell> - <TableBodyCell> - {formatUnixTimestamp(entry.submitted_at)} - </TableBodyCell> - <TableBodyCell> - {entry.code && ( - <CodePopover code={entry.code} language={problemLanguage} /> - )} - </TableBodyCell> - </tr> - ))} - </tbody> - </table> - </div> + <DataTable headers={["順位", "プレイヤー", "スコア", "提出時刻", "コード"]}> + {ranking.map((entry, index) => ( + <tr key={entry.player.user_id}> + <DataTableCell>{index + 1}</DataTableCell> + <DataTableCell> + {entry.player.display_name} + {entry.player.label && ` (${entry.player.label})`} + </DataTableCell> + <DataTableCell>{entry.score}</DataTableCell> + <DataTableCell> + {formatUnixTimestamp(entry.submitted_at)} + </DataTableCell> + <DataTableCell> + {entry.code && ( + <CodePopover code={entry.code} language={problemLanguage} /> + )} + </DataTableCell> + </tr> + ))} + </DataTable> ); } diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx index 1c1e7ae..5d00239 100644 --- a/frontend/app/components/GolfPlayApp.tsx +++ b/frontend/app/components/GolfPlayApp.tsx @@ -1,6 +1,6 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useHydrateAtoms } from "jotai/utils"; -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useTimer } from "react-use-precision-timer"; import { useDebouncedCallback } from "use-debounce"; import { ApiClientContext } from "../api/client"; @@ -21,6 +21,7 @@ import GolfPlayAppWaiting from "./GolfPlayApps/GolfPlayAppWaiting"; type Game = components["schemas"]["Game"]; type User = components["schemas"]["User"]; +type Submission = components["schemas"]["Submission"]; type LatestGameState = components["schemas"]["LatestGameState"]; type Props = { @@ -75,8 +76,26 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) { { leading: true }, ); + const [submissions, setSubmissions] = useState<Submission[]>([]); const [isDataPolling, setIsDataPolling] = useState(false); + const fetchSubmissions = useCallback(async () => { + try { + const { submissions } = await apiClient.getGamePlaySubmissions( + game.game_id, + ); + setSubmissions(submissions); + } catch (error) { + console.error(error); + } + }, [apiClient, game.game_id]); + + useEffect(() => { + if (gameStateKind === "finished") { + fetchSubmissions(); + } + }, [gameStateKind, fetchSubmissions]); + useEffect(() => { if (isDataPolling) { return; @@ -98,6 +117,7 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) { game.game_id, ); setLatestGameState(state); + await fetchSubmissions(); } } catch (error) { console.error(error); @@ -116,6 +136,7 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) { gameStateKind, setGameStartedAt, setLatestGameState, + fetchSubmissions, ]); if (gameStateKind === "loading") { @@ -134,6 +155,7 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) { onCodeChange={onCodeChange} onCodeSubmit={onCodeSubmit} isFinished={false} + submissions={submissions} /> ); } @@ -158,6 +180,7 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) { onCodeChange={onCodeChange} onCodeSubmit={onCodeSubmit} isFinished={gameStateKind === "finished"} + submissions={submissions} /> ); } diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx new file mode 100644 index 0000000..2d51d66 --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx @@ -0,0 +1,107 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, describe, expect, test } from "vitest"; +import { + setCurrentTimestampAtom, + setDurationSecondsAtom, + setGameStartedAtAtom, + setLatestGameStateAtom, +} from "../../states/play"; +import GolfPlayAppGaming from "./GolfPlayAppGaming"; + +afterEach(() => { + cleanup(); +}); + +function createTestStore() { + const store = createStore(); + const now = Math.floor(Date.now() / 1000); + store.set(setCurrentTimestampAtom); + store.set(setDurationSecondsAtom, 600); + store.set(setGameStartedAtAtom, now - 60); + store.set(setLatestGameStateAtom, { + status: "none", + code: "", + score: null, + best_score_submitted_at: null, + }); + return store; +} + +const defaultProps = { + gameDisplayName: "Test Game", + playerProfile: { + id: 1, + displayName: "Test Player", + iconPath: null, + }, + problemTitle: "Test Problem", + problemDescription: "Description", + problemLanguage: "php" as const, + sampleCode: "<?php echo 1;", + initialCode: "", + onCodeChange: () => {}, + onCodeSubmit: () => {}, + isFinished: false, +}; + +describe("GolfPlayAppGaming submission history", () => { + test("shows placeholder row when no submissions", () => { + const store = createTestStore(); + render( + <Provider store={store}> + <GolfPlayAppGaming {...defaultProps} submissions={[]} /> + </Provider>, + ); + expect(screen.getByText("提出待ち")).toBeDefined(); + const dashes = screen.getAllByText("-"); + expect(dashes.length).toBe(3); + }); + + test("renders submission rows with status and code size", () => { + const store = createTestStore(); + const submissions = [ + { + submission_id: 1, + game_id: 1, + status: "success" as const, + code: "<?php echo 1;", + code_size: 7, + created_at: 1740000000, + }, + { + submission_id: 2, + game_id: 1, + status: "wrong_answer" as const, + code: "<?php echo 2;", + code_size: 10, + created_at: 1740000060, + }, + ]; + render( + <Provider store={store}> + <GolfPlayAppGaming {...defaultProps} submissions={submissions} /> + </Provider>, + ); + expect(screen.getByText("成功")).toBeDefined(); + expect(screen.getByText("テスト失敗")).toBeDefined(); + expect(screen.getByText("7")).toBeDefined(); + expect(screen.getByText("10")).toBeDefined(); + }); + + test("renders table headers", () => { + const store = createTestStore(); + render( + <Provider store={store}> + <GolfPlayAppGaming {...defaultProps} submissions={[]} /> + </Provider>, + ); + expect(screen.getByText("ステータス")).toBeDefined(); + expect(screen.getByText("スコア")).toBeDefined(); + expect(screen.getByText("提出時刻")).toBeDefined(); + expect(screen.getByText("コード")).toBeDefined(); + }); +}); diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx index fa9a2b4..e590df0 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "jotai"; import React, { useRef, useState } from "react"; import { Link } from "wouter"; +import type { components } from "../../api/schema"; import { calcCodeSize, gamingLeftTimeSecondsAtom, @@ -10,6 +11,11 @@ import { import type { PlayerProfile } from "../../types/PlayerProfile"; import type { SupportedLanguage } from "../../types/SupportedLanguage"; import BorderedContainer from "../BorderedContainer"; +import CodePopover from "../Gaming/CodePopover"; +import DataTable, { + DataTableCell, + formatUnixTimestamp, +} from "../Gaming/DataTable"; import LeftTime from "../Gaming/LeftTime"; import ProblemColumn from "../Gaming/ProblemColumn"; import SubmitButton from "../SubmitButton"; @@ -18,6 +24,8 @@ import ThreeColumnLayout from "../ThreeColumnLayout"; import TitledColumn from "../TitledColumn"; import UserIcon from "../UserIcon"; +type Submission = components["schemas"]["Submission"]; + type Props = { gameDisplayName: string; playerProfile: PlayerProfile; @@ -29,6 +37,7 @@ type Props = { onCodeChange: (code: string) => void; onCodeSubmit: (code: string) => void; isFinished: boolean; + submissions: Submission[]; }; export default function GolfPlayAppGaming({ @@ -42,6 +51,7 @@ export default function GolfPlayAppGaming({ onCodeChange, onCodeSubmit, isFinished, + submissions, }: Props) { const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom); const score = useAtomValue(scoreAtom); @@ -124,33 +134,33 @@ export default function GolfPlayAppGaming({ </BorderedContainer> </TitledColumn> <TitledColumn title="提出結果"> - <div className="overflow-hidden border-2 border-brand-600 rounded-xl"> - <table className="min-w-full divide-y divide-gray-400 border-collapse"> - <thead className="bg-gray-50"> - <tr> - <th - scope="col" - className="px-6 py-3 text-left font-medium text-gray-800" - > - ステータス - </th> + <DataTable headers={["ステータス", "スコア", "提出時刻", "コード"]}> + {submissions.length === 0 ? ( + <tr> + <DataTableCell> + <SubmitStatusLabel status={status} /> + </DataTableCell> + <DataTableCell>-</DataTableCell> + <DataTableCell>-</DataTableCell> + <DataTableCell>-</DataTableCell> + </tr> + ) : ( + submissions.map((s) => ( + <tr key={s.submission_id}> + <DataTableCell> + <SubmitStatusLabel status={s.status} /> + </DataTableCell> + <DataTableCell>{s.code_size}</DataTableCell> + <DataTableCell> + {formatUnixTimestamp(s.created_at)} + </DataTableCell> + <DataTableCell> + <CodePopover code={s.code} language={problemLanguage} /> + </DataTableCell> </tr> - </thead> - <tbody className="bg-white divide-y divide-gray-300"> - {[status].map((status) => ( - <tr key={99999}> - <td className="px-6 py-4 whitespace-nowrap text-gray-900"> - <SubmitStatusLabel status={status} /> - </td> - </tr> - ))} - </tbody> - </table> - </div> - <p> - NOTE: - 過去の提出結果を閲覧する機能は現在実装中です。それまでは提出コードをお手元に保管しておいてください。 - </p> + )) + )} + </DataTable> </TitledColumn> </ThreeColumnLayout> </div> |
