aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/app/states
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-17 20:32:58 +0900
committernsfisis <nsfisis@gmail.com>2026-02-17 20:32:58 +0900
commit2f3583212f470f454a8bd4942a36742be92ad62b (patch)
tree521313b18404adcbae7bc6ddf9ab415a1d959e9b /frontend/app/states
parent602cca615c733c79bc3930f37408a0e71ee40e62 (diff)
downloadphperkaigi-2026-albatross-2f3583212f470f454a8bd4942a36742be92ad62b.tar.gz
phperkaigi-2026-albatross-2f3583212f470f454a8bd4942a36742be92ad62b.tar.zst
phperkaigi-2026-albatross-2f3583212f470f454a8bd4942a36742be92ad62b.zip
test(frontend): add comprehensive tests for components, hooks, and state
Add 84 new tests covering Jotai atoms (play/watch state transitions, game timing, score management), utility functions (calcCodeSize, checkGameResultKind), UI components (SubmitStatusLabel, LeftTime, SubmitButton, InputText, BorderedContainerWithCaption, FoldableBorderedContainerWithCaption, UserIcon, PlayerNameAndIcon), and the usePageTitle hook. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend/app/states')
-rw-r--r--frontend/app/states/play.test.ts194
-rw-r--r--frontend/app/states/watch.test.ts206
2 files changed, 400 insertions, 0 deletions
diff --git a/frontend/app/states/play.test.ts b/frontend/app/states/play.test.ts
new file mode 100644
index 0000000..0f73039
--- /dev/null
+++ b/frontend/app/states/play.test.ts
@@ -0,0 +1,194 @@
+import { createStore } from "jotai";
+import { describe, expect, test } from "vitest";
+import {
+ calcCodeSize,
+ gameStateKindAtom,
+ gamingLeftTimeSecondsAtom,
+ handleSubmitCodePostAtom,
+ handleSubmitCodePreAtom,
+ scoreAtom,
+ setCurrentTimestampAtom,
+ setDurationSecondsAtom,
+ setGameStartedAtAtom,
+ setLatestGameStateAtom,
+ startingLeftTimeSecondsAtom,
+ statusAtom,
+} from "./play";
+
+describe("calcCodeSize", () => {
+ test("counts UTF-8 bytes after removing whitespace (swift)", () => {
+ expect(calcCodeSize("print(1)", "swift")).toBe(8);
+ });
+
+ test("removes all whitespace for swift", () => {
+ expect(calcCodeSize("print( 1 )\n", "swift")).toBe(8);
+ });
+
+ test("removes <?php tag for php", () => {
+ expect(calcCodeSize("<?php echo 1;", "php")).toBe(6);
+ });
+
+ test("removes <? short tag for php", () => {
+ expect(calcCodeSize("<? echo 1;", "php")).toBe(6);
+ });
+
+ test("removes ?> closing tag for php", () => {
+ expect(calcCodeSize("<?php echo 1;?>", "php")).toBe(6);
+ });
+
+ test("removes whitespace and tags together for php", () => {
+ expect(calcCodeSize("<?php\n echo 1; \n?>", "php")).toBe(6);
+ });
+
+ test("returns 0 for empty string", () => {
+ expect(calcCodeSize("", "swift")).toBe(0);
+ });
+
+ test("counts multi-byte characters correctly", () => {
+ // "あ" is 3 bytes in UTF-8
+ expect(calcCodeSize("あ", "swift")).toBe(3);
+ });
+
+ test("php with only tags and whitespace returns 0", () => {
+ expect(calcCodeSize("<?php ?>", "php")).toBe(0);
+ });
+});
+
+describe("Jotai atoms", () => {
+ test("gameStateKindAtom returns 'loading' initially", () => {
+ const store = createStore();
+ expect(store.get(gameStateKindAtom)).toBe("loading");
+ });
+
+ test("gameStateKindAtom returns 'waiting' when timestamp set but no startedAt", () => {
+ const store = createStore();
+ store.set(setCurrentTimestampAtom);
+ expect(store.get(gameStateKindAtom)).toBe("waiting");
+ });
+
+ test("gameStateKindAtom returns 'starting' when now < startedAt", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now + 60);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gameStateKindAtom)).toBe("starting");
+ });
+
+ test("gameStateKindAtom returns 'gaming' when now >= startedAt and now < finishedAt", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 10);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gameStateKindAtom)).toBe("gaming");
+ });
+
+ test("gameStateKindAtom returns 'finished' when now >= finishedAt", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 400);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gameStateKindAtom)).toBe("finished");
+ });
+
+ test("startingLeftTimeSecondsAtom returns null when startedAt is null", () => {
+ const store = createStore();
+ expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
+ });
+
+ test("startingLeftTimeSecondsAtom returns null when currentTimestamp is null", () => {
+ const store = createStore();
+ store.set(setGameStartedAtAtom, 1000);
+ expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
+ });
+
+ test("startingLeftTimeSecondsAtom returns remaining time before start", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now + 30);
+ const leftTime = store.get(startingLeftTimeSecondsAtom);
+ expect(leftTime).toBeGreaterThanOrEqual(29);
+ expect(leftTime).toBeLessThanOrEqual(30);
+ });
+
+ test("startingLeftTimeSecondsAtom returns 0 when past start time", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 10);
+ expect(store.get(startingLeftTimeSecondsAtom)).toBe(0);
+ });
+
+ test("gamingLeftTimeSecondsAtom returns null when startedAt is null", () => {
+ const store = createStore();
+ expect(store.get(gamingLeftTimeSecondsAtom)).toBeNull();
+ });
+
+ test("gamingLeftTimeSecondsAtom returns remaining game time", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 10);
+ store.set(setDurationSecondsAtom, 300);
+ const leftTime = store.get(gamingLeftTimeSecondsAtom);
+ expect(leftTime).toBeGreaterThanOrEqual(289);
+ expect(leftTime).toBeLessThanOrEqual(290);
+ });
+
+ test("gamingLeftTimeSecondsAtom clamps to 0 when game is over", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 400);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gamingLeftTimeSecondsAtom)).toBe(0);
+ });
+
+ test("gamingLeftTimeSecondsAtom clamps to duration before start", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now + 60);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gamingLeftTimeSecondsAtom)).toBe(300);
+ });
+
+ test("statusAtom returns 'running' when submitting code", () => {
+ const store = createStore();
+ store.set(handleSubmitCodePreAtom);
+ expect(store.get(statusAtom)).toBe("running");
+ });
+
+ test("statusAtom returns raw status when not submitting", () => {
+ const store = createStore();
+ expect(store.get(statusAtom)).toBe("none");
+ });
+
+ test("handleSubmitCodePostAtom resets submitting state", () => {
+ const store = createStore();
+ store.set(handleSubmitCodePreAtom);
+ expect(store.get(statusAtom)).toBe("running");
+ store.set(handleSubmitCodePostAtom);
+ expect(store.get(statusAtom)).toBe("none");
+ });
+
+ test("setLatestGameStateAtom updates status and score", () => {
+ const store = createStore();
+ store.set(setLatestGameStateAtom, {
+ code: "",
+ status: "success",
+ score: 42,
+ best_score_submitted_at: null,
+ });
+ expect(store.get(statusAtom)).toBe("success");
+ expect(store.get(scoreAtom)).toBe(42);
+ });
+
+ test("scoreAtom returns null initially", () => {
+ const store = createStore();
+ expect(store.get(scoreAtom)).toBeNull();
+ });
+});
diff --git a/frontend/app/states/watch.test.ts b/frontend/app/states/watch.test.ts
new file mode 100644
index 0000000..dae1cb9
--- /dev/null
+++ b/frontend/app/states/watch.test.ts
@@ -0,0 +1,206 @@
+import { createStore } from "jotai";
+import { describe, expect, test } from "vitest";
+import {
+ calcCodeSize,
+ checkGameResultKind,
+ gameStateKindAtom,
+ gamingLeftTimeSecondsAtom,
+ latestGameStatesAtom,
+ rankingAtom,
+ setCurrentTimestampAtom,
+ setDurationSecondsAtom,
+ setGameStartedAtAtom,
+ setLatestGameStatesAtom,
+ startingLeftTimeSecondsAtom,
+} from "./watch";
+
+describe("checkGameResultKind", () => {
+ test("returns null when game is not finished", () => {
+ expect(checkGameResultKind("gaming", null, null)).toBeNull();
+ expect(checkGameResultKind("waiting", null, null)).toBeNull();
+ expect(checkGameResultKind("starting", null, null)).toBeNull();
+ expect(checkGameResultKind("loading", null, null)).toBeNull();
+ });
+
+ test("returns draw when both scores are null", () => {
+ expect(checkGameResultKind("finished", null, null)).toBe("draw");
+ });
+
+ test("returns draw when both states have null scores", () => {
+ const stateA = {
+ code: "",
+ status: "none" as const,
+ score: null,
+ best_score_submitted_at: null,
+ };
+ const stateB = {
+ code: "",
+ status: "none" as const,
+ score: null,
+ best_score_submitted_at: null,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("draw");
+ });
+
+ test("returns winB when only A has null score", () => {
+ const stateA = {
+ code: "",
+ status: "none" as const,
+ score: null,
+ best_score_submitted_at: null,
+ };
+ const stateB = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
+ });
+
+ test("returns winA when only B has null score", () => {
+ const stateA = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ const stateB = {
+ code: "",
+ status: "none" as const,
+ score: null,
+ best_score_submitted_at: null,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
+ });
+
+ test("returns winA when A has lower score (code golf)", () => {
+ const stateA = {
+ code: "a",
+ status: "success" as const,
+ score: 5,
+ best_score_submitted_at: 1000,
+ };
+ const stateB = {
+ code: "abcdefghij",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
+ });
+
+ test("returns winB when B has lower score (code golf)", () => {
+ const stateA = {
+ code: "abcdefghij",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ const stateB = {
+ code: "a",
+ status: "success" as const,
+ score: 5,
+ best_score_submitted_at: 1000,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
+ });
+
+ test("breaks tie by earlier submission time - A wins", () => {
+ const stateA = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ const stateB = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1060,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
+ });
+
+ test("breaks tie by earlier submission time - B wins", () => {
+ const stateA = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1060,
+ };
+ const stateB = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
+ });
+});
+
+describe("watch calcCodeSize", () => {
+ test("works the same as play calcCodeSize", () => {
+ expect(calcCodeSize("<?php echo 1;", "php")).toBe(6);
+ expect(calcCodeSize("print(1)", "swift")).toBe(8);
+ });
+});
+
+describe("watch Jotai atoms", () => {
+ test("gameStateKindAtom returns 'loading' initially", () => {
+ const store = createStore();
+ expect(store.get(gameStateKindAtom)).toBe("loading");
+ });
+
+ test("gameStateKindAtom transitions through states correctly", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+
+ store.set(setCurrentTimestampAtom);
+ expect(store.get(gameStateKindAtom)).toBe("waiting");
+
+ store.set(setGameStartedAtAtom, now + 60);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gameStateKindAtom)).toBe("starting");
+
+ store.set(setGameStartedAtAtom, now - 10);
+ expect(store.get(gameStateKindAtom)).toBe("gaming");
+
+ store.set(setGameStartedAtAtom, now - 400);
+ expect(store.get(gameStateKindAtom)).toBe("finished");
+ });
+
+ test("rankingAtom is empty initially", () => {
+ const store = createStore();
+ expect(store.get(rankingAtom)).toEqual([]);
+ });
+
+ test("latestGameStatesAtom is empty initially", () => {
+ const store = createStore();
+ expect(store.get(latestGameStatesAtom)).toEqual({});
+ });
+
+ test("setLatestGameStatesAtom updates states", () => {
+ const store = createStore();
+ const states = {
+ player1: {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ },
+ };
+ store.set(setLatestGameStatesAtom, states);
+ expect(store.get(latestGameStatesAtom)).toEqual(states);
+ });
+
+ test("startingLeftTimeSecondsAtom returns null initially", () => {
+ const store = createStore();
+ expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
+ });
+
+ test("gamingLeftTimeSecondsAtom returns null initially", () => {
+ const store = createStore();
+ expect(store.get(gamingLeftTimeSecondsAtom)).toBeNull();
+ });
+});