diff --git a/cypress/e2e/survey-and-landing-fixes.cy.ts b/cypress/e2e/survey-and-landing-fixes.cy.ts new file mode 100644 index 00000000..c08c241c --- /dev/null +++ b/cypress/e2e/survey-and-landing-fixes.cy.ts @@ -0,0 +1,63 @@ +/** + * Minimal smoke tests for the frontend fixes: + * 1. VariantSwitcher is gone — no "V2 Dark / V3 Editorial / …" bar on the landing page + * 2. Landing page has a direct "Launch" CTA visible in the mobile header + * (users must not need the hamburger menu to reach the game) + * 3. Unauthenticated users are NOT redirected away from "/" (landing stays up) + * 4. "/game" redirects unauthenticated users away (protected route still gated) + */ + +describe("Landing page & survey UX fixes", () => { + beforeEach(() => { + cy.waitForSupabase(); + }); + + // ── 1. No VariantSwitcher ──────────────────────────────────────────────── + it("landing page does not render the variant-switcher nav bar", () => { + cy.visit("/"); + // The VariantSwitcher used an aria-label of "Landing page variant switcher" + cy.get('[aria-label="Landing page variant switcher"]').should("not.exist"); + // Also confirm the variant labels are absent + cy.contains("V2 Dark").should("not.exist"); + cy.contains("V3 Editorial").should("not.exist"); + }); + + // ── 2. Mobile header has a direct "Launch" CTA ─────────────────────────── + it("mobile viewport shows a Launch button in the header without opening the hamburger", () => { + cy.viewport("iphone-x"); + cy.visit("/"); + + // The button text is "Launch →" and should be visible without any interaction + cy.get("header").within(() => { + cy.contains("Launch").should("be.visible"); + }); + + // Confirm it links to /auth + cy.get("header a").contains("Launch").should("have.attr", "href", "/auth"); + }); + + // ── 3. Unauthenticated users can see the landing page ──────────────────── + it("unauthenticated visit to / renders the landing page (not a redirect)", () => { + cy.visit("/"); + cy.url().should("include", "/").and("not.include", "/auth").and("not.include", "/game"); + // Key landing page content should be present + cy.contains("Star Sailors").should("be.visible"); + }); + + // ── 4. /game still protected for unauthenticated users ─────────────────── + it("visiting /game while unauthenticated redirects away from the game hub", () => { + cy.visit("/game", { failOnStatusCode: false }); + cy.url().should("satisfy", (url: string) => + url.includes("/auth") || url.includes("/") && !url.includes("/game") + ); + }); + + // ── 5. Desktop landing has the full nav (Launch Web Client link) ───────── + it("desktop viewport shows Launch Web Client in the header nav", () => { + cy.viewport(1280, 800); + cy.visit("/"); + cy.get("header").within(() => { + cy.contains("Launch Web Client").should("be.visible"); + }); + }); +}); diff --git a/src/app/game/GameClient.tsx b/src/app/game/GameClient.tsx index 9fb2473a..ff06bbb1 100644 --- a/src/app/game/GameClient.tsx +++ b/src/app/game/GameClient.tsx @@ -453,7 +453,7 @@ export default function GameClient({ initialData, user }: GameClientProps) {
{/* ── Left / Main column ── */} -
+
{/* Mission Brief — always above fold */} - {/* Sector Radar — hidden until first deployment */} - {Object.values(radarStations).some(r => r.deployed) ? ( + {/* Sector Radar — only shown once structures are deployed */} + {Object.values(radarStations).some(r => r.deployed) && ( <> - ) : ( -
-

What do you want to do today?

-

Deploy a structure to start scanning your sector.

-
- {[ - { id: "telescope" as const, label: "Hunt planets", accent: "border-teal-500/30 text-teal-300 hover:bg-teal-500/10" }, - { id: "satellite" as const, label: "Track weather", accent: "border-sky-500/30 text-sky-300 hover:bg-sky-500/10" }, - { id: "rover" as const, label: "Train rovers", accent: "border-amber-500/30 text-amber-300 hover:bg-amber-500/10" }, - { id: "solar" as const, label: "Join solar", accent: "border-orange-500/30 text-orange-300 hover:bg-orange-500/10" }, - ].map((option) => ( - - ))} -
- -
)} - {/* Structure cards — primary navigation */} + {/* Structure cards — horizontal scroll on mobile, 4-col grid on sm+ */} -
+
{(["telescope","satellite","rover","solar"] as const).map((id) => { const station = radarStations[id]; const state = structureStates[id]; return ( - handleStructureClick(id)} - /> +
+ handleStructureClick(id)} + /> +
); })}
diff --git a/src/app/page.tsx b/src/app/page.tsx index dc64a234..16fe4fd1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,8 +10,6 @@ import { LandingAnalytics } from "./apt/_components/LandingAnalytics"; import { LandingMobileMenu } from "./apt/_components/LandingMobileMenu"; import { LandingStats, LandingStatsFallback } from "./apt/_components/LandingStats"; -export const experimental_ppr = true; - export const metadata: Metadata = { title: "Star Sailors — Real astronomy. Your kind of game.", description: @@ -117,7 +115,15 @@ export default async function HomePage() {
-
+
+ + Launch → +
diff --git a/src/components/landing/VariantSwitcher.tsx b/src/components/landing/VariantSwitcher.tsx deleted file mode 100644 index d7c6b1ea..00000000 --- a/src/components/landing/VariantSwitcher.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -const variants = [ - { label: "V1 Light", href: "/" }, - { label: "V2 Dark", href: "/apt/v2" }, - { label: "V3 Editorial", href: "/apt/v3" }, - { label: "V4 Cinematic", href: "/apt/v4" }, - { label: "V5 Interactive", href: "/apt/v5" }, - { label: "V6 Warm", href: "/apt/v6" }, -]; - -export function VariantSwitcher() { - const pathname = usePathname(); - - return ( -
- -
- ); -} diff --git a/src/features/surveys/components/GameSurveys.tsx b/src/features/surveys/components/GameSurveys.tsx index ba19c249..a25e82a2 100644 --- a/src/features/surveys/components/GameSurveys.tsx +++ b/src/features/surveys/components/GameSurveys.tsx @@ -4,6 +4,7 @@ import { usePostHog } from "posthog-js/react"; import { useGameSurveys } from "../hooks/useGameSurveys"; import { useProjectEngagementSurveys } from "../hooks/useProjectEngagementSurveys"; import type { ClassificationForSurvey } from "../hooks/useProjectEngagementSurveys"; +import type { ClassificationForMechanicSurvey } from "../hooks/useGameSurveys"; import MechanicPulseSurvey from "./MechanicPulseSurvey"; interface GameSurveysProps { @@ -18,7 +19,7 @@ export function GameSurveys({ userId, classifications = [] }: GameSurveysProps) activeSurvey: activeMechanicSurvey, dismissSurvey: dismissMechanic, completeSurvey: completeMechanic, - } = useGameSurveys(userId); + } = useGameSurveys(userId, classifications as ClassificationForMechanicSurvey[]); const { activeSurvey: activeEngagementSurvey, diff --git a/src/features/surveys/hooks/useGameSurveys.ts b/src/features/surveys/hooks/useGameSurveys.ts index 56121e76..1365327b 100644 --- a/src/features/surveys/hooks/useGameSurveys.ts +++ b/src/features/surveys/hooks/useGameSurveys.ts @@ -5,10 +5,17 @@ import { useSearchParams } from "next/navigation"; import { MECHANIC_SURVEYS, SURVEY_DISPLAY_DELAY_MS, surveyStorageKey } from "../mechanic-surveys"; import type { MechanicMicroSurvey } from "../types"; -export function useGameSurveys(userId?: string) { +export type ClassificationForMechanicSurvey = { + classificationtype?: string | null; +}; + +export function useGameSurveys( + userId?: string, + classifications: ClassificationForMechanicSurvey[] = [] +) { const searchParams = useSearchParams(); const view = searchParams.get("view") || "base"; - + const [activeSurvey, setActiveSurvey] = useState(null); useEffect(() => { @@ -27,15 +34,29 @@ export function useGameSurveys(userId?: string) { "satellite": "mechanic_satellite_loop_v1", "solar": "mechanic_solar_loop_v1", "inventory": "feature_inventory_v1", - "base": "feature_profile_v1" // Just an example mapping + "base": "feature_profile_v1", }; - if (s.id === viewMap[view]) { - // Check if already completed - const key = surveyStorageKey(s.id, userId); - return !localStorage.getItem(key); + if (s.id !== viewMap[view]) return false; + + // Check if already completed or dismissed + const key = surveyStorageKey(s.id, userId); + if (localStorage.getItem(key)) return false; + + // For mechanic surveys, enforce a minimum classification threshold + // so new users are not immediately shown surveys about tools they've never used + if (s.minClassificationsRequired && s.minClassificationsRequired > 0) { + const relevantTypes = s.relevantClassificationTypes; + const count = classifications.filter((c) => { + if (!c.classificationtype) return false; + // If no specific types are defined, count all classifications + if (!relevantTypes || relevantTypes.length === 0) return true; + return relevantTypes.includes(c.classificationtype); + }).length; + if (count < s.minClassificationsRequired) return false; } - return false; + + return true; }); if (candidate) { @@ -44,7 +65,7 @@ export function useGameSurveys(userId?: string) { }, SURVEY_DISPLAY_DELAY_MS); return () => clearTimeout(timer); - }, [userId, view]); + }, [userId, view, classifications]); const dismissSurvey = () => { if (activeSurvey && userId) { diff --git a/src/features/surveys/mechanic-surveys.ts b/src/features/surveys/mechanic-surveys.ts index 162121bc..b107f642 100644 --- a/src/features/surveys/mechanic-surveys.ts +++ b/src/features/surveys/mechanic-surveys.ts @@ -64,6 +64,8 @@ export const MECHANIC_SURVEYS: readonly MechanicMicroSurvey[] = [ title: "Telescope Debrief", subtitle: "How did the detection loop feel this run?", triggerSurface: "game", + minClassificationsRequired: 3, + relevantClassificationTypes: ["planet", "telescope-tess", "telescope-minorPlanet"], questions: [ { id: "pace", @@ -84,6 +86,8 @@ export const MECHANIC_SURVEYS: readonly MechanicMicroSurvey[] = [ title: "Rover Debrief", subtitle: "Quick systems check from your terrain run.", triggerSurface: "game", + minClassificationsRequired: 3, + relevantClassificationTypes: ["rover"], questions: [ { id: "control", @@ -104,6 +108,8 @@ export const MECHANIC_SURVEYS: readonly MechanicMicroSurvey[] = [ title: "Satellite Ops", subtitle: "Feedback on cloud tracking.", triggerSurface: "game", + minClassificationsRequired: 3, + relevantClassificationTypes: ["cloud", "lidar-jovianVortexHunter"], questions: [ { id: "ui", @@ -118,6 +124,8 @@ export const MECHANIC_SURVEYS: readonly MechanicMicroSurvey[] = [ title: "Solar Array", subtitle: "Energy collection efficiency.", triggerSurface: "game", + minClassificationsRequired: 3, + relevantClassificationTypes: ["telescope-sunspot"], questions: [ { id: "complexity", diff --git a/src/features/surveys/types.ts b/src/features/surveys/types.ts index 14092885..123817ef 100644 --- a/src/features/surveys/types.ts +++ b/src/features/surveys/types.ts @@ -11,6 +11,10 @@ export type MechanicMicroSurvey = { subtitle: string; triggerSurface: "game" | "ecosystem"; questions: readonly [MicroSurveyQuestion, MicroSurveyQuestion?]; + /** Minimum number of relevant classifications before this survey is shown. */ + minClassificationsRequired?: number; + /** Which classification types count toward minClassificationsRequired. */ + relevantClassificationTypes?: readonly string[]; }; export type ProjectType = diff --git a/src/middleware.ts b/src/middleware.ts index 9102eaf4..f323523f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -11,10 +11,21 @@ function hasSupabaseAuthCookie(req: NextRequest) { } export function middleware(req: NextRequest) { - if (!hasSupabaseAuthCookie(req) && req.nextUrl.pathname.startsWith("/game")) { + const isAuthenticated = hasSupabaseAuthCookie(req); + const { pathname } = req.nextUrl; + + // Authenticated users visiting the landing page → send straight to the game. + // Cookie presence is sufficient here: if the access token is expired but the + // refresh token is still valid, /game will resolve correctly. If both are + // expired the game's own auth guard will redirect to /auth. + if (isAuthenticated && pathname === "/") { + return NextResponse.redirect(new URL("/game?from=landing", req.url)); + } + + // Unauthenticated users trying to access the game → send to login. + if (!isAuthenticated && pathname.startsWith("/game")) { const loginUrl = new URL("/auth", req.url); - const nextPath = `${req.nextUrl.pathname}${req.nextUrl.search}`; - loginUrl.searchParams.set("next", nextPath); + loginUrl.searchParams.set("next", `${pathname}${req.nextUrl.search}`); return NextResponse.redirect(loginUrl); } @@ -22,5 +33,6 @@ export function middleware(req: NextRequest) { } export const config = { - matcher: ["/game/:path*"], + // Run on the landing page and all game routes. + matcher: ["/", "/game/:path*"], }; diff --git a/tests/unit/components/landing/VariantSwitcher.test.tsx b/tests/unit/components/landing/VariantSwitcher.test.tsx deleted file mode 100644 index 20754f57..00000000 --- a/tests/unit/components/landing/VariantSwitcher.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; - -vi.mock("next/navigation", () => ({ - usePathname: vi.fn().mockReturnValue("/"), - useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn() })), - useSearchParams: vi.fn(() => ({ get: vi.fn() })), -})); - -import { VariantSwitcher } from "@/src/components/landing/VariantSwitcher"; - -describe("VariantSwitcher", () => { - it("renders all 6 variant links", () => { - render(); - expect(screen.getByText("V1 Light")).toBeInTheDocument(); - expect(screen.getByText("V2 Dark")).toBeInTheDocument(); - expect(screen.getByText("V3 Editorial")).toBeInTheDocument(); - expect(screen.getByText("V4 Cinematic")).toBeInTheDocument(); - expect(screen.getByText("V5 Interactive")).toBeInTheDocument(); - expect(screen.getByText("V6 Warm")).toBeInTheDocument(); - }); - - it("each link has the correct href", () => { - render(); - expect(screen.getByRole("link", { name: "V1 Light" })).toHaveAttribute("href", "/"); - expect(screen.getByRole("link", { name: "V2 Dark" })).toHaveAttribute("href", "/apt/v2"); - expect(screen.getByRole("link", { name: "V6 Warm" })).toHaveAttribute("href", "/apt/v6"); - }); - - it("renders a nav with accessible label", () => { - render(); - expect( - screen.getByRole("navigation", { name: "Landing page variant switcher" }) - ).toBeInTheDocument(); - }); - - it("active link has aria-current=page when pathname matches", () => { - // The global mock makes usePathname return "/" - render(); - expect(screen.getByRole("link", { name: "V1 Light" })).toHaveAttribute("aria-current", "page"); - }); -}); diff --git a/tests/unit/features/surveys/GameSurveys-integration.test.tsx b/tests/unit/features/surveys/GameSurveys-integration.test.tsx new file mode 100644 index 00000000..41b4e19e --- /dev/null +++ b/tests/unit/features/surveys/GameSurveys-integration.test.tsx @@ -0,0 +1,95 @@ +/** + * Integration tests for the GameSurveys component verifying that: + * 1. No survey is rendered when the user has no classifications (new user) + * 2. A mechanic survey renders once classifications exceed the threshold + * 3. The component renders null for unauthenticated sessions + * + * Classifications must be stable constant references — inline array literals + * would create new references on each re-render and clear the survey state. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; + +vi.mock("posthog-js/react", () => ({ + usePostHog: () => ({ capture: vi.fn() }), +})); + +vi.mock("next/navigation", () => ({ + useSearchParams: vi.fn(() => ({ get: vi.fn(() => null) })), + useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn() })), +})); + +vi.mock("@/features/surveys/components/MechanicPulseSurvey", () => ({ + default: ({ survey }: { survey: { id: string } }) => ( +
+ ), +})); + +import { GameSurveys } from "@/features/surveys/components/GameSurveys"; + +// Stable classification fixtures (must not be inline literals in renderHook/render props) +const NO_CLASSIFICATIONS: { classificationtype: string | null }[] = []; +const TELESCOPE_2 = [ + { classificationtype: "telescope-tess" }, + { classificationtype: "planet" }, +]; +const TELESCOPE_3 = [ + { classificationtype: "telescope-tess" }, + { classificationtype: "planet" }, + { classificationtype: "telescope-tess" }, +]; + +async function setView(view: string) { + const { useSearchParams } = await import("next/navigation"); + vi.mocked(useSearchParams).mockReturnValue({ get: () => view } as any); +} + +describe("GameSurveys component — classification gate integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders nothing when userId is undefined", async () => { + await setView("telescope"); + const { container } = render( + + ); + act(() => { vi.runAllTimers(); }); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for a brand-new user with no classifications", async () => { + await setView("telescope"); + const { container } = render( + + ); + act(() => { vi.runAllTimers(); }); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when below the classification threshold (2 of 3)", async () => { + await setView("telescope"); + const { container } = render( + + ); + act(() => { vi.runAllTimers(); }); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders a survey once the classification threshold is met (3 of 3)", async () => { + await setView("telescope"); + render(); + act(() => { vi.runAllTimers(); }); + expect(screen.getByTestId("mechanic-pulse-survey")).toBeInTheDocument(); + expect(screen.getByTestId("mechanic-pulse-survey")).toHaveAttribute( + "data-survey-id", + "mechanic_telescope_loop_v1" + ); + }); +}); diff --git a/tests/unit/features/surveys/mechanic-surveys-min-count.test.ts b/tests/unit/features/surveys/mechanic-surveys-min-count.test.ts new file mode 100644 index 00000000..b9bd947d --- /dev/null +++ b/tests/unit/features/surveys/mechanic-surveys-min-count.test.ts @@ -0,0 +1,66 @@ +/** + * Tests that mechanic loop surveys have the new gating fields so they + * are not shown to brand-new users who haven't used those mechanics yet. + */ +import { describe, it, expect } from "vitest"; +import { MECHANIC_SURVEYS } from "@/features/surveys/mechanic-surveys"; + +const MECHANIC_LOOP_IDS = [ + "mechanic_telescope_loop_v1", + "mechanic_rover_loop_v1", + "mechanic_satellite_loop_v1", + "mechanic_solar_loop_v1", +] as const; + +describe("mechanic loop surveys — gating fields", () => { + for (const id of MECHANIC_LOOP_IDS) { + const survey = MECHANIC_SURVEYS.find((s) => s.id === id); + + it(`${id} exists in MECHANIC_SURVEYS`, () => { + expect(survey).toBeDefined(); + }); + + it(`${id} has minClassificationsRequired >= 1`, () => { + expect(survey?.minClassificationsRequired).toBeDefined(); + expect(survey!.minClassificationsRequired).toBeGreaterThanOrEqual(1); + }); + + it(`${id} has at least one relevantClassificationType`, () => { + expect(survey?.relevantClassificationTypes).toBeDefined(); + expect(survey!.relevantClassificationTypes!.length).toBeGreaterThan(0); + }); + } + + it("non-mechanic surveys are NOT gated (no minClassificationsRequired)", () => { + const ungated = ["trigger_first_classification_v1", "feature_inventory_v1", "feature_profile_v1"]; + for (const id of ungated) { + const survey = MECHANIC_SURVEYS.find((s) => s.id === id); + // These surveys may or may not have minClassificationsRequired — + // the important thing is that mechanic loop surveys DO have it. + // We just assert the survey exists. + expect(survey).toBeDefined(); + } + }); + + it("telescope survey counts telescope-tess and planet classifications", () => { + const survey = MECHANIC_SURVEYS.find((s) => s.id === "mechanic_telescope_loop_v1"); + expect(survey!.relevantClassificationTypes).toContain("telescope-tess"); + expect(survey!.relevantClassificationTypes).toContain("planet"); + }); + + it("rover survey counts rover classifications", () => { + const survey = MECHANIC_SURVEYS.find((s) => s.id === "mechanic_rover_loop_v1"); + expect(survey!.relevantClassificationTypes).toContain("rover"); + }); + + it("satellite survey counts cloud and lidar classifications", () => { + const survey = MECHANIC_SURVEYS.find((s) => s.id === "mechanic_satellite_loop_v1"); + expect(survey!.relevantClassificationTypes).toContain("cloud"); + expect(survey!.relevantClassificationTypes).toContain("lidar-jovianVortexHunter"); + }); + + it("solar survey counts telescope-sunspot classifications", () => { + const survey = MECHANIC_SURVEYS.find((s) => s.id === "mechanic_solar_loop_v1"); + expect(survey!.relevantClassificationTypes).toContain("telescope-sunspot"); + }); +}); diff --git a/tests/unit/features/surveys/useGameSurveys-classification-gate.test.ts b/tests/unit/features/surveys/useGameSurveys-classification-gate.test.ts new file mode 100644 index 00000000..3a315323 --- /dev/null +++ b/tests/unit/features/surveys/useGameSurveys-classification-gate.test.ts @@ -0,0 +1,192 @@ +/** + * Focused tests for the classification-count gate added to useGameSurveys. + * Verifies that mechanic surveys are suppressed for new users and only + * fire once the required classification threshold is met. + * + * NOTE: classifications must be stable references (constants defined outside + * the renderHook factory) so the useEffect dependency array does not change + * on each re-render — which would clear the survey back to null. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +vi.mock("next/navigation", () => ({ + useSearchParams: vi.fn(() => ({ get: vi.fn(() => null) })), +})); + +import { useGameSurveys } from "@/features/surveys/hooks/useGameSurveys"; +import { surveyStorageKey } from "@/features/surveys/mechanic-surveys"; + +// Stable classification fixtures +const NO_CLASSIFICATIONS: { classificationtype: string | null | undefined }[] = []; + +const TELESCOPE_2 = [ + { classificationtype: "telescope-tess" }, + { classificationtype: "planet" }, +]; // below threshold (min = 3) + +const TELESCOPE_3 = [ + { classificationtype: "telescope-tess" }, + { classificationtype: "planet" }, + { classificationtype: "telescope-tess" }, +]; // meets threshold + +const ROVER_3 = [ + { classificationtype: "rover" }, + { classificationtype: "rover" }, + { classificationtype: "rover" }, +]; + +const SATELLITE_3 = [ + { classificationtype: "cloud" }, + { classificationtype: "lidar-jovianVortexHunter" }, + { classificationtype: "cloud" }, +]; + +const SOLAR_3 = [ + { classificationtype: "telescope-sunspot" }, + { classificationtype: "telescope-sunspot" }, + { classificationtype: "telescope-sunspot" }, +]; + +const ROVER_3_WRONG_TYPE = [ + // 3 rover classifications — irrelevant for the telescope survey + { classificationtype: "rover" }, + { classificationtype: "rover" }, + { classificationtype: "rover" }, +]; + +const WITH_NULLS = [ + { classificationtype: null }, + { classificationtype: undefined }, + { classificationtype: "telescope-tess" }, // only 1 relevant +]; + +// Helper — sets useSearchParams to return the given view +async function mockView(view: string) { + const { useSearchParams } = await import("next/navigation"); + vi.mocked(useSearchParams).mockReturnValue({ get: () => view } as any); +} + +describe("useGameSurveys — classification gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ── Telescope ────────────────────────────────────────────────────────────── + + it("suppresses telescope survey for a brand-new user (0 classifications)", async () => { + await mockView("telescope"); + const { result } = renderHook(() => useGameSurveys("new-user", NO_CLASSIFICATIONS)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); + + it("suppresses telescope survey when count is below threshold (2 of 3)", async () => { + await mockView("telescope"); + const { result } = renderHook(() => useGameSurveys("user-1", TELESCOPE_2)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); + + it("shows telescope survey once threshold is reached (3 classifications)", async () => { + await mockView("telescope"); + const { result } = renderHook(() => useGameSurveys("user-1", TELESCOPE_3)); + expect(result.current.activeSurvey).toBeNull(); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey?.id).toBe("mechanic_telescope_loop_v1"); + }); + + it("ignores irrelevant classification types for telescope threshold", async () => { + await mockView("telescope"); + // 3 rover classifications — don't count toward telescope threshold + const { result } = renderHook(() => useGameSurveys("user-1", ROVER_3_WRONG_TYPE)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); + + // ── Rover ────────────────────────────────────────────────────────────────── + + it("suppresses rover survey for a brand-new user", async () => { + await mockView("rover"); + const { result } = renderHook(() => useGameSurveys("new-user", NO_CLASSIFICATIONS)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); + + it("shows rover survey once threshold is reached", async () => { + await mockView("rover"); + const { result } = renderHook(() => useGameSurveys("user-1", ROVER_3)); + expect(result.current.activeSurvey).toBeNull(); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey?.id).toBe("mechanic_rover_loop_v1"); + }); + + // ── Satellite ────────────────────────────────────────────────────────────── + + it("suppresses satellite survey for a brand-new user", async () => { + await mockView("satellite"); + const { result } = renderHook(() => useGameSurveys("new-user", NO_CLASSIFICATIONS)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); + + it("shows satellite survey once threshold is reached", async () => { + await mockView("satellite"); + const { result } = renderHook(() => useGameSurveys("user-1", SATELLITE_3)); + expect(result.current.activeSurvey).toBeNull(); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey?.id).toBe("mechanic_satellite_loop_v1"); + }); + + // ── Solar ────────────────────────────────────────────────────────────────── + + it("suppresses solar survey for a brand-new user", async () => { + await mockView("solar"); + const { result } = renderHook(() => useGameSurveys("new-user", NO_CLASSIFICATIONS)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); + + it("shows solar survey once threshold is reached", async () => { + await mockView("solar"); + const { result } = renderHook(() => useGameSurveys("user-1", SOLAR_3)); + expect(result.current.activeSurvey).toBeNull(); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey?.id).toBe("mechanic_solar_loop_v1"); + }); + + // ── null / undefined classificationtype ─────────────────────────────────── + + it("handles null/undefined classificationtype gracefully", async () => { + await mockView("telescope"); + // Only 1 relevant entry — still below threshold of 3 + const { result } = renderHook(() => useGameSurveys("user-1", WITH_NULLS)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); + + // ── localStorage persistence ─────────────────────────────────────────────── + + it("does not re-show survey after dismiss even if classifications exceed threshold", async () => { + await mockView("telescope"); + localStorage.setItem(surveyStorageKey("mechanic_telescope_loop_v1", "user-1"), "dismissed"); + const { result } = renderHook(() => useGameSurveys("user-1", TELESCOPE_3)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); + + it("does not re-show survey after complete even if classifications exceed threshold", async () => { + await mockView("rover"); + localStorage.setItem(surveyStorageKey("mechanic_rover_loop_v1", "user-1"), "completed"); + const { result } = renderHook(() => useGameSurveys("user-1", ROVER_3)); + act(() => { vi.runAllTimers(); }); + expect(result.current.activeSurvey).toBeNull(); + }); +}); diff --git a/tests/unit/features/surveys/useGameSurveys.test.ts b/tests/unit/features/surveys/useGameSurveys.test.ts index 9c38fa90..1e67665e 100644 --- a/tests/unit/features/surveys/useGameSurveys.test.ts +++ b/tests/unit/features/surveys/useGameSurveys.test.ts @@ -8,6 +8,12 @@ vi.mock("next/navigation", () => ({ import { useGameSurveys } from "@/features/surveys/hooks/useGameSurveys"; import { surveyStorageKey } from "@/features/surveys/mechanic-surveys"; +const TELESCOPE_CLASSIFICATIONS = [ + { classificationtype: "telescope-tess" }, + { classificationtype: "telescope-tess" }, + { classificationtype: "planet" }, +]; + describe("useGameSurveys", () => { beforeEach(() => { vi.clearAllMocks(); @@ -55,7 +61,9 @@ describe("useGameSurveys", () => { const key = surveyStorageKey("mechanic_telescope_loop_v1", "user-1"); localStorage.setItem(key, "completed"); - const { result } = renderHook(() => useGameSurveys("user-1")); + const { result } = renderHook(() => + useGameSurveys("user-1", TELESCOPE_CLASSIFICATIONS) + ); act(() => { vi.runAllTimers(); }); expect(result.current.activeSurvey).toBeNull(); @@ -68,21 +76,48 @@ describe("useGameSurveys", () => { const key = surveyStorageKey("mechanic_rover_loop_v1", "user-1"); localStorage.setItem(key, "dismissed"); - const { result } = renderHook(() => useGameSurveys("user-1")); + const { result } = renderHook(() => + useGameSurveys("user-1", [{ classificationtype: "rover" }, { classificationtype: "rover" }, { classificationtype: "rover" }]) + ); act(() => { vi.runAllTimers(); }); expect(result.current.activeSurvey).toBeNull(); }); - it("shows survey after delay when view matches and not yet completed", async () => { + it("does not show mechanic survey when user has too few relevant classifications", async () => { const { useSearchParams } = await import("next/navigation"); vi.mocked(useSearchParams).mockReturnValue({ get: () => "telescope" } as any); - const { result } = renderHook(() => useGameSurveys("user-1")); + // Only 1 classification — below the 3 minimum + const { result } = renderHook(() => + useGameSurveys("user-1", [{ classificationtype: "telescope-tess" }]) + ); + act(() => { vi.runAllTimers(); }); + + expect(result.current.activeSurvey).toBeNull(); + }); + + it("shows survey after delay when view matches and user has enough classifications", async () => { + const { useSearchParams } = await import("next/navigation"); + vi.mocked(useSearchParams).mockReturnValue({ get: () => "telescope" } as any); + + const { result } = renderHook(() => + useGameSurveys("user-1", TELESCOPE_CLASSIFICATIONS) + ); expect(result.current.activeSurvey).toBeNull(); act(() => { vi.runAllTimers(); }); expect(result.current.activeSurvey?.id).toBe("mechanic_telescope_loop_v1"); }); + + it("does not show telescope survey with no classifications at all", async () => { + const { useSearchParams } = await import("next/navigation"); + vi.mocked(useSearchParams).mockReturnValue({ get: () => "telescope" } as any); + + const { result } = renderHook(() => useGameSurveys("user-1", [])); + act(() => { vi.runAllTimers(); }); + + expect(result.current.activeSurvey).toBeNull(); + }); }); diff --git a/tests/unit/middleware.test.ts b/tests/unit/middleware.test.ts index 7ec0b8f0..63c125e3 100644 --- a/tests/unit/middleware.test.ts +++ b/tests/unit/middleware.test.ts @@ -81,4 +81,33 @@ describe("middleware", () => { expect(mockRedirect).not.toHaveBeenCalled(); expect(mockNext).toHaveBeenCalled(); }); + + // ── Landing page (/) redirect for authenticated users ──────────────────── + + it("redirects authenticated user from '/' to '/game?from=landing'", () => { + const req = makeRequest("/", "http://localhost:3000", [ + { name: "sb-abc-auth-token", value: "token" }, + ]); + middleware(req); + expect(mockRedirect).toHaveBeenCalledWith( + expect.objectContaining({ pathname: "/game", search: "?from=landing" }) + ); + }); + + it("passes through unauthenticated user on '/' (shows landing page)", () => { + const req = makeRequest("/", "http://localhost:3000", []); + middleware(req); + expect(mockRedirect).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + + it("redirects authenticated user on '/' with chunked cookie to '/game?from=landing'", () => { + const req = makeRequest("/", "http://localhost:3000", [ + { name: "sb-abc-auth-token.0", value: "chunk" }, + ]); + middleware(req); + expect(mockRedirect).toHaveBeenCalledWith( + expect.objectContaining({ pathname: "/game", search: "?from=landing" }) + ); + }); });