From 1957e96363a793b32a86e5484e94d1c3c56e97b3 Mon Sep 17 00:00:00 2001 From: jimmy Date: Sat, 11 Apr 2026 18:22:50 +1200 Subject: [PATCH] Add folder game, file browser UI, and automated tests. Introduces a localStorage-backed messy-desktop challenge with sub-steps, drag-and-drop and Places/Back navigation, IBM Plex Mono, and a /api/health endpoint. Adds Vitest coverage for the API and level logic plus Playwright smoke tests. Made-with: Cursor --- .gitignore | 4 + e2e/app.spec.ts | 25 ++ package-lock.json | 68 +++++- package.json | 4 + playwright.config.ts | 30 +++ src/app/api/health/route.ts | 3 + src/app/home-hub.tsx | 78 +++++++ src/app/layout.tsx | 13 +- src/app/page.tsx | 52 +---- src/app/play/file-browser.tsx | 401 +++++++++++++++++++++++++++++++++ src/app/play/page.tsx | 11 + src/app/play/play-client.tsx | 307 +++++++++++++++++++++++++ src/lib/folder-game/levels.ts | 223 ++++++++++++++++++ src/lib/folder-game/state.ts | 76 +++++++ src/lib/folder-game/storage.ts | 57 +++++ src/lib/folder-game/types.ts | 23 ++ src/styles/globals.css | 1 + tests/api/health-route.test.ts | 11 + tests/api/post-hello.test.ts | 21 ++ tests/unit/levels.test.ts | 52 +++++ vitest.config.ts | 22 ++ 21 files changed, 1429 insertions(+), 53 deletions(-) create mode 100644 e2e/app.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/home-hub.tsx create mode 100644 src/app/play/file-browser.tsx create mode 100644 src/app/play/page.tsx create mode 100644 src/app/play/play-client.tsx create mode 100644 src/lib/folder-game/levels.ts create mode 100644 src/lib/folder-game/state.ts create mode 100644 src/lib/folder-game/storage.ts create mode 100644 src/lib/folder-game/types.ts create mode 100644 tests/api/health-route.test.ts create mode 100644 tests/api/post-hello.test.ts create mode 100644 tests/unit/levels.test.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index c24a835..9bdaac0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ # testing /coverage +/playwright-report/ +/test-results/ +/blob-report/ +/playwright/.cache/ # database /prisma/db.sqlite diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..09dc9a4 --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; + +test.describe("browser", () => { + test("home page shows title", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "Folder Game Challenge" }), + ).toBeVisible(); + }); + + test("play page loads", async ({ page }) => { + await page.goto("/play"); + await expect(page.getByText("Messy desktop")).toBeVisible({ + timeout: 15_000, + }); + }); +}); + +test.describe("API", () => { + test("GET /api/health", async ({ request }) => { + const res = await request.get("/api/health"); + expect(res.ok()).toBe(true); + await expect(res.json()).resolves.toEqual({ status: "ok" }); + }); +}); diff --git a/package-lock.json b/package-lock.json index e523a93..d0aee30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "t3app", + "name": "folder-game-challenge", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "t3app", + "name": "folder-game-challenge", "version": "0.1.0", "hasInstallScript": true, "dependencies": { @@ -4625,6 +4625,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", diff --git a/package.json b/package.json index 7500adb..6a826b6 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "lint:fix": "next lint --fix", "preview": "next build && next start", "start": "next start", + "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:watch": "vitest", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e4d2d6e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "html", + use: { + baseURL: "http://127.0.0.1:3000", + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "npm run dev", + url: "http://127.0.0.1:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + ...process.env, + NODE_ENV: "development", + SKIP_ENV_VALIDATION: "1", + DATABASE_URL: "file:./prisma/db.sqlite", + AUTH_SECRET: + process.env.AUTH_SECRET ?? + "playwright-local-dev-secret-32chars!!", + }, + }, +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..eb68084 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ status: "ok" as const }); +} diff --git a/src/app/home-hub.tsx b/src/app/home-hub.tsx new file mode 100644 index 0000000..ebfc9f9 --- /dev/null +++ b/src/app/home-hub.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { freshSave } from "~/lib/folder-game/levels"; +import { + clearSave, + hasInProgressSave, + isFinished, + loadSave, + writeSave, +} from "~/lib/folder-game/storage"; + +export function HomeHub() { + const router = useRouter(); + const [ready, setReady] = useState(false); + const [canContinue, setCanContinue] = useState(false); + const [done, setDone] = useState(false); + + useEffect(() => { + const s = loadSave(); + setCanContinue(hasInProgressSave()); + setDone(s !== null && isFinished(s)); + setReady(true); + }, []); + + return ( +
+ +
+

+ Folder Game Challenge +

+

+ Repair a messy desktop: folders, sorting, downloads, and safe saves. + Progress is stored in your browser. +

+
+
+ + {ready && canContinue && !done ? "Continue" : "Play"} + + +
+ {ready && done ? ( +

+ You already finished — open Play to see the ending, or New game to + reset. +

+ ) : null} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 42eaca0..1dac4c6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ import "~/styles/globals.css"; import { type Metadata } from "next"; -import { Bakbak_One, Teko } from "next/font/google"; +import { Bakbak_One, IBM_Plex_Mono, Teko } from "next/font/google"; import { TRPCReactProvider } from "~/trpc/react"; @@ -24,11 +24,20 @@ const bakbakOne = Bakbak_One({ variable: "--font-bakbak-one", }); +const ibmPlexMono = IBM_Plex_Mono({ + weight: ["400", "500"], + subsets: ["latin"], + variable: "--font-ibm-plex-mono", +}); + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 68cf024..5475599 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,55 +1,9 @@ -import { readFile } from "fs/promises"; -import Image from "next/image"; -import path from "path"; - -function parseBrief(raw: string): { title: string; paragraphs: string[] } { - const lines = raw.trimEnd().split("\n"); - const title = (lines[0] ?? "").trim(); - const body = lines.slice(1).join("\n").trim(); - const paragraphs = body - .split(/\n\s*\n/) - .map((p) => p.replace(/\n/g, " ").trim()) - .filter(Boolean); - return { title, paragraphs }; -} - -export default async function Home() { - const briefPath = path.join(process.cwd(), "Brief.md"); - const raw = await readFile(briefPath, "utf-8"); - const { title, paragraphs } = parseBrief(raw); +import { HomeHub } from "./home-hub"; +export default function Home() { return (
-
-
- -

- {title} -

-
- -
- {paragraphs.map((text, i) => ( -

- {text} -

- ))} -
-
+
); } diff --git a/src/app/play/file-browser.tsx b/src/app/play/file-browser.tsx new file mode 100644 index 0000000..6bb2d57 --- /dev/null +++ b/src/app/play/file-browser.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { + IconChevronRight, + IconFolder, + IconFolderOpen, + IconFile, + IconLayoutSidebarLeftExpand, +} from "@tabler/icons-react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type DragEvent, +} from "react"; + +import { + DESKTOP_ID, + ROOT_ID, + enterFolder, + getChildren, + getNode, + goUp, + moveFile, +} from "~/lib/folder-game/state"; +import type { FileNode, SaveV1 } from "~/lib/folder-game/types"; + +const FILE_DRAG_TYPE = "text/plain"; + +type FileBrowserViewProps = { + save: SaveV1; + persist: (next: SaveV1) => void; + tryMoveToFolder: (fileId: string, folderId: string) => void; + subComplete: boolean; + lastSub: number; + advanceSubOrLevel: () => void; +}; + +function crumbTargetContains( + el: HTMLElement, + related: EventTarget | null, +): boolean { + if (!(related instanceof Node)) return false; + return el.contains(related); +} + +export function FileBrowserView({ + save, + persist, + tryMoveToFolder, + subComplete, + lastSub, + advanceSubOrLevel, +}: FileBrowserViewProps) { + const fs = save.fs; + const [selectedFileId, setSelectedFileId] = useState(null); + const [dragOverFolderId, setDragOverFolderId] = useState(null); + + useEffect(() => { + setSelectedFileId(null); + }, [save.levelIndex, save.subIndex, save.fs.viewParentId]); + + const sortedChildren = useMemo(() => { + return [...getChildren(fs.nodes, fs.viewParentId)].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [fs.nodes, fs.viewParentId]); + + const breadcrumbs = useMemo( + () => parentChainForBreadcrumb(fs.nodes, fs.viewParentId), + [fs.nodes, fs.viewParentId], + ); + + const placesFolders = useMemo(() => { + return getChildren(fs.nodes, DESKTOP_ID) + .filter((n) => n.kind === "folder") + .sort((a, b) => a.name.localeCompare(b.name)); + }, [fs.nodes]); + + const foldersForMove = useMemo( + () => fs.nodes.filter((n) => n.kind === "folder" && n.id !== "root"), + [fs.nodes], + ); + + const openFolder = useCallback( + (folderId: string) => { + const next = enterFolder(fs, folderId); + if (next) persist({ ...save, fs: next }); + }, + [fs, persist, save], + ); + + const goBack = useCallback(() => { + const up = goUp(fs); + if (up) persist({ ...save, fs: up }); + }, [fs, persist, save]); + + const bindFolderDrop = useCallback( + (folderId: string) => ({ + onDragOver: (e: DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverFolderId(folderId); + }, + onDragLeave: (e: DragEvent) => { + const el = e.currentTarget as HTMLElement; + if (!crumbTargetContains(el, e.relatedTarget)) { + setDragOverFolderId((cur) => (cur === folderId ? null : cur)); + } + }, + onDrop: (e: DragEvent) => { + e.preventDefault(); + const fileId = e.dataTransfer.getData(FILE_DRAG_TYPE); + if (fileId) tryMoveToFolder(fileId, folderId); + setDragOverFolderId(null); + }, + }), + [tryMoveToFolder], + ); + + const canGoBack = goUp(fs) !== null; + + return ( +
+ {/* Title bar */} +
+
+ + + +
+ + Files + + + Drag files onto folders + +
+ +
+ {/* Sidebar — Places */} + + + {/* Main column */} +
+ {/* Toolbar */} +
+ +
+ {breadcrumbs.map((node, i) => ( + + {i > 0 ? ( + + ) : null} + + + ))} +
+
+ + {/* List header */} +
+ Name + Kind +
+ + {/* Rows */} +
+ {sortedChildren.length === 0 ? ( +

+ This folder is empty. +

+ ) : ( +
    + {sortedChildren.map((n) => + n.kind === "folder" ? ( +
  • +
    + +
    +
  • + ) : ( +
  • + +
  • + ), + )} +
+ )} +
+ + {/* Destinations strip */} +
+

+ {selectedFileId ? ( + <> + Tap a destination for “ + + {getNode(fs.nodes, selectedFileId)?.name} + + ”, or drag it there: + + ) : ( + <>Drop a file onto a folder row above, or onto a destination: + )} +

+
+ {foldersForMove.map((f) => { + const isParent = + !!selectedFileId && + f.id === getNode(fs.nodes, selectedFileId)?.parentId; + return ( +
+ +
+ ); + })} +
+
+ + {subComplete ? ( +
+ +
+ ) : null} +
+
+
+ ); +} + +/** Breadcrumb chain from root to current folder (skip synthetic root name). */ +function parentChainForBreadcrumb( + nodes: FileNode[], + viewParentId: string, +): FileNode[] { + const chain: FileNode[] = []; + let cur: FileNode | undefined = getNode(nodes, viewParentId); + while (cur && cur.id !== ROOT_ID) { + chain.push(cur); + cur = getNode(nodes, cur.parentId); + } + chain.reverse(); + return chain; +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx new file mode 100644 index 0000000..8382b4a --- /dev/null +++ b/src/app/play/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; + +import { PlayClient } from "./play-client"; + +export const metadata: Metadata = { + title: "Play · Folder Game Challenge", +}; + +export default function PlayPage() { + return ; +} diff --git a/src/app/play/play-client.tsx b/src/app/play/play-client.tsx new file mode 100644 index 0000000..5021faa --- /dev/null +++ b/src/app/play/play-client.tsx @@ -0,0 +1,307 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +import { + LAST_PLAYABLE, + QUIZ_CORRECT_ID, + buildInitialFs, + isQuizComplete, + isSubComplete, + levelBlurb, + levelTitle, + subBlurb, + subCount, +} from "~/lib/folder-game/levels"; +import { moveFile } from "~/lib/folder-game/state"; +import type { SaveV1 } from "~/lib/folder-game/types"; +import { + clearSave, + isFinished, + resumeOrNew, + writeSave, +} from "~/lib/folder-game/storage"; + +import { FileBrowserView } from "./file-browser"; + +export function PlayClient() { + const [save, setSave] = useState(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setSave(resumeOrNew()); + setMounted(true); + }, []); + + useEffect(() => { + if (save) writeSave(save); + }, [save]); + + const persist = useCallback((next: SaveV1) => { + setSave(next); + }, []); + + const tryMoveToFolder = useCallback((fileId: string, folderId: string) => { + setSave((s) => { + if (!s) return s; + const nextNodes = moveFile(s.fs.nodes, fileId, folderId); + if (!nextNodes) return s; + return { ...s, fs: { ...s.fs, nodes: nextNodes } }; + }); + }, []); + + /** After quiz or when leaving the last sub-challenge of a level. */ + const advanceMainLevel = useCallback(() => { + setSave((s) => { + if (!s) return s; + const nextIdx = s.levelIndex + 1; + if (nextIdx > LAST_PLAYABLE + 1) return s; + return { + version: 1, + levelIndex: nextIdx, + subIndex: 0, + fs: buildInitialFs(Math.min(nextIdx, LAST_PLAYABLE)), + quizChoiceId: null, + }; + }); + }, []); + + /** After finishing the current sub-challenge (or whole level if last sub). */ + const advanceSubOrLevel = useCallback(() => { + setSave((s) => { + if (!s) return s; + const subs = subCount(s.levelIndex); + const lastSub = subs - 1; + if (s.subIndex < lastSub) { + return { ...s, subIndex: s.subIndex + 1 }; + } + const nextIdx = s.levelIndex + 1; + if (nextIdx > LAST_PLAYABLE + 1) return s; + return { + version: 1, + levelIndex: nextIdx, + subIndex: 0, + fs: buildInitialFs(Math.min(nextIdx, LAST_PLAYABLE)), + quizChoiceId: null, + }; + }); + }, []); + + if (!mounted || !save) { + return ( +
+ Loading… +
+ ); + } + + if (isFinished(save)) { + return ( +
+ +

Desktop repaired

+

+ You practiced folders, sorting, downloads, and picking a safe save + spot. Try the same ideas on your real computer. +

+
+ + + Home + +
+
+ ); + } + + if (save.levelIndex === 3) { + return ( + { + persist({ ...save, quizChoiceId: id }); + }} + onWin={advanceMainLevel} + blurb={levelBlurb(3)} + /> + ); + } + + const fs = save.fs; + const subs = subCount(save.levelIndex); + const subComplete = isSubComplete(save.levelIndex, save.subIndex, fs); + const lastSub = subs - 1; + const stepLabel = + subs > 1 + ? `Step ${save.subIndex + 1} of ${subs}` + : null; + + return ( +
+
+ + ← Home + + +
+ +
+

+ Messy desktop · Level {save.levelIndex + 1} / {LAST_PLAYABLE + 1} + {stepLabel ? ` · ${stepLabel}` : ""} +

+

+ {levelTitle(save.levelIndex)} +

+

+ {subBlurb(save.levelIndex, save.subIndex)} +

+

+ {levelBlurb(save.levelIndex)} +

+
+ + +
+ ); +} + +function QuizLevel({ + save, + onPick, + onWin, + blurb, +}: { + save: SaveV1; + onPick: (id: string) => void; + onWin: () => void; + blurb: string; +}) { + const [wrong, setWrong] = useState(false); + + const choices = [ + { + id: QUIZ_CORRECT_ID, + label: "Documents", + hint: "A stable place for things you care about.", + }, + { + id: "opt-dl", + label: "Downloads", + hint: "Easy to lose track when everything lands here.", + }, + { + id: "opt-desk", + label: "Desktop", + hint: "Clutters fast; not ideal for homework.", + }, + ]; + + return ( +
+ + ← Home + +

Safe saving

+

{blurb}

+
+ Save location + {choices.map((c) => ( + + ))} +
+ + {wrong ? ( +

+ Not quite — homework stays easiest to find in a dedicated folder like + Documents. +

+ ) : null} +
+ ); +} diff --git a/src/lib/folder-game/levels.ts b/src/lib/folder-game/levels.ts new file mode 100644 index 0000000..ba78155 --- /dev/null +++ b/src/lib/folder-game/levels.ts @@ -0,0 +1,223 @@ +import type { FileNode, FsSlice, SaveV1 } from "./types"; + +import { DESKTOP_ID, baseNodes, initialFsSlice } from "./state"; + +/** Playable levels are 0..3; 4 means finished. */ +export const LAST_PLAYABLE = 3; + +function withDesktop(children: Omit[]): FileNode[] { + const nodes = baseNodes(); + for (const c of children) { + nodes.push({ ...c, parentId: DESKTOP_ID }); + } + return nodes; +} + +function level0Nodes(): FileNode[] { + return withDesktop([ + { id: "fld-docs", name: "Documents", kind: "folder" }, + { id: "fld-games", name: "Games", kind: "folder" }, + { id: "file-readme", name: "READ_ME.txt", kind: "file" }, + ]); +} + +function level1Nodes(): FileNode[] { + return withDesktop([ + { id: "fld-pics", name: "Pictures", kind: "folder" }, + { id: "fld-school", name: "School", kind: "folder" }, + { id: "file-cat", name: "cat.png", kind: "file" }, + { id: "file-hw", name: "homework.txt", kind: "file" }, + ]); +} + +function level2Nodes(): FileNode[] { + const nodes = baseNodes(); + nodes.push( + { id: "fld-photos", name: "Photos", parentId: DESKTOP_ID, kind: "folder" }, + { id: "fld-work", name: "Work", parentId: DESKTOP_ID, kind: "folder" }, + { + id: "fld-installers", + name: "Installers", + kind: "folder", + parentId: DESKTOP_ID, + }, + { + id: "fld-downloads", + name: "Downloads", + kind: "folder", + parentId: DESKTOP_ID, + }, + { + id: "f-beach", + name: "beach.jpg", + kind: "file", + parentId: "fld-downloads", + }, + { + id: "f-budget", + name: "budget.xlsx", + kind: "file", + parentId: "fld-downloads", + }, + { + id: "f-game", + name: "game-installer.msi", + kind: "file", + parentId: "fld-downloads", + }, + ); + return nodes; +} + +export function buildInitialFs(levelIndex: number): FsSlice { + switch (levelIndex) { + case 0: + return initialFsSlice(level0Nodes()); + case 1: + return initialFsSlice(level1Nodes()); + case 2: + return initialFsSlice(level2Nodes()); + case 3: + return initialFsSlice( + withDesktop([{ id: "fld-docs", name: "Documents", kind: "folder" }]), + ); + default: + return initialFsSlice(level0Nodes()); + } +} + +export function freshSave(levelIndex: number): SaveV1 { + return { + version: 1, + levelIndex, + subIndex: 0, + fs: buildInitialFs(Math.min(levelIndex, LAST_PLAYABLE)), + quizChoiceId: null, + }; +} + +function parentOf(fs: FsSlice, fileId: string) { + return fs.nodes.find((n) => n.id === fileId)?.parentId; +} + +/** How many sub-challenges this main level has (quiz = 1). */ +export function subCount(levelIndex: number): number { + switch (levelIndex) { + case 0: + return 2; + case 1: + return 2; + case 2: + return 3; + case 3: + return 1; + default: + return 1; + } +} + +/** + * Align subIndex with filesystem progress (fixes older saves without `subIndex`). + */ +export function migrateSubIndex( + levelIndex: number, + fs: FsSlice, + _storedSub: number | undefined, +): number { + const n = subCount(levelIndex); + for (let i = 0; i < n; i++) { + if (!isSubComplete(levelIndex, i, fs)) return i; + } + return Math.max(0, n - 1); +} + +export function isSubComplete( + levelIndex: number, + subIndex: number, + fs: FsSlice, +): boolean { + switch (levelIndex) { + case 0: + if (subIndex === 0) return fs.visitedFolderIds.includes("fld-games"); + if (subIndex === 1) return fs.visitedFolderIds.includes("fld-docs"); + return false; + case 1: + if (subIndex === 0) return parentOf(fs, "file-cat") === "fld-pics"; + if (subIndex === 1) return parentOf(fs, "file-hw") === "fld-school"; + return false; + case 2: + if (subIndex === 0) return parentOf(fs, "f-beach") === "fld-photos"; + if (subIndex === 1) return parentOf(fs, "f-budget") === "fld-work"; + if (subIndex === 2) return parentOf(fs, "f-game") === "fld-installers"; + return false; + default: + return false; + } +} + +export function isFsLevelComplete(levelIndex: number, fs: FsSlice): boolean { + const n = subCount(levelIndex); + for (let i = 0; i < n; i++) { + if (!isSubComplete(levelIndex, i, fs)) return false; + } + return true; +} + +export const QUIZ_CORRECT_ID = "opt-docs"; + +export function isQuizComplete(choiceId: string | null) { + return choiceId === QUIZ_CORRECT_ID; +} + +export function levelTitle(levelIndex: number): string { + switch (levelIndex) { + case 0: + return "Find Documents"; + case 1: + return "Put files away"; + case 2: + return "Rescue Downloads"; + case 3: + return "Safe saving"; + default: + return "Complete"; + } +} + +/** Short context for the whole level. */ +export function levelBlurb(levelIndex: number): string { + switch (levelIndex) { + case 0: + return "Folders are like drawers—open them with a click. Use Back to return to the folder above."; + case 1: + return "Drag a file onto a folder to move it—or tap a file, then tap a folder below."; + case 2: + return "Drag one file at a time onto the right folder (or use tap + folder buttons)."; + case 3: + return "Where should you save homework so you can find it later?"; + default: + return ""; + } +} + +/** Current sub-challenge instruction. */ +export function subBlurb(levelIndex: number, subIndex: number): string { + switch (levelIndex) { + case 0: + if (subIndex === 0) + return "First: click the Games folder to open it. Then use Back to return to the Desktop."; + return "Next: open the Documents folder the same way."; + case 1: + if (subIndex === 0) + return "Drag cat.png onto the Pictures folder (or select it, then pick Pictures)."; + return "Drag homework.txt onto the School folder."; + case 2: + if (subIndex === 0) return "Drag beach.jpg onto Photos."; + if (subIndex === 1) return "Drag budget.xlsx onto Work."; + return "Drag game-installer.msi onto Installers."; + case 3: + return levelBlurb(3); + default: + return ""; + } +} diff --git a/src/lib/folder-game/state.ts b/src/lib/folder-game/state.ts new file mode 100644 index 0000000..827861b --- /dev/null +++ b/src/lib/folder-game/state.ts @@ -0,0 +1,76 @@ +import type { FileNode, FsSlice } from "./types"; + +export const ROOT_ID = "root"; +export const DESKTOP_ID = "desktop"; + +export function getChildren(nodes: FileNode[], parentId: string) { + return nodes.filter((n) => n.parentId === parentId); +} + +export function getNode(nodes: FileNode[], id: string) { + return nodes.find((n) => n.id === id); +} + +export function parentChain(nodes: FileNode[], id: string): FileNode[] { + const out: FileNode[] = []; + let cur: FileNode | undefined = getNode(nodes, id); + while (cur && cur.id !== ROOT_ID) { + out.push(cur); + cur = getNode(nodes, cur.parentId); + } + return out.reverse(); +} + +export function pathLabel(nodes: FileNode[], viewParentId: string): string { + const chain = parentChain(nodes, viewParentId); + return chain.map((n) => n.name).join(" / "); +} + +export function moveFile( + nodes: FileNode[], + fileId: string, + newParentId: string, +): FileNode[] | null { + const file = getNode(nodes, fileId); + const target = getNode(nodes, newParentId); + if (!file || file.kind !== "file") return null; + if (!target || target.kind !== "folder") return null; + return nodes.map((n) => + n.id === fileId ? { ...n, parentId: newParentId } : n, + ); +} + +export function enterFolder(fs: FsSlice, folderId: string): FsSlice | null { + const folder = getNode(fs.nodes, folderId); + if (!folder || folder.kind !== "folder") return null; + const visited = fs.visitedFolderIds.includes(folderId) + ? fs.visitedFolderIds + : [...fs.visitedFolderIds, folderId]; + return { ...fs, viewParentId: folderId, visitedFolderIds: visited }; +} + +export function goUp(fs: FsSlice): FsSlice | null { + const cur = getNode(fs.nodes, fs.viewParentId); + if (!cur || cur.parentId === ROOT_ID) return null; + return { ...fs, viewParentId: cur.parentId }; +} + +export function initialFsSlice(nodes: FileNode[]): FsSlice { + return { + nodes, + viewParentId: DESKTOP_ID, + visitedFolderIds: [], + }; +} + +export function baseNodes(): FileNode[] { + return [ + { id: ROOT_ID, name: "", parentId: ROOT_ID, kind: "folder" }, + { + id: DESKTOP_ID, + name: "Desktop", + parentId: ROOT_ID, + kind: "folder", + }, + ]; +} diff --git a/src/lib/folder-game/storage.ts b/src/lib/folder-game/storage.ts new file mode 100644 index 0000000..9fdf9ee --- /dev/null +++ b/src/lib/folder-game/storage.ts @@ -0,0 +1,57 @@ +import type { SaveV1 } from "./types"; + +import { + LAST_PLAYABLE, + freshSave, + migrateSubIndex, + subCount, +} from "./levels"; + +export const STORAGE_KEY = "folder-game-challenge:v1"; + +export function parseSave(raw: string | null): SaveV1 | null { + if (!raw) return null; + try { + const v = JSON.parse(raw) as SaveV1; + if (v?.version !== 1) return null; + if (typeof v.levelIndex !== "number") return null; + if (v.levelIndex < 0 || v.levelIndex > LAST_PLAYABLE + 1) return null; + if (!v.fs?.nodes || typeof v.fs.viewParentId !== "string") return null; + if (!Array.isArray(v.fs.visitedFolderIds)) return null; + const subIndex = migrateSubIndex( + v.levelIndex, + v.fs, + typeof v.subIndex === "number" ? v.subIndex : undefined, + ); + const maxSub = Math.max(0, subCount(v.levelIndex) - 1); + return { ...v, subIndex: Math.min(subIndex, maxSub) }; + } catch { + return null; + } +} + +export function loadSave(): SaveV1 | null { + if (typeof window === "undefined") return null; + return parseSave(localStorage.getItem(STORAGE_KEY)); +} + +export function writeSave(save: SaveV1) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(save)); +} + +export function clearSave() { + localStorage.removeItem(STORAGE_KEY); +} + +export function resumeOrNew(): SaveV1 { + return loadSave() ?? freshSave(0); +} + +export function hasInProgressSave(): boolean { + const s = loadSave(); + return s !== null && s.levelIndex <= LAST_PLAYABLE; +} + +export function isFinished(save: SaveV1) { + return save.levelIndex > LAST_PLAYABLE; +} diff --git a/src/lib/folder-game/types.ts b/src/lib/folder-game/types.ts new file mode 100644 index 0000000..c37496b --- /dev/null +++ b/src/lib/folder-game/types.ts @@ -0,0 +1,23 @@ +export type NodeKind = "file" | "folder"; + +export type FileNode = { + id: string; + name: string; + parentId: string; + kind: NodeKind; +}; + +export type FsSlice = { + nodes: FileNode[]; + viewParentId: string; + visitedFolderIds: string[]; +}; + +export type SaveV1 = { + version: 1; + levelIndex: number; + /** Step within the current level (filesystem levels use multiple). */ + subIndex: number; + fs: FsSlice; + quizChoiceId: string | null; +}; diff --git a/src/styles/globals.css b/src/styles/globals.css index afc0e1e..f755467 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -4,4 +4,5 @@ --font-sans: var(--font-bakbak-one), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-heading: var(--font-teko), ui-sans-serif, sans-serif; + --font-mono: var(--font-ibm-plex-mono), ui-monospace, monospace; } diff --git a/tests/api/health-route.test.ts b/tests/api/health-route.test.ts new file mode 100644 index 0000000..732edf5 --- /dev/null +++ b/tests/api/health-route.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { GET } from "~/app/api/health/route"; + +describe("GET /api/health", () => { + it("returns 200 and status ok", async () => { + const res = await GET(); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ status: "ok" }); + }); +}); diff --git a/tests/api/post-hello.test.ts b/tests/api/post-hello.test.ts new file mode 100644 index 0000000..b26cb5b --- /dev/null +++ b/tests/api/post-hello.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("~/server/auth", () => ({ + auth: vi.fn(async () => null), +})); + +vi.mock("~/server/db", () => ({ + db: {}, +})); + +import { createCaller } from "~/server/api/root"; +import { createTRPCContext } from "~/server/api/trpc"; + +describe("tRPC post.hello", () => { + it("returns a greeting for the given text", async () => { + const ctx = await createTRPCContext({ headers: new Headers() }); + const caller = createCaller(ctx); + const out = await caller.post.hello({ text: "Tester" }); + expect(out.greeting).toBe("Hello Tester"); + }); +}); diff --git a/tests/unit/levels.test.ts b/tests/unit/levels.test.ts new file mode 100644 index 0000000..6ede8f3 --- /dev/null +++ b/tests/unit/levels.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + buildInitialFs, + isFsLevelComplete, + isQuizComplete, + isSubComplete, + QUIZ_CORRECT_ID, +} from "~/lib/folder-game/levels"; + +describe("isFsLevelComplete", () => { + it("level 0 completes when Games and Documents were opened", () => { + const fs = buildInitialFs(0); + expect(isFsLevelComplete(0, fs)).toBe(false); + expect( + isFsLevelComplete(0, { + ...fs, + visitedFolderIds: ["fld-games", "fld-docs"], + }), + ).toBe(true); + }); + + it("level 1 completes when files are sorted", () => { + const fs = buildInitialFs(1); + const nodes = fs.nodes.map((n) => { + if (n.id === "file-cat") return { ...n, parentId: "fld-pics" }; + if (n.id === "file-hw") return { ...n, parentId: "fld-school" }; + return n; + }); + expect(isFsLevelComplete(1, { ...fs, nodes })).toBe(true); + }); +}); + +describe("isSubComplete", () => { + it("level 0 sub 0 requires Games", () => { + const fs = buildInitialFs(0); + expect(isSubComplete(0, 0, fs)).toBe(false); + expect( + isSubComplete(0, 0, { + ...fs, + visitedFolderIds: ["fld-games"], + }), + ).toBe(true); + }); +}); + +describe("isQuizComplete", () => { + it("accepts Documents choice", () => { + expect(isQuizComplete(QUIZ_CORRECT_ID)).toBe(true); + expect(isQuizComplete("opt-dl")).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5075897 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const root = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "~": path.join(root, "src"), + }, + }, + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + exclude: ["e2e/**", "node_modules/**"], + env: { + NODE_ENV: "test", + SKIP_ENV_VALIDATION: "1", + }, + }, +});