From 104d6a1d462c1efb1fcef0259d8d9d5b0caaef65 Mon Sep 17 00:00:00 2001 From: jimmy Date: Sat, 11 Apr 2026 19:26:39 +1200 Subject: [PATCH] feat(play): debug retry step, folder hints, and e2e coverage - Add buildFsAtSubStart so dev "Retry step" resets the current sub while keeping earlier subs complete; quiz screen clears the selected answer. - Stop adding hinted files' parent folders to click hints (avoids wrongly highlighting Downloads when already inside it). - Extend Playwright: localStorage reset, full playthrough through quiz, and a retry-step regression test. Add Vitest for buildFsAtSubStart. Made-with: Cursor --- e2e/app.spec.ts | 85 ++++++++++++++++ src/app/play/file-browser.tsx | 180 +++++++++++++++------------------- src/app/play/play-client.tsx | 125 ++++++++++++++++------- src/lib/folder-game/levels.ts | 128 ++++++++++++++++++++++-- tests/unit/levels.test.ts | 32 ++++++ 5 files changed, 404 insertions(+), 146 deletions(-) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 09dc9a4..920ccdc 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,6 +1,14 @@ import { expect, test } from "@playwright/test"; +const SAVE_KEY = "folder-game-challenge:v1"; + test.describe("browser", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript((key) => { + localStorage.removeItem(key); + }, SAVE_KEY); + }); + test("home page shows title", async ({ page }) => { await page.goto("/"); await expect( @@ -14,6 +22,83 @@ test.describe("browser", () => { timeout: 15_000, }); }); + + test("debug retry step resets current sub without losing prior progress", async ({ + page, + }) => { + await page.goto("/play"); + await expect(page.getByText("Messy desktop")).toBeVisible({ + timeout: 15_000, + }); + + await page.getByRole("button", { name: "Games" }).first().click(); + await page.getByRole("button", { name: "Back" }).click(); + await page.getByRole("button", { name: "Documents" }).first().click(); + await page.getByRole("button", { name: "Next level" }).click(); + + await expect(page.getByText(/Drag cat\.png/)).toBeVisible(); + await page.getByRole("button", { name: /cat\.png/ }).dragTo( + page.locator("aside").getByRole("button", { name: "Pictures" }), + ); + await expect(page.getByText(/Drag homework\.txt/)).toBeVisible(); + + await page.getByRole("button", { name: "Retry this step (debug)" }).click(); + + await page.locator("aside").getByRole("button", { name: "Pictures" }).click(); + await expect(page.getByRole("button", { name: /cat\.png/ })).toBeVisible(); + await expect(page.getByText(/Drag homework\.txt/)).toBeVisible(); + }); + + test("full playthrough through quiz win", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/play"); + await expect(page.getByText("Messy desktop")).toBeVisible({ + timeout: 15_000, + }); + + // Level 0 — Find Documents + await expect(page.getByText(/click the Games folder/)).toBeVisible(); + await page.getByRole("button", { name: "Games" }).first().click(); + await page.getByRole("button", { name: "Back" }).click(); + await page.getByRole("button", { name: "Documents" }).first().click(); + await page.getByRole("button", { name: "Next level" }).click(); + + // Level 1 — Put files away + await expect(page.getByText(/Drag cat\.png/)).toBeVisible(); + await page.getByRole("button", { name: /cat\.png/ }).dragTo( + page.locator("aside").getByRole("button", { name: "Pictures" }), + ); + await expect(page.getByText(/Drag homework\.txt/)).toBeVisible(); + await page.getByRole("button", { name: /homework\.txt/ }).dragTo( + page.locator("aside").getByRole("button", { name: "School" }), + ); + await page.getByRole("button", { name: "Next level" }).click(); + + // Level 2 — Rescue Downloads (starts in Downloads) + await expect(page.getByText(/Drag beach\.jpg/)).toBeVisible(); + await expect(page.getByRole("button", { name: /beach\.jpg/ })).toBeVisible(); + await page.getByRole("button", { name: /beach\.jpg/ }).dragTo( + page.locator("aside").getByRole("button", { name: "Photos" }), + ); + await expect(page.getByText(/Drag budget\.xlsx/)).toBeVisible(); + await page.getByRole("button", { name: /budget\.xlsx/ }).dragTo( + page.locator("aside").getByRole("button", { name: "Work" }), + ); + await expect(page.getByText(/Drag game-installer/)).toBeVisible(); + await page.getByRole("button", { name: /game-installer\.msi/ }).dragTo( + page.locator("aside").getByRole("button", { name: "Installers" }), + ); + await page.getByRole("button", { name: "Next level" }).click(); + + // Level 3 — Quiz + await expect(page.getByRole("heading", { name: "Safe saving" })).toBeVisible(); + await page.getByRole("radio", { name: /Documents/i }).check(); + await page.getByRole("button", { name: "Submit answer" }).click(); + + await expect( + page.getByRole("heading", { name: "Desktop repaired" }), + ).toBeVisible({ timeout: 15_000 }); + }); }); test.describe("API", () => { diff --git a/src/app/play/file-browser.tsx b/src/app/play/file-browser.tsx index 6bb2d57..337be60 100644 --- a/src/app/play/file-browser.tsx +++ b/src/app/play/file-browser.tsx @@ -7,14 +7,12 @@ import { IconFile, IconLayoutSidebarLeftExpand, } from "@tabler/icons-react"; -import { - useCallback, - useEffect, - useMemo, - useState, - type DragEvent, -} from "react"; +import { useCallback, useMemo, useState, type DragEvent } from "react"; +import { + highlightTargets, + isSubComplete, +} from "~/lib/folder-game/levels"; import { DESKTOP_ID, ROOT_ID, @@ -22,19 +20,21 @@ import { getChildren, getNode, goUp, - moveFile, } from "~/lib/folder-game/state"; import type { FileNode, SaveV1 } from "~/lib/folder-game/types"; const FILE_DRAG_TYPE = "text/plain"; +/** Pulsing ring for the current sub-task targets. */ +const HINT_RING = + "ring-2 ring-amber-400 ring-offset-2 ring-offset-zinc-950 shadow-[0_0_22px_rgba(251,191,36,0.35)] motion-safe:animate-pulse"; + type FileBrowserViewProps = { save: SaveV1; persist: (next: SaveV1) => void; tryMoveToFolder: (fileId: string, folderId: string) => void; - subComplete: boolean; - lastSub: number; - advanceSubOrLevel: () => void; + levelComplete: boolean; + onNextLevel: () => void; }; function crumbTargetContains( @@ -49,18 +49,12 @@ export function FileBrowserView({ save, persist, tryMoveToFolder, - subComplete, - lastSub, - advanceSubOrLevel, + levelComplete, + onNextLevel, }: 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; @@ -79,11 +73,6 @@ export function FileBrowserView({ .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); @@ -122,6 +111,45 @@ export function FileBrowserView({ const canGoBack = goUp(fs) !== null; + const clickHints = useMemo(() => { + if (save.levelIndex > 2 || levelComplete) { + return { + active: false, + folders: new Set(), + files: new Set(), + back: false, + }; + } + const subDone = isSubComplete(save.levelIndex, save.subIndex, fs); + const active = !subDone; + if (!active) { + return { + active: false, + folders: new Set(), + files: new Set(), + back: false, + }; + } + const t = highlightTargets(save.levelIndex, save.subIndex); + const folders = new Set(t.folderIds); + const back = + save.levelIndex === 0 && + save.subIndex === 1 && + fs.viewParentId !== DESKTOP_ID && + goUp(fs) !== null; + return { + active: true, + folders, + files: new Set(t.fileIds), + back, + }; + }, [save.levelIndex, save.subIndex, fs, levelComplete]); + + const hintFolder = (id: string) => + clickHints.active && clickHints.folders.has(id); + const hintFile = (id: string) => + clickHints.active && clickHints.files.has(id); + return (
{/* Title bar */} @@ -156,11 +184,16 @@ export function FileBrowserView({ 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 ${ + {...bindFolderDrop(f.id)} + className={`flex w-full 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" - }`} + } ${ + dragOverFolderId === f.id + ? "ring-2 ring-violet-400 ring-offset-2 ring-offset-zinc-950" + : "" + } ${hintFolder(f.id) ? HINT_RING : ""}`} > {active ? ( @@ -174,11 +207,16 @@ export function FileBrowserView({ @@ -213,11 +253,16 @@ export function FileBrowserView({ @@ -244,11 +289,11 @@ export function FileBrowserView({ n.kind === "folder" ? (
  • -
    - ); - })} -
  • - - - {subComplete ? ( + {levelComplete ? (
    ) : null} diff --git a/src/app/play/play-client.tsx b/src/app/play/play-client.tsx index 5021faa..04169c1 100644 --- a/src/app/play/play-client.tsx +++ b/src/app/play/play-client.tsx @@ -7,7 +7,9 @@ import { useCallback, useEffect, useState } from "react"; import { LAST_PLAYABLE, QUIZ_CORRECT_ID, + buildFsAtSubStart, buildInitialFs, + isFsLevelComplete, isQuizComplete, isSubComplete, levelBlurb, @@ -39,6 +41,24 @@ export function PlayClient() { if (save) writeSave(save); }, [save]); + /** Advance sub-step automatically when the current sub is done (no mid-level button). */ + useEffect(() => { + if (!mounted || !save) return; + if (save.levelIndex > 2) return; + + setSave((s) => { + if (!s || s.levelIndex > 2) return s; + const subs = subCount(s.levelIndex); + const last = subs - 1; + let subIndex = s.subIndex; + while (subIndex < last && isSubComplete(s.levelIndex, subIndex, s.fs)) { + subIndex += 1; + } + if (subIndex === s.subIndex) return s; + return { ...s, subIndex }; + }); + }, [mounted, save?.fs, save?.levelIndex, save?.subIndex]); + const persist = useCallback((next: SaveV1) => { setSave(next); }, []); @@ -68,15 +88,10 @@ export function PlayClient() { }); }, []); - /** After finishing the current sub-challenge (or whole level if last sub). */ - const advanceSubOrLevel = useCallback(() => { + /** After all sub-challenges in the level are done (single continue control). */ + const advanceToNextLevel = 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 { @@ -149,49 +164,67 @@ export function PlayClient() { }} onWin={advanceMainLevel} blurb={levelBlurb(3)} + onDebugRetryQuiz={ + process.env.NODE_ENV === "development" + ? () => persist({ ...save, quizChoiceId: null }) + : undefined + } /> ); } const fs = save.fs; const subs = subCount(save.levelIndex); - const subComplete = isSubComplete(save.levelIndex, save.subIndex, fs); - const lastSub = subs - 1; + const levelComplete = isFsLevelComplete(save.levelIndex, fs); const stepLabel = subs > 1 ? `Step ${save.subIndex + 1} of ${subs}` : null; return ( -
    -
    - - ← Home - - -
    - -
    -

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

    -

    +
    +
    +
    + + ← Home + +

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

    + {process.env.NODE_ENV === "development" ? ( + + ) : null} + +
    +

    {levelTitle(save.levelIndex)}

    -

    +

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

    -

    +

    {levelBlurb(save.levelIndex)}

    @@ -200,9 +233,8 @@ export function PlayClient() { save={save} persist={persist} tryMoveToFolder={tryMoveToFolder} - subComplete={subComplete} - lastSub={lastSub} - advanceSubOrLevel={advanceSubOrLevel} + levelComplete={levelComplete} + onNextLevel={advanceToNextLevel} />
    ); @@ -213,11 +245,13 @@ function QuizLevel({ onPick, onWin, blurb, + onDebugRetryQuiz, }: { save: SaveV1; onPick: (id: string) => void; onWin: () => void; blurb: string; + onDebugRetryQuiz?: () => void; }) { const [wrong, setWrong] = useState(false); @@ -247,7 +281,22 @@ function QuizLevel({ > ← Home -

    Safe saving

    +
    +

    Safe saving

    + {onDebugRetryQuiz ? ( + + ) : null} +

    {blurb}

    Save location diff --git a/src/lib/folder-game/levels.ts b/src/lib/folder-game/levels.ts index ba78155..4d21255 100644 --- a/src/lib/folder-game/levels.ts +++ b/src/lib/folder-game/levels.ts @@ -1,6 +1,6 @@ import type { FileNode, FsSlice, SaveV1 } from "./types"; -import { DESKTOP_ID, baseNodes, initialFsSlice } from "./state"; +import { DESKTOP_ID, baseNodes, initialFsSlice, moveFile } from "./state"; /** Playable levels are 0..3; 4 means finished. */ export const LAST_PLAYABLE = 3; @@ -75,8 +75,15 @@ export function buildInitialFs(levelIndex: number): FsSlice { return initialFsSlice(level0Nodes()); case 1: return initialFsSlice(level1Nodes()); - case 2: - return initialFsSlice(level2Nodes()); + case 2: { + const nodes = level2Nodes(); + /** Start inside Downloads so beach.jpg and the other files are visible (not only on Desktop). */ + return { + nodes, + viewParentId: "fld-downloads", + visitedFolderIds: ["fld-downloads"], + }; + } case 3: return initialFsSlice( withDesktop([{ id: "fld-docs", name: "Documents", kind: "folder" }]), @@ -86,6 +93,78 @@ export function buildInitialFs(levelIndex: number): FsSlice { } } +function withVisit(fs: FsSlice, folderId: string): FsSlice { + if (fs.visitedFolderIds.includes(folderId)) return fs; + return { ...fs, visitedFolderIds: [...fs.visitedFolderIds, folderId] }; +} + +/** Apply the filesystem effects of having finished sub-step `completedSub` (0-based). */ +function applyCompletedSub( + levelIndex: number, + completedSub: number, + fs: FsSlice, +): FsSlice { + switch (levelIndex) { + case 0: + if (completedSub === 0) return withVisit(fs, "fld-games"); + if (completedSub === 1) return withVisit(fs, "fld-docs"); + return fs; + case 1: { + if (completedSub === 0) { + const nodes = moveFile(fs.nodes, "file-cat", "fld-pics"); + return nodes ? { ...fs, nodes } : fs; + } + if (completedSub === 1) { + const nodes = moveFile(fs.nodes, "file-hw", "fld-school"); + return nodes ? { ...fs, nodes } : fs; + } + return fs; + } + case 2: { + if (completedSub === 0) { + const nodes = moveFile(fs.nodes, "f-beach", "fld-photos"); + if (!nodes) return fs; + return { + ...fs, + nodes, + viewParentId: "fld-downloads", + visitedFolderIds: fs.visitedFolderIds.includes("fld-downloads") + ? fs.visitedFolderIds + : [...fs.visitedFolderIds, "fld-downloads"], + }; + } + if (completedSub === 1) { + const nodes = moveFile(fs.nodes, "f-budget", "fld-work"); + if (!nodes) return fs; + return { + ...fs, + nodes, + viewParentId: "fld-downloads", + visitedFolderIds: fs.visitedFolderIds.includes("fld-downloads") + ? fs.visitedFolderIds + : [...fs.visitedFolderIds, "fld-downloads"], + }; + } + return fs; + } + default: + return fs; + } +} + +/** + * Filesystem at the start of sub-step `subIndex` (all earlier subs applied). + * Used for debug “retry this step” without restarting the whole level. + */ +export function buildFsAtSubStart(levelIndex: number, subIndex: number): FsSlice { + if (subIndex <= 0) return buildInitialFs(levelIndex); + let fs = buildInitialFs(levelIndex); + for (let s = 0; s < subIndex; s++) { + fs = applyCompletedSub(levelIndex, s, fs); + } + return fs; +} + export function freshSave(levelIndex: number): SaveV1 { return { version: 1, @@ -163,6 +242,35 @@ export function isFsLevelComplete(levelIndex: number, fs: FsSlice): boolean { return true; } +/** Folders / files to visually emphasize for the current sub-step. */ +export type HighlightTargets = { + folderIds: readonly string[]; + fileIds: readonly string[]; +}; + +export function highlightTargets( + levelIndex: number, + subIndex: number, +): HighlightTargets { + switch (levelIndex) { + case 0: + if (subIndex === 0) return { folderIds: ["fld-games"], fileIds: [] }; + return { folderIds: ["fld-docs"], fileIds: [] }; + case 1: + if (subIndex === 0) + return { folderIds: ["fld-pics"], fileIds: ["file-cat"] }; + return { folderIds: ["fld-school"], fileIds: ["file-hw"] }; + case 2: + if (subIndex === 0) + return { folderIds: ["fld-photos"], fileIds: ["f-beach"] }; + if (subIndex === 1) + return { folderIds: ["fld-work"], fileIds: ["f-budget"] }; + return { folderIds: ["fld-installers"], fileIds: ["f-game"] }; + default: + return { folderIds: [], fileIds: [] }; + } +} + export const QUIZ_CORRECT_ID = "opt-docs"; export function isQuizComplete(choiceId: string | null) { @@ -190,9 +298,9 @@ export function levelBlurb(levelIndex: number): string { 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."; + return "Drag each file onto the right folder in the list or sidebar."; case 2: - return "Drag one file at a time onto the right folder (or use tap + folder buttons)."; + return "You start inside Downloads. Drag each file onto its folder using Places or by opening Desktop."; case 3: return "Where should you save homework so you can find it later?"; default: @@ -209,12 +317,14 @@ export function subBlurb(levelIndex: number, subIndex: number): string { 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 cat.png onto the Pictures folder."; 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."; + if (subIndex === 0) + return "Drag beach.jpg onto Photos (drop on Photos in Places or the path bar)."; + if (subIndex === 1) + return "Drag budget.xlsx onto Work (Places or path bar)."; + return "Drag game-installer.msi onto Installers (Places or path bar)."; case 3: return levelBlurb(3); default: diff --git a/tests/unit/levels.test.ts b/tests/unit/levels.test.ts index 6ede8f3..0c874b6 100644 --- a/tests/unit/levels.test.ts +++ b/tests/unit/levels.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import { + buildFsAtSubStart, buildInitialFs, isFsLevelComplete, isQuizComplete, isSubComplete, QUIZ_CORRECT_ID, } from "~/lib/folder-game/levels"; +import { DESKTOP_ID } from "~/lib/folder-game/state"; describe("isFsLevelComplete", () => { it("level 0 completes when Games and Documents were opened", () => { @@ -50,3 +52,33 @@ describe("isQuizComplete", () => { expect(isQuizComplete("opt-dl")).toBe(false); }); }); + +describe("buildFsAtSubStart", () => { + it("matches full initial state for sub 0", () => { + expect(buildFsAtSubStart(1, 0)).toEqual(buildInitialFs(1)); + }); + + it("level 1 sub 1 start keeps cat sorted and homework on desktop", () => { + const fs = buildFsAtSubStart(1, 1); + expect(isSubComplete(1, 0, fs)).toBe(true); + expect(isSubComplete(1, 1, fs)).toBe(false); + const nodes = fs.nodes; + const cat = nodes.find((n) => n.id === "file-cat"); + const hw = nodes.find((n) => n.id === "file-hw"); + expect(cat?.parentId).toBe("fld-pics"); + expect(hw?.parentId).toBe(DESKTOP_ID); + }); + + it("level 2 sub 2 start has beach and budget sorted, installer in downloads", () => { + const fs = buildFsAtSubStart(2, 2); + const beach = fs.nodes.find((n) => n.id === "f-beach"); + const budget = fs.nodes.find((n) => n.id === "f-budget"); + const game = fs.nodes.find((n) => n.id === "f-game"); + expect(beach?.parentId).toBe("fld-photos"); + expect(budget?.parentId).toBe("fld-work"); + expect(game?.parentId).toBe("fld-downloads"); + expect(isSubComplete(2, 0, fs)).toBe(true); + expect(isSubComplete(2, 1, fs)).toBe(true); + expect(isSubComplete(2, 2, fs)).toBe(false); + }); +});