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
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
# database
|
# database
|
||||||
/prisma/db.sqlite
|
/prisma/db.sqlite
|
||||||
|
|||||||
25
e2e/app.spec.ts
Normal file
25
e2e/app.spec.ts
Normal file
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "t3app",
|
"name": "folder-game-challenge",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t3app",
|
"name": "folder-game-challenge",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4625,6 +4625,70 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
30
playwright.config.ts
Normal file
30
playwright.config.ts
Normal file
@@ -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!!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
3
src/app/api/health/route.ts
Normal file
3
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function GET() {
|
||||||
|
return Response.json({ status: "ok" as const });
|
||||||
|
}
|
||||||
78
src/app/home-hub.tsx
Normal file
78
src/app/home-hub.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="mx-auto max-w-xl space-y-8 px-4 py-12 text-center">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt=""
|
||||||
|
width={200}
|
||||||
|
height={225}
|
||||||
|
className="mx-auto opacity-95"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="font-heading text-5xl tracking-tight text-white sm:text-6xl">
|
||||||
|
Folder Game Challenge
|
||||||
|
</h1>
|
||||||
|
<p className="font-sans text-lg text-zinc-300">
|
||||||
|
Repair a messy desktop: folders, sorting, downloads, and safe saves.
|
||||||
|
Progress is stored in your browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:justify-center">
|
||||||
|
<Link
|
||||||
|
href="/play"
|
||||||
|
className="rounded-lg bg-violet-600 px-6 py-3 font-heading text-2xl text-white hover:bg-violet-500"
|
||||||
|
>
|
||||||
|
{ready && canContinue && !done ? "Continue" : "Play"}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!ready}
|
||||||
|
className="rounded-lg border border-zinc-500 px-6 py-3 font-heading text-2xl text-zinc-200 hover:bg-white/5 disabled:opacity-40"
|
||||||
|
onClick={() => {
|
||||||
|
clearSave();
|
||||||
|
const start = freshSave(0);
|
||||||
|
writeSave(start);
|
||||||
|
router.push("/play");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{ready && done ? (
|
||||||
|
<p className="font-sans text-sm text-emerald-300/90">
|
||||||
|
You already finished — open Play to see the ending, or New game to
|
||||||
|
reset.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
import { type Metadata } from "next";
|
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";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
|
|
||||||
@@ -24,11 +24,20 @@ const bakbakOne = Bakbak_One({
|
|||||||
variable: "--font-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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${teko.variable} ${bakbakOne.variable}`}>
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`${teko.variable} ${bakbakOne.variable} ${ibmPlexMono.variable}`}
|
||||||
|
>
|
||||||
<body className="min-h-screen bg-gradient-to-b from-purple-900 via-purple-950 to-[#0f0518] text-zinc-100 antialiased">
|
<body className="min-h-screen bg-gradient-to-b from-purple-900 via-purple-950 to-[#0f0518] text-zinc-100 antialiased">
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,55 +1,9 @@
|
|||||||
import { readFile } from "fs/promises";
|
import { HomeHub } from "./home-hub";
|
||||||
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);
|
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-6 py-16">
|
<main className="min-h-screen px-6 py-16">
|
||||||
<div className="mx-auto max-w-3xl space-y-10">
|
<HomeHub />
|
||||||
<header className="space-y-6 text-center">
|
|
||||||
<Image
|
|
||||||
src="/logo.png"
|
|
||||||
alt=""
|
|
||||||
width={611}
|
|
||||||
height={689}
|
|
||||||
className="mx-auto h-auto max-h-36 w-auto sm:max-h-44"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<h1 className="font-heading text-5xl tracking-tight text-white sm:text-6xl">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-6 text-lg leading-relaxed text-zinc-300 sm:text-xl">
|
|
||||||
{paragraphs.map((text, i) => (
|
|
||||||
<p
|
|
||||||
key={i}
|
|
||||||
className={
|
|
||||||
i === paragraphs.length - 1 && text.toLowerCase().includes("npm install")
|
|
||||||
? "rounded-lg border border-purple-400/20 bg-black/25 px-4 py-3 text-base text-zinc-300 sm:text-lg"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
401
src/app/play/file-browser.tsx
Normal file
401
src/app/play/file-browser.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(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 (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-zinc-600/70 bg-zinc-950/90 shadow-2xl shadow-black/40 ring-1 ring-white/5">
|
||||||
|
{/* Title bar */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-zinc-700/80 bg-zinc-900/90 px-4 py-3">
|
||||||
|
<div className="flex gap-2" aria-hidden>
|
||||||
|
<span className="h-3.5 w-3.5 rounded-full bg-red-500/90" />
|
||||||
|
<span className="h-3.5 w-3.5 rounded-full bg-amber-400/90" />
|
||||||
|
<span className="h-3.5 w-3.5 rounded-full bg-emerald-500/80" />
|
||||||
|
</div>
|
||||||
|
<span className="font-heading text-xl tracking-wide text-zinc-300 sm:text-2xl">
|
||||||
|
Files
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-sm text-zinc-500">
|
||||||
|
Drag files onto folders
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-[min(70vh,520px)] flex-col sm:min-h-[min(72vh,580px)] md:min-h-[min(75vh,640px)] md:flex-row">
|
||||||
|
{/* Sidebar — Places */}
|
||||||
|
<aside className="flex w-full flex-col border-b border-zinc-700/80 bg-zinc-900/50 md:w-64 md:border-b-0 md:border-r md:border-zinc-700/80 lg:w-72">
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-700/60 px-4 py-3 text-zinc-400">
|
||||||
|
<IconLayoutSidebarLeftExpand size={20} stroke={1.5} />
|
||||||
|
<span className="font-heading text-base uppercase tracking-wider">
|
||||||
|
Places
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-row gap-1 overflow-x-auto p-3 md:flex-col md:overflow-visible">
|
||||||
|
{placesFolders.map((f) => {
|
||||||
|
const active = fs.viewParentId === f.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => openFolder(f.id)}
|
||||||
|
className={`flex shrink-0 items-center gap-3 rounded-lg px-4 py-3 text-left font-sans text-base transition-colors md:text-lg ${
|
||||||
|
active
|
||||||
|
? "bg-violet-600/25 text-violet-100"
|
||||||
|
: "text-zinc-300 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<IconFolderOpen size={22} className="text-amber-400/90" />
|
||||||
|
) : (
|
||||||
|
<IconFolder size={22} className="text-amber-500/70" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openFolder(DESKTOP_ID)}
|
||||||
|
className={`flex shrink-0 items-center gap-3 rounded-lg px-4 py-3 text-left font-sans text-base md:mt-1 md:text-lg ${
|
||||||
|
fs.viewParentId === DESKTOP_ID
|
||||||
|
? "bg-violet-600/25 text-violet-100"
|
||||||
|
: "text-zinc-400 hover:bg-white/5 hover:text-zinc-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IconFolder size={22} />
|
||||||
|
<span>Desktop</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main column */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-700/80 bg-zinc-900/30 px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canGoBack}
|
||||||
|
onClick={goBack}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-zinc-600 bg-zinc-800/80 px-4 py-2.5 font-sans text-base font-medium text-zinc-200 hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-35"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 flex-1 flex-wrap items-center gap-1 font-mono text-sm text-violet-200/95 sm:text-base"
|
||||||
|
aria-label="Path"
|
||||||
|
>
|
||||||
|
{breadcrumbs.map((node, i) => (
|
||||||
|
<span key={node.id} className="flex min-w-0 items-center gap-1">
|
||||||
|
{i > 0 ? (
|
||||||
|
<IconChevronRight
|
||||||
|
size={18}
|
||||||
|
className="shrink-0 text-zinc-500"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openFolder(node.id)}
|
||||||
|
className={`truncate rounded px-1.5 py-1 hover:bg-white/10 hover:text-white ${
|
||||||
|
i === breadcrumbs.length - 1
|
||||||
|
? "font-medium text-violet-100"
|
||||||
|
: "text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node.name || "This PC"}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List header */}
|
||||||
|
<div className="grid grid-cols-[1fr_auto] gap-6 border-b border-zinc-700/60 bg-zinc-900/40 px-5 py-3 font-sans text-sm font-medium uppercase tracking-wide text-zinc-500 sm:px-6 sm:text-base">
|
||||||
|
<span>Name</span>
|
||||||
|
<span className="text-right">Kind</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{sortedChildren.length === 0 ? (
|
||||||
|
<p className="px-5 py-16 text-center font-sans text-lg text-zinc-500">
|
||||||
|
This folder is empty.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-zinc-800/80">
|
||||||
|
{sortedChildren.map((n) =>
|
||||||
|
n.kind === "folder" ? (
|
||||||
|
<li key={n.id}>
|
||||||
|
<div
|
||||||
|
className={`transition-colors ${
|
||||||
|
dragOverFolderId === n.id
|
||||||
|
? "bg-violet-600/20 ring-1 ring-inset ring-violet-400/60"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
{...bindFolderDrop(n.id)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openFolder(n.id)}
|
||||||
|
className="grid w-full grid-cols-[1fr_auto] gap-6 px-5 py-4 text-left font-sans text-lg text-zinc-100 hover:bg-white/[0.04] sm:px-6 sm:py-5"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-4">
|
||||||
|
<IconFolder
|
||||||
|
size={28}
|
||||||
|
stroke={1.5}
|
||||||
|
className="shrink-0 text-amber-500/85"
|
||||||
|
/>
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{n.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-sm text-zinc-500 sm:text-base">
|
||||||
|
Folder
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li key={n.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData(FILE_DRAG_TYPE, n.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDragOverFolderId(null)}
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedFileId((id) =>
|
||||||
|
id === n.id ? null : n.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`grid w-full grid-cols-[1fr_auto] gap-6 px-5 py-4 text-left font-sans text-lg hover:bg-white/[0.04] sm:px-6 sm:py-5 ${
|
||||||
|
selectedFileId === n.id
|
||||||
|
? "bg-violet-950/40 ring-1 ring-inset ring-violet-500/40"
|
||||||
|
: "cursor-grab active:cursor-grabbing"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-4">
|
||||||
|
<IconFile
|
||||||
|
size={28}
|
||||||
|
stroke={1.5}
|
||||||
|
className="shrink-0 text-zinc-400"
|
||||||
|
/>
|
||||||
|
<span className="truncate font-mono text-base text-zinc-200 sm:text-lg">
|
||||||
|
{n.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-sm text-zinc-500 sm:text-base">
|
||||||
|
File
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destinations strip */}
|
||||||
|
<div className="border-t border-zinc-700/80 bg-zinc-900/40 p-4 sm:p-5">
|
||||||
|
<p className="mb-3 font-sans text-sm leading-relaxed text-zinc-500 sm:text-base">
|
||||||
|
{selectedFileId ? (
|
||||||
|
<>
|
||||||
|
Tap a destination for “
|
||||||
|
<span className="font-mono text-zinc-300">
|
||||||
|
{getNode(fs.nodes, selectedFileId)?.name}
|
||||||
|
</span>
|
||||||
|
”, or drag it there:
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Drop a file onto a folder row above, or onto a destination:</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{foldersForMove.map((f) => {
|
||||||
|
const isParent =
|
||||||
|
!!selectedFileId &&
|
||||||
|
f.id === getNode(fs.nodes, selectedFileId)?.parentId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className={`rounded-lg transition-shadow ${
|
||||||
|
dragOverFolderId === f.id
|
||||||
|
? "ring-2 ring-violet-400 ring-offset-2 ring-offset-zinc-950"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
{...bindFolderDrop(f.id)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!selectedFileId || isParent}
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedFileId) return;
|
||||||
|
const nextNodes = moveFile(
|
||||||
|
fs.nodes,
|
||||||
|
selectedFileId,
|
||||||
|
f.id,
|
||||||
|
);
|
||||||
|
if (!nextNodes) return;
|
||||||
|
persist({ ...save, fs: { ...fs, nodes: nextNodes } });
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2.5 rounded-lg border border-zinc-600 bg-zinc-800/90 px-4 py-3 font-mono text-sm text-zinc-100 hover:bg-zinc-700 disabled:opacity-40 sm:text-base"
|
||||||
|
>
|
||||||
|
<IconFolder size={20} className="text-amber-500/80" />
|
||||||
|
{f.name || "This PC"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subComplete ? (
|
||||||
|
<div className="flex justify-end border-t border-zinc-700/80 bg-zinc-900/50 p-4 sm:p-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg bg-emerald-600 px-8 py-3 font-heading text-2xl text-white hover:bg-emerald-500 sm:text-3xl"
|
||||||
|
onClick={advanceSubOrLevel}
|
||||||
|
>
|
||||||
|
{save.subIndex < lastSub ? "Next step" : "Next level"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
11
src/app/play/page.tsx
Normal file
11
src/app/play/page.tsx
Normal file
@@ -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 <PlayClient />;
|
||||||
|
}
|
||||||
307
src/app/play/play-client.tsx
Normal file
307
src/app/play/play-client.tsx
Normal file
@@ -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<SaveV1 | null>(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 (
|
||||||
|
<div className="flex min-h-[50vh] items-center justify-center font-sans text-zinc-400">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFinished(save)) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-lg space-y-8 px-4 py-12 text-center">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt=""
|
||||||
|
width={200}
|
||||||
|
height={225}
|
||||||
|
className="mx-auto opacity-90"
|
||||||
|
/>
|
||||||
|
<h1 className="font-heading text-4xl text-white">Desktop repaired</h1>
|
||||||
|
<p className="font-sans text-lg text-zinc-300">
|
||||||
|
You practiced folders, sorting, downloads, and picking a safe save
|
||||||
|
spot. Try the same ideas on your real computer.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg bg-violet-600 px-5 py-2.5 font-heading text-xl text-white hover:bg-violet-500"
|
||||||
|
onClick={() => {
|
||||||
|
clearSave();
|
||||||
|
setSave({
|
||||||
|
version: 1,
|
||||||
|
levelIndex: 0,
|
||||||
|
subIndex: 0,
|
||||||
|
fs: buildInitialFs(0),
|
||||||
|
quizChoiceId: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Play again
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="rounded-lg border border-zinc-600 px-5 py-2.5 font-heading text-xl text-zinc-200 hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (save.levelIndex === 3) {
|
||||||
|
return (
|
||||||
|
<QuizLevel
|
||||||
|
save={save}
|
||||||
|
onPick={(id) => {
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-8 px-4 py-8 sm:px-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="font-sans text-sm text-violet-300 hover:text-violet-200"
|
||||||
|
>
|
||||||
|
← Home
|
||||||
|
</Link>
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt=""
|
||||||
|
width={80}
|
||||||
|
height={90}
|
||||||
|
className="opacity-90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className="space-y-4">
|
||||||
|
<p className="font-heading text-base tracking-wide text-violet-300 sm:text-lg">
|
||||||
|
Messy desktop · Level {save.levelIndex + 1} / {LAST_PLAYABLE + 1}
|
||||||
|
{stepLabel ? ` · ${stepLabel}` : ""}
|
||||||
|
</p>
|
||||||
|
<h1 className="font-heading text-5xl tracking-tight text-white sm:text-6xl">
|
||||||
|
{levelTitle(save.levelIndex)}
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-4xl font-sans text-xl leading-relaxed text-zinc-100 sm:text-2xl sm:leading-relaxed">
|
||||||
|
{subBlurb(save.levelIndex, save.subIndex)}
|
||||||
|
</p>
|
||||||
|
<p className="max-w-4xl font-sans text-base leading-relaxed text-zinc-400 sm:text-lg">
|
||||||
|
{levelBlurb(save.levelIndex)}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FileBrowserView
|
||||||
|
save={save}
|
||||||
|
persist={persist}
|
||||||
|
tryMoveToFolder={tryMoveToFolder}
|
||||||
|
subComplete={subComplete}
|
||||||
|
lastSub={lastSub}
|
||||||
|
advanceSubOrLevel={advanceSubOrLevel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto max-w-lg space-y-6 px-4 py-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-block font-sans text-sm text-violet-300 hover:text-violet-200"
|
||||||
|
>
|
||||||
|
← Home
|
||||||
|
</Link>
|
||||||
|
<h1 className="font-heading text-4xl text-white">Safe saving</h1>
|
||||||
|
<p className="font-sans text-lg text-zinc-300">{blurb}</p>
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="sr-only">Save location</legend>
|
||||||
|
{choices.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
className={`flex cursor-pointer flex-col rounded-lg border p-4 ${
|
||||||
|
save.quizChoiceId === c.id
|
||||||
|
? "border-violet-400 bg-violet-950/40"
|
||||||
|
: "border-zinc-600 bg-black/25 hover:border-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="save-loc"
|
||||||
|
checked={save.quizChoiceId === c.id}
|
||||||
|
onChange={() => {
|
||||||
|
setWrong(false);
|
||||||
|
onPick(c.id);
|
||||||
|
}}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="font-heading text-2xl text-white">
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 pl-7 font-sans text-sm text-zinc-400">
|
||||||
|
{c.hint}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!save.quizChoiceId}
|
||||||
|
className="w-full rounded-lg bg-violet-600 py-3 font-heading text-2xl text-white hover:bg-violet-500 disabled:opacity-40"
|
||||||
|
onClick={() => {
|
||||||
|
if (!save.quizChoiceId) return;
|
||||||
|
if (isQuizComplete(save.quizChoiceId)) {
|
||||||
|
onWin();
|
||||||
|
} else {
|
||||||
|
setWrong(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit answer
|
||||||
|
</button>
|
||||||
|
{wrong ? (
|
||||||
|
<p className="font-sans text-amber-300" role="alert">
|
||||||
|
Not quite — homework stays easiest to find in a dedicated folder like
|
||||||
|
Documents.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/lib/folder-game/levels.ts
Normal file
223
src/lib/folder-game/levels.ts
Normal file
@@ -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, "parentId">[]): 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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/lib/folder-game/state.ts
Normal file
76
src/lib/folder-game/state.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
57
src/lib/folder-game/storage.ts
Normal file
57
src/lib/folder-game/storage.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
23
src/lib/folder-game/types.ts
Normal file
23
src/lib/folder-game/types.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -4,4 +4,5 @@
|
|||||||
--font-sans: var(--font-bakbak-one), ui-sans-serif, system-ui, sans-serif,
|
--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";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
--font-heading: var(--font-teko), ui-sans-serif, sans-serif;
|
--font-heading: var(--font-teko), ui-sans-serif, sans-serif;
|
||||||
|
--font-mono: var(--font-ibm-plex-mono), ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
11
tests/api/health-route.test.ts
Normal file
11
tests/api/health-route.test.ts
Normal file
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tests/api/post-hello.test.ts
Normal file
21
tests/api/post-hello.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
52
tests/unit/levels.test.ts
Normal file
52
tests/unit/levels.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
vitest.config.ts
Normal file
22
vitest.config.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user