mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-14 02:16:41 +00:00
feat(game): put the region in the URL path
/game?region=TPHCM becomes /game/tphcm, backed by an app/game/[region] dynamic segment. The region is a property of the page, so it belongs in the path: the URL is now shareable and honest, an unknown region is a real 404 instead of silently rendering as "Vietnam", and the page no longer needs useSearchParams or its Suspense boundary. It also makes per-region traffic visible. Vercel's Pages dimension strips query parameters on every plan, so ?region= was invisible; one row per region falls out of correct routing. Slugs are lowercase, and regionSlug/regionFromSlug in lib/regions.js own the conversion. Three call sites build these URLs -- the picker, generateStaticParams, and the legacy redirect -- and a casing mismatch between any two would split one region across two rows, which is the whole point of the move. An unusual casing renders rather than redirecting to the canonical form. A redirect on a prerendered route gets cached as that route's response: on a case-insensitive filesystem the ISR cache folds /game/DNA onto /game/dna, and a cached redirect that has lost its Location header then answers the canonical URL with a 307 to nowhere for the life of the process. Reproduced on Windows against next start; no redirect on the route means no such mechanism anywhere. Legacy ?region= and ?location= redirect from app/game/page.js rather than next.config.mjs, because a config redirect forwards the source query to the destination and would park ?region= on /game/tphcm permanently. The legacy value is encodeURIComponent'd: it reaches a Location header unvalidated, where a raw CRLF makes Node throw a 500 instead of the honest 404, and a raw ../ would be normalised onto another path. A region-less /game now plays the country rather than defaulting to TPHCM, which was never a stated default -- just where the old query chain happened to end. API routes keep their query params. They are fetch calls, not navigations, so a path segment buys nothing there. Also repairs four specs that were already failing on main: three asserted that the landing page shows no username prompt, which the landing-prompt change contradicted, and one expected the build sha and its copy button to be a single element, which splitting them contradicted.
This commit is contained in:
1 parent
90ebed728a
commit
711ef863a6
14 files changed
+409
-71
No files matched your search
+3
-2
@@ -98,8 +98,9 @@ The UI has a Playwright smoke lane: `npm run test:e2e` runs `tests/e2e/*.spec.js
|
||||
in Chromium against a dev server it starts (or reuses) itself. Every `/api/*`
|
||||
call and the panorama image are stubbed at the browser boundary
|
||||
(`tests/e2e/helpers.js`), so it needs no Redis, Neon, Mapillary, or `.env`.
|
||||
It covers the region picker, the username modal, and one full round to the
|
||||
reveal. Anything beyond those flows (real panoramas, real scoring round-trips)
|
||||
It covers the region picker, the username modal, one full round to the
|
||||
reveal, and the `/game/{slug}` routing contract (legacy `?region=` redirects,
|
||||
unknown-region 404s). Anything beyond those flows (real panoramas, real scoring round-trips)
|
||||
is still manual: inform the user when work is complete. Do NOT start
|
||||
development servers - user handles manual testing themselves.
|
||||
|
||||
|
||||
@@ -23,6 +23,26 @@
|
||||
- Regions with no usable panoramas are listed but disabled, with the reason
|
||||
shown. See the Coverage note in [project-overview.md](project-overview.md)
|
||||
|
||||
#### The game URL
|
||||
|
||||
The region is a lowercase path segment: `/game/tphcm`, `/game/vn`,
|
||||
`/game/hn-badinh`. Codes are uppercase inside the app (they key the region
|
||||
tree); `regionSlug` / `regionFromSlug` in `src/lib/regions.js` own the
|
||||
conversion, and are the only place the URL casing convention lives.
|
||||
|
||||
- An unknown region is a 404. A real region with no imagery is not — it renders
|
||||
and shows the coverage message, which is a better answer than a 404
|
||||
- An uppercase URL plays rather than redirecting. There is deliberately no
|
||||
canonical-casing redirect: a redirect on a prerendered route gets cached as
|
||||
that route's response, and nothing in the app generates an uppercase link
|
||||
- `/game` with no region plays the whole country
|
||||
- `?region=` and `?location=` still work; they redirect to `/game/{slug}` and
|
||||
are kept for links and bookmarks already in the wild
|
||||
- **API routes deliberately keep their query params.** `/api/new-game?region=`,
|
||||
`/api/leaderboard?region=` and the debug routes are unchanged and should stay
|
||||
that way — they are fetch calls, not navigations, so there is nothing for a
|
||||
path segment to buy there
|
||||
|
||||
### 3. Session Creation
|
||||
- Server generates unique UUID v4 session ID
|
||||
- Server stores the exact target location **and the district the panorama sits
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
- `CLAUDE.md` - Project instructions and guidelines for Claude Code
|
||||
- `components.json` - shadcn/ui configuration
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `next.config.mjs` - Next.js configuration
|
||||
- `next.config.mjs` - Next.js configuration. Deliberately has no `redirects()`:
|
||||
a config redirect forwards the source query string to the destination, so the
|
||||
legacy `/game?region=` links are redirected from `src/app/game/page.js` instead
|
||||
- `eslint.config.mjs` - ESLint configuration
|
||||
- `postcss.config.mjs` - PostCSS configuration
|
||||
- `jsconfig.json` - JavaScript project configuration
|
||||
@@ -26,7 +28,12 @@ Next.js 16 App Router structure:
|
||||
- `favicon.ico` - Site favicon
|
||||
|
||||
#### Game Pages
|
||||
- `game/page.js` - Main game interface
|
||||
- `game/[region]/page.js` - The game screen for one region (`/game/tphcm`).
|
||||
Server Component: validates the slug, prerenders one page per region,
|
||||
404s an unknown one
|
||||
- `game/[region]/not-found.js` - The 404 for an unknown region code
|
||||
- `game/page.js` - Redirects the legacy `?region=` / `?location=` links to
|
||||
`/game/{slug}`; a region-less `/game` goes to the country round
|
||||
- `credits/page.js` - Data sources, licenses, and open-source credits
|
||||
- `debug/page.js` - Debug hub: lists every debug tool as a peer
|
||||
- `debug/layout.js` - Shared shell for all debug pages: app bar, DebugNav, theme
|
||||
@@ -84,7 +91,7 @@ the shadcn CLI when a screen needs them, rather than keeping unused ones around.
|
||||
Both directories are build output. Do not hand-edit; see *Rebuilding the
|
||||
generated region data* in [development.md](development.md).
|
||||
|
||||
- `regions/index.js` - The 67-node tree: code, name, parent, level, children,
|
||||
- `regions/index.js` - The 85-node tree: code, name, parent, level, children,
|
||||
center, bbox, and coverage flags
|
||||
- `regions/counts.js` - Per-region panorama and cell tallies. **The one
|
||||
panorama-derived file a client component may import** - it carries counts
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Beer } from 'lucide-react';
|
||||
import PanoramaViewer from './PanoramaViewer';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
@@ -39,11 +39,18 @@ async function fetchNewRound(locationCode, currentSessionId) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function GameClient() {
|
||||
const searchParams = useSearchParams();
|
||||
/**
|
||||
* The game screen.
|
||||
*
|
||||
* The region arrives as a prop from the /game/[region] page, which has already
|
||||
* resolved and validated it -- so there is no query string to read and nothing
|
||||
* to suspend on.
|
||||
* @param {Object} props
|
||||
* @param {string} props.region Validated, canonical-case region code to play.
|
||||
*/
|
||||
export default function GameClient({ region }) {
|
||||
const router = useRouter();
|
||||
|
||||
const [location, setLocation] = useState('TPHCM');
|
||||
const [imageData, setImageData] = useState(null);
|
||||
// Bumped once per applied round and used as the viewer's key. The image URL
|
||||
// is not enough: a small district can serve the same panorama twice in a
|
||||
@@ -83,14 +90,11 @@ export default function GameClient() {
|
||||
// ignores this flag entirely.
|
||||
const [mapExpanded, setMapExpanded] = useState(false);
|
||||
|
||||
// What the player picked, resolved through the tree. A bookmarked
|
||||
// ?location=DL still works: DL is a district of Lam Dong now, and every node
|
||||
// keeps an entry.
|
||||
const pickedCode = location.toUpperCase();
|
||||
const pickedRegion = isRegion(pickedCode) ? getRegion(pickedCode) : null;
|
||||
// Never echo the raw query string: an unknown value would render as the
|
||||
// page's own label, and the API uppercases before resolving, so ?region=hn
|
||||
// would otherwise show 'hn' while serving a Ha Noi round.
|
||||
// What the player picked, resolved through the tree. The page validated the
|
||||
// code before rendering, so the isRegion guard is belt-and-braces rather
|
||||
// than the load-bearing check it used to be when the code came from a query
|
||||
// string this component read itself.
|
||||
const pickedRegion = isRegion(region) ? getRegion(region) : null;
|
||||
const regionName = pickedRegion ? pickedRegion.name : 'Vietnam';
|
||||
|
||||
const initializingRef = useRef(false);
|
||||
@@ -166,14 +170,10 @@ export default function GameClient() {
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
// ?region= is the current form; ?location= is what existing links carry.
|
||||
const locationParam =
|
||||
searchParams.get('region') || searchParams.get('location') || 'TPHCM';
|
||||
setLocation(locationParam);
|
||||
const existingUsername = getUsername();
|
||||
setUsernameState(existingUsername || '');
|
||||
loadLibrariesAndInitialize(locationParam);
|
||||
}, [searchParams, loadLibrariesAndInitialize, initialized]);
|
||||
loadLibrariesAndInitialize(region);
|
||||
}, [region, loadLibrariesAndInitialize, initialized]);
|
||||
|
||||
const submitGameResult = async (guessCoords) => {
|
||||
if (!guessCoords || !sessionId) return null;
|
||||
@@ -293,7 +293,7 @@ export default function GameClient() {
|
||||
|
||||
setSubmitting(false);
|
||||
setShowResult(true);
|
||||
startPrefetch(location, currentSession);
|
||||
startPrefetch(region, currentSession);
|
||||
};
|
||||
|
||||
// Everything a round accumulates. Both Next Round and Skip come through here,
|
||||
@@ -334,7 +334,7 @@ export default function GameClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await loadRound(location, currentSession, epoch);
|
||||
const loaded = await loadRound(region, currentSession, epoch);
|
||||
if (!loaded) setRoundLoading(false);
|
||||
};
|
||||
|
||||
@@ -362,7 +362,7 @@ export default function GameClient() {
|
||||
// suppress the spinner and read as a hang while the new round loads.
|
||||
setLoadError(null);
|
||||
setRoundLoading(true);
|
||||
const loaded = await loadRound(location, currentSession, roundEpochRef.current);
|
||||
const loaded = await loadRound(region, currentSession, roundEpochRef.current);
|
||||
if (!loaded) setRoundLoading(false);
|
||||
};
|
||||
|
||||
@@ -370,7 +370,7 @@ export default function GameClient() {
|
||||
setLoadError(null);
|
||||
roundEpochRef.current += 1;
|
||||
setRoundLoading(true);
|
||||
const loaded = await loadRound(location, null, roundEpochRef.current);
|
||||
const loaded = await loadRound(region, null, roundEpochRef.current);
|
||||
if (!loaded) setRoundLoading(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isThin,
|
||||
isUnresolved,
|
||||
provinces,
|
||||
regionSlug,
|
||||
} from '../../lib/regions';
|
||||
|
||||
/** Thousands separators, so 225,966 reads as a quantity rather than a code. */
|
||||
@@ -49,7 +50,7 @@ function unavailableLabel(code) {
|
||||
*/
|
||||
function PlayRow({ code, label, emphasis, onPlayClick }) {
|
||||
const { panos } = coverageOf(code);
|
||||
const href = `/game?region=${code}`;
|
||||
const href = `/game/${regionSlug(code)}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Reached when the [region] page rejects a code that is not in the tree --
|
||||
// a typo'd or truncated shared link, mostly. Next's stock 404 is a dead end,
|
||||
// and the one thing this visitor needs is the way to a region that does
|
||||
// exist, so the page is mostly that link.
|
||||
//
|
||||
// Deliberately says nothing about which codes are valid: there are 85 of them
|
||||
// and the picker is a better answer than a list.
|
||||
export default function RegionNotFound() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center vn-surface p-6">
|
||||
<div className="text-center space-y-4 max-w-sm animate-fade-in-up">
|
||||
{/* A real heading, not a styled <p>: a not-found page owns the whole
|
||||
document, unlike GameClient's in-place error panel, which sits
|
||||
inside a page that already has its own h1. */}
|
||||
<h1 className="text-2xl font-bold text-foreground">No such region</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
That link points at a region that doesn't exist. It may have been
|
||||
mistyped or cut short.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/">Pick a region</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import GameClient from '../../components/GameClient';
|
||||
import { allRegions, regionFromSlug, regionSlug } from '../../../lib/regions';
|
||||
|
||||
// Every region gets a prerendered page. The region tree is the only data this
|
||||
// needs -- never pano-index.js or pano-db.js, which reach the exact panorama
|
||||
// coordinates. See the boundary note at the top of lib/regions.js.
|
||||
//
|
||||
// Deliberately allRegions() and not playableRegions(): a real region with no
|
||||
// imagery still renders, so the player gets the coverage message the API
|
||||
// already writes ("... has no street view coverage yet") instead of a 404,
|
||||
// which would be a worse answer than the one already there.
|
||||
export async function generateStaticParams() {
|
||||
return allRegions().map((code) => ({ region: regionSlug(code) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The game screen for one region.
|
||||
*
|
||||
* Validating here rather than in GameClient is what turns a typo'd URL into a
|
||||
* real 404. Without it an unknown code renders under the page's own fallback
|
||||
* label ('Vietnam') while the API resolves whatever it was handed.
|
||||
*
|
||||
* An unusual casing renders rather than redirecting to the canonical one --
|
||||
* see regionFromSlug for why.
|
||||
*/
|
||||
export default async function GameRegionPage({ params }) {
|
||||
// params is a Promise in Next 16; destructuring it synchronously is the
|
||||
// pre-16 shape and yields undefined here.
|
||||
const { region } = await params;
|
||||
const code = regionFromSlug(region);
|
||||
|
||||
if (!code) notFound();
|
||||
|
||||
// Keyed by region so a region-to-region navigation remounts rather than
|
||||
// handing the running game a new prop. GameClient initialises once behind an
|
||||
// `initialized` guard, so a prop change alone would leave the previous
|
||||
// region's round on screen. Nothing navigates that way today -- every path
|
||||
// out goes through the menu -- but a path segment makes prop-change-without-
|
||||
// unmount the normal App Router pattern, and this is one word.
|
||||
return <GameClient key={code} region={code} />;
|
||||
}
|
||||
+48
-19
@@ -1,23 +1,52 @@
|
||||
"use client";
|
||||
import { redirect } from 'next/navigation';
|
||||
import { COUNTRY_CODE, regionSlug } from '../../lib/regions';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import GameClient from '../components/GameClient';
|
||||
|
||||
function GameLoadingFallback() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center vn-surface">
|
||||
<div role="status" aria-live="polite" className="text-center space-y-4 animate-fade-in-up">
|
||||
<div className="w-12 h-12 border-4 border-border border-t-brand rounded-full animate-spin mx-auto" aria-hidden="true" />
|
||||
<p className="text-foreground text-lg font-medium">Loading game...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
/**
|
||||
* One-line value read from a query string.
|
||||
*
|
||||
* URLSearchParams hands back an array when a key repeats, and an empty string
|
||||
* for a present-but-empty key. Both must read as "no value" so ?region= falls
|
||||
* through to the country rather than redirecting to a broken `/game/`.
|
||||
* @param {string|string[]|undefined} value Raw search-param value.
|
||||
* @returns {string} The value, or '' when there is not a usable one.
|
||||
*/
|
||||
function firstValue(value) {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
return typeof raw === 'string' ? raw : '';
|
||||
}
|
||||
|
||||
export default function GamePage() {
|
||||
return (
|
||||
<Suspense fallback={<GameLoadingFallback />}>
|
||||
<GameClient />
|
||||
</Suspense>
|
||||
);
|
||||
/**
|
||||
* The old game URL, kept alive for every link and bookmark still in the wild.
|
||||
*
|
||||
* The region lives in the path now (/game/{slug}), so this reads the legacy
|
||||
* query and redirects. `region` wins over `location`, matching how the client
|
||||
* used to resolve a URL carrying both.
|
||||
*
|
||||
* Redirecting from here rather than through next.config.mjs `redirects()` is
|
||||
* deliberate: a config redirect forwards the original query string to the
|
||||
* destination, so a visitor would land on `/game/tphcm?region=TPHCM` and keep
|
||||
* the dead param forever. Building the destination here also applies the
|
||||
* lowercase URL convention in the same hop, so a legacy `?region=HN` reaches
|
||||
* `/game/hn` directly rather than through a second redirect.
|
||||
*
|
||||
* A region-less visit goes to the country. `resolveRegion()` has always read
|
||||
* an absent region that way; the old TPHCM fallback was never a stated default,
|
||||
* just where the query chain happened to end.
|
||||
*/
|
||||
export default async function GamePage({ searchParams }) {
|
||||
// searchParams is a Promise in Next 16.
|
||||
const query = await searchParams;
|
||||
const legacy = firstValue(query.region) || firstValue(query.location);
|
||||
const code = regionSlug(legacy || COUNTRY_CODE);
|
||||
|
||||
// Encoded because `code` is whatever the query string carried -- this value
|
||||
// goes into a Location header unvalidated. A raw CRLF makes Node throw
|
||||
// (a 500 instead of the honest 404 below), and a raw '../' would be
|
||||
// normalised by the browser into a different path entirely. A real region
|
||||
// code round-trips through encodeURIComponent untouched, which
|
||||
// tests/regions.test.js asserts, so this costs nothing on the happy path.
|
||||
//
|
||||
// An unknown legacy code lands on /game/{slug}, which 404s there. That is
|
||||
// the honest answer -- it was never a playable link.
|
||||
redirect(`/game/${encodeURIComponent(code)}`);
|
||||
}
|
||||
@@ -35,6 +35,41 @@ export function isRegion(code) {
|
||||
return Boolean(code && REGIONS[code]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL path segment for a region: `/game/tphcm-q7`.
|
||||
*
|
||||
* Codes are uppercase everywhere inside the app -- they are the keys of the
|
||||
* generated tree -- but URLs are lowercase, which is the ordinary convention
|
||||
* for a path a person might type or read aloud.
|
||||
*
|
||||
* The conversion lives here rather than at each call site because three of
|
||||
* them build these URLs (the region picker, the route's generateStaticParams,
|
||||
* and the legacy-query redirect), and a casing mismatch between any two would
|
||||
* split one region across two analytics rows -- which is the whole reason the
|
||||
* region is in the path at all.
|
||||
* @param {string} code Region code, e.g. 'TPHCM-Q7'.
|
||||
* @returns {string} Path segment, e.g. 'tphcm-q7'.
|
||||
*/
|
||||
export function regionSlug(code) {
|
||||
return String(code).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The region a URL path segment names, or null.
|
||||
*
|
||||
* Case-insensitive on purpose: lowercase is what the app links to, but a
|
||||
* hand-typed or hand-edited `/game/TPHCM` should play rather than 404. There
|
||||
* is deliberately no redirect to the canonical casing -- a redirect on a
|
||||
* prerendered route gets cached as that route's response, and an uppercase URL
|
||||
* is rare enough that one stray analytics row costs less than that mechanism.
|
||||
* @param {string} slug Path segment from the URL.
|
||||
* @returns {string|null} The canonical region code, or null when unknown.
|
||||
*/
|
||||
export function regionFromSlug(slug) {
|
||||
const code = String(slug).toUpperCase();
|
||||
return isRegion(code) ? code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node and every ancestor above it, ending at the country.
|
||||
*
|
||||
|
||||
@@ -11,7 +11,7 @@ test.beforeEach(async ({ page }) => {
|
||||
// Not the spec under test here, and the banner would sit over the panorama.
|
||||
await seedHintSeen(page);
|
||||
await stubGameApis(page, 'e2e-player');
|
||||
await page.goto('/game?region=TPHCM');
|
||||
await page.goto('/game/tphcm');
|
||||
});
|
||||
|
||||
test('plays a round to the reveal and into the next one', async ({ page }) => {
|
||||
|
||||
+15
-6
@@ -23,7 +23,7 @@ test('expands a province to playable district links', async ({ page }) => {
|
||||
await expect(page.getByRole('link', { name: /Play anywhere in Ho Chi Minh/ })).toBeVisible();
|
||||
const district7 = page.getByRole('link', { name: /District 7/ });
|
||||
await expect(district7).toBeVisible();
|
||||
await expect(district7).toHaveAttribute('href', '/game?region=TPHCM-Q7');
|
||||
await expect(district7).toHaveAttribute('href', '/game/tphcm-q7');
|
||||
});
|
||||
|
||||
test('lists an uncovered district as disabled, with the reason', async ({ page }) => {
|
||||
@@ -38,13 +38,22 @@ test('lists an uncovered district as disabled, with the reason', async ({ page }
|
||||
test('shows the build commit in the debug footer and copies it on click', async ({ page, context }) => {
|
||||
// The dev server resolves the sha from git, so this asserts the real wiring:
|
||||
// config env -> layout -> footer -> clipboard.
|
||||
const stamp = page.getByRole('button', { name: 'Copy build commit' });
|
||||
await expect(stamp).toBeVisible();
|
||||
await expect(stamp).toHaveText(/^[0-9a-f]{7}$/);
|
||||
//
|
||||
// The sha and the copy control are two elements, not one: the label links to
|
||||
// the commit on GitHub and the button beside it copies the full sha. Both
|
||||
// halves are asserted here.
|
||||
const shaLink = page.getByRole('link', { name: /^View build commit [0-9a-f]{40} on GitHub$/ });
|
||||
await expect(shaLink).toBeVisible();
|
||||
// The label is the short form; the href carries the full one.
|
||||
await expect(shaLink).toHaveText(/^[0-9a-f]{7}$/);
|
||||
await expect(shaLink).toHaveAttribute('href', /\/commit\/[0-9a-f]{40}$/);
|
||||
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await stamp.click();
|
||||
await expect(stamp).toHaveText('copied!');
|
||||
await page.getByRole('button', { name: 'Copy build commit' }).click();
|
||||
|
||||
// The glyph swap is decorative (aria-hidden); the accessible name is what
|
||||
// announces the state, so that is what gets asserted.
|
||||
await expect(page.getByRole('button', { name: 'Build commit copied' })).toBeVisible();
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { stubGameApis, seedUsername, seedHintSeen } from './helpers.js';
|
||||
|
||||
// The region lives in the path (/game/tphcm), and every URL shape that used to
|
||||
// work still has to. A dead bookmark produces no error anyone sees, so these
|
||||
// assertions are the only thing that would notice a broken redirect.
|
||||
//
|
||||
// URLs are asserted exactly, not by partial match: an unanchored /\/game\/VN/
|
||||
// would also pass for /game/vnsomething.
|
||||
|
||||
/**
|
||||
* The path a navigation actually settled on, query string included.
|
||||
*
|
||||
* Compared as one string rather than pathname alone: proving the query is
|
||||
* GONE is half the point of these tests -- a config-level redirect would have
|
||||
* carried ?region= onto the destination. Host-free so the spec does not
|
||||
* duplicate playwright.config.js's baseURL.
|
||||
* @param {import('@playwright/test').Page} page Playwright page.
|
||||
* @returns {string} e.g. '/game/TPHCM'.
|
||||
*/
|
||||
function landedAt(page) {
|
||||
const url = new URL(page.url());
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedUsername(page, 'e2e-router');
|
||||
await seedHintSeen(page);
|
||||
await stubGameApis(page, 'e2e-router');
|
||||
});
|
||||
|
||||
test('serves the canonical region URL without redirecting', async ({ page }) => {
|
||||
const response = await page.goto('/game/tphcm');
|
||||
expect(response.status()).toBe(200);
|
||||
expect(landedAt(page)).toBe('/game/tphcm');
|
||||
// Landed on a real round, not an error panel.
|
||||
await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('redirects the legacy ?region= form', async ({ page }) => {
|
||||
await page.goto('/game?region=TPHCM');
|
||||
expect(landedAt(page)).toBe('/game/tphcm');
|
||||
});
|
||||
|
||||
test('redirects the legacy ?location= form', async ({ page }) => {
|
||||
await page.goto('/game?location=TPHCM');
|
||||
expect(landedAt(page)).toBe('/game/tphcm');
|
||||
});
|
||||
|
||||
test('prefers region over location when a link carries both', async ({ page }) => {
|
||||
// Matches how the old client resolved it: `region` first, `location` as the
|
||||
// fallback. A link carrying both must not change meaning in the migration.
|
||||
await page.goto('/game?region=TPHCM&location=HN');
|
||||
expect(landedAt(page)).toBe('/game/tphcm');
|
||||
});
|
||||
|
||||
test('sends a region-less /game to the country round', async ({ page }) => {
|
||||
await page.goto('/game');
|
||||
expect(landedAt(page)).toBe('/game/vn');
|
||||
});
|
||||
|
||||
test('treats an empty region param as region-less', async ({ page }) => {
|
||||
// firstValue() reads '' as "no value", so an empty param falls through to
|
||||
// the country rather than landing on a broken `/game/`.
|
||||
await page.goto('/game?region=');
|
||||
expect(landedAt(page)).toBe('/game/vn');
|
||||
});
|
||||
|
||||
test('serves an uppercase URL directly, without redirecting', async ({ page }) => {
|
||||
// Nothing in the app generates an uppercase URL, but a hand-edited one has
|
||||
// to play rather than 404. Deliberately NOT redirected to the lowercase
|
||||
// form: a redirect on a prerendered route gets cached as that route's
|
||||
// response, and one stray analytics row costs less than that mechanism.
|
||||
const response = await page.goto('/game/TPHCM');
|
||||
expect(response.status()).toBe(200);
|
||||
expect(landedAt(page)).toBe('/game/TPHCM');
|
||||
await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('lowercases an uppercase legacy region in one hop', async ({ page }) => {
|
||||
// The legacy handler applies the slug convention while building the
|
||||
// destination, so this lands lowercase in a single redirect. Also proves the
|
||||
// redirect leaves no query behind -- a config-level rule would have carried
|
||||
// ?region=HN onto the destination.
|
||||
await page.goto('/game?region=HN');
|
||||
expect(landedAt(page)).toBe('/game/hn');
|
||||
});
|
||||
|
||||
test('encodes a hostile legacy region value instead of 500ing or escaping the path', async ({ page }) => {
|
||||
// The legacy value goes into a Location header. Unencoded, a CRLF makes Node
|
||||
// throw (500) and a '../' would be normalised by the browser onto a
|
||||
// different path. Both must end at the honest 404 instead.
|
||||
const crlf = await page.goto('/game?region=a%0d%0ab');
|
||||
expect(crlf.status()).toBe(404);
|
||||
|
||||
const traversal = await page.goto('/game?region=..%2F..%2Fdebug');
|
||||
expect(traversal.status()).toBe(404);
|
||||
expect(landedAt(page)).not.toBe('/debug');
|
||||
});
|
||||
|
||||
test('404s an unknown region', async ({ page }) => {
|
||||
const response = await page.goto('/game/NOTAREGION');
|
||||
// Asserted on status, never on the not-found page's wording, which is free
|
||||
// to change without breaking this.
|
||||
expect(response.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('serves a real region with no imagery instead of 404ing it', async ({ page }) => {
|
||||
// Deliberately NOT a 404. The API answers an uncovered region with a written
|
||||
// message ("... has no street view coverage yet") and the page renders it;
|
||||
// a 404 would replace a good answer with a worse one. The APIs are stubbed
|
||||
// here, so this asserts only the routing half -- that the page exists and
|
||||
// serves. The message itself is the API's contract, covered in
|
||||
// tests/region-request.test.js.
|
||||
const { isRegion, isPlayable } = await import('../../src/lib/regions.js');
|
||||
const code = 'TPHCM-CUCHI';
|
||||
expect(isRegion(code), `${code} should still be a region`).toBe(true);
|
||||
expect(isPlayable(code), `${code} gained coverage; pick another fixture`).toBe(false);
|
||||
|
||||
const { regionSlug } = await import('../../src/lib/regions.js');
|
||||
const response = await page.goto(`/game/${regionSlug(code)}`);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(landedAt(page)).toBe(`/game/${regionSlug(code)}`);
|
||||
});
|
||||
+26
-17
@@ -1,9 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { stubGameApis, seedUsername, seedHintSeen, USERNAME_STORAGE_KEY } from './helpers.js';
|
||||
|
||||
// The name prompt appears at the first Play click -- when the name is about
|
||||
// to matter -- not on landing. No seeded name in the fresh-profile tests:
|
||||
// that timing is the behavior under test.
|
||||
// With no stored name the prompt opens on landing, so every way out of it
|
||||
// (save, skip, dismiss) leaves a name behind before Play is ever clicked --
|
||||
// see the comment above the effect in src/app/page.js. No seeded name in the
|
||||
// fresh-profile tests: that timing is the behavior under test.
|
||||
//
|
||||
// The Play-click interception in handlePlayClick() is the remaining fallback
|
||||
// for a name that goes missing between landing and the click. It is not
|
||||
// reachable by an ordinary gesture, so nothing here exercises it.
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubGameApis(page, 'fresh-player');
|
||||
@@ -11,39 +16,43 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('landing shows no prompt; the first Play click asks, saves, and starts the game', async ({ page }) => {
|
||||
// The landing page introduces the game first: no ambush modal, just an
|
||||
// always-visible entry point for setting a name later.
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Set name' })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: /Play anywhere in Vietnam/ }).click();
|
||||
|
||||
test('landing asks for a name, and Play then goes straight into the round', async ({ page }) => {
|
||||
// The prompt comes before Play rather than interrupting it, so the name is
|
||||
// already settled by the time the player commits to a region.
|
||||
const dialog = page.getByRole('dialog', { name: /Welcome to VNGeoGuessr/ });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel(/Username/).fill('fresh-player');
|
||||
await dialog.getByRole('button', { name: 'Save name' }).click();
|
||||
|
||||
// Saving resumes the intercepted navigation into the round.
|
||||
await expect(page).toHaveURL(/\/game\?region=VN/);
|
||||
// Saving with nothing pending just closes: there is no navigation to resume.
|
||||
await expect(dialog).toBeHidden();
|
||||
expect(
|
||||
await page.evaluate((key) => window.localStorage.getItem(key), USERNAME_STORAGE_KEY)
|
||||
).toBe('fresh-player');
|
||||
|
||||
// Play is now uninterrupted.
|
||||
await page.getByRole('link', { name: /Play anywhere in Vietnam/ }).click();
|
||||
await expect(page).toHaveURL(/\/game\/vn$/);
|
||||
});
|
||||
|
||||
test('skip generates a random name and still starts the game', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Play anywhere in Vietnam/ }).click();
|
||||
test('skip generates a random name and still lets the game start', async ({ page }) => {
|
||||
const dialog = page.getByRole('dialog', { name: /Welcome to VNGeoGuessr/ });
|
||||
await dialog.getByRole('button', { name: /Skip — random name/ }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
await expect(page).toHaveURL(/\/game\?region=VN/);
|
||||
// Skip is not "play anonymously": it persists a generated name exactly like
|
||||
// a typed one, so the round still reaches the leaderboard under something
|
||||
// the player can recognise and later edit.
|
||||
expect(
|
||||
await page.evaluate((key) => window.localStorage.getItem(key), USERNAME_STORAGE_KEY)
|
||||
).toMatch(/^Player-[0-9a-z]{6}$/);
|
||||
|
||||
await page.getByRole('link', { name: /Play anywhere in Vietnam/ }).click();
|
||||
await expect(page).toHaveURL(/\/game\/vn$/);
|
||||
});
|
||||
|
||||
test('rejects an invalid username with a visible error', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Set name' }).click();
|
||||
// Already open on landing -- no stored name in this profile.
|
||||
const dialog = page.getByRole('dialog', { name: /Welcome to VNGeoGuessr/ });
|
||||
await dialog.getByLabel(/Username/).fill('bad name!');
|
||||
await dialog.getByRole('button', { name: 'Save name' }).click();
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
regionPath,
|
||||
allRegions,
|
||||
isUnresolved,
|
||||
regionSlug,
|
||||
regionFromSlug,
|
||||
} from '../src/lib/regions.js';
|
||||
|
||||
const LEAVES = allRegions().filter((code) => childrenOf(code).length === 0);
|
||||
@@ -25,6 +27,36 @@ describe('region tree shape', () => {
|
||||
expect(getRegion(COUNTRY_CODE).level).toBe('country');
|
||||
});
|
||||
|
||||
it('every code has a URL-safe lowercase slug', () => {
|
||||
// The slug is the /game/[region] path segment. Anything outside this class
|
||||
// would need percent-encoding, which would break both the prerendered
|
||||
// params and the legacy redirect that builds these URLs by hand.
|
||||
for (const code of allRegions()) {
|
||||
const slug = regionSlug(code);
|
||||
expect(slug, code).toMatch(/^[a-z0-9-]+$/);
|
||||
expect(encodeURIComponent(slug), code).toBe(slug);
|
||||
}
|
||||
});
|
||||
|
||||
it('every slug resolves back to the code it came from', () => {
|
||||
// regionSlug and regionFromSlug are the only two directions this app
|
||||
// converts between, and three call sites depend on the round trip.
|
||||
for (const code of allRegions()) {
|
||||
expect(regionFromSlug(regionSlug(code)), code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a slug whatever its casing, and rejects a non-region', () => {
|
||||
// Uppercase URLs are not generated anywhere, but a hand-edited one plays
|
||||
// rather than 404ing -- there is deliberately no canonical-casing redirect.
|
||||
expect(regionFromSlug('tphcm')).toBe('TPHCM');
|
||||
expect(regionFromSlug('TPHCM')).toBe('TPHCM');
|
||||
expect(regionFromSlug('TpHcM')).toBe('TPHCM');
|
||||
expect(regionFromSlug('hn-badinh')).toBe('HN-BADINH');
|
||||
expect(regionFromSlug('notaregion')).toBeNull();
|
||||
expect(regionFromSlug('')).toBeNull();
|
||||
});
|
||||
|
||||
it('every parent reference resolves', () => {
|
||||
for (const code of allRegions()) {
|
||||
const { parent } = getRegion(code);
|
||||
|
||||
Reference in new issue
Block a user