From b5208e9c37caf442a697d67e541ed1d9f74db9cf Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 18 Apr 2026 05:29:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20welcome=20modal=20e2e=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../age-of-dwarves/guide/e2e/welcome.spec.ts | 139 ++++++++ .../src/pages/engine/BiomeBrowserPage.tsx | 322 +++++++++++------- 2 files changed, 337 insertions(+), 124 deletions(-) create mode 100644 public/games/age-of-dwarves/guide/e2e/welcome.spec.ts diff --git a/public/games/age-of-dwarves/guide/e2e/welcome.spec.ts b/public/games/age-of-dwarves/guide/e2e/welcome.spec.ts new file mode 100644 index 00000000..baf89a81 --- /dev/null +++ b/public/games/age-of-dwarves/guide/e2e/welcome.spec.ts @@ -0,0 +1,139 @@ +import { test, expect } from '@playwright/test' +import type { Page, ConsoleMessage } from '@playwright/test' + +/** + * WelcomeModal → themed HomePage walkthrough (objective p2-29, last bullet). + * + * Loads `/` WITHOUT `?skip=welcome` so the modal actually renders, picks + * Dwarf + Female + a distinctive ruler name, clicks "Enter the Guide", and + * asserts the resulting HomePage is themed with the chosen name (inside + * ``) and Dwarf vocabulary — and leaks no Game 2/3 scope + * text (the `` block must stay dark in the default + * prod build where `VITE_DEV_GUIDE` is unset). + * + * Runs against the default prod build (`CI=1`, `VITE_DEV_GUIDE=0`) so the + * scope-hygiene assertions are valid — see `scope-hygiene.spec.ts` for the + * same `CI=1` invocation pattern. + * + * Run: `pnpm --prefix public/games/age-of-dwarves/guide test:e2e --grep welcome` + */ + +const RULER_NAME = 'Brenna Ironshield' + +// Subset of `scope-hygiene.spec.ts` FORBIDDEN_SUBSTRINGS — the Game 2/3 +// strings the task brief explicitly calls out for the HomePage assertion. +const FORBIDDEN_PATTERNS: readonly RegExp[] = [ + /magic schools/i, + /High Archon/i, + /ley lines/i, + /Archon Telepathy/i, +] as const + +const IGNORED_ERROR_PATTERNS: readonly RegExp[] = [ + /Download the React DevTools/, +] + +interface ErrorCapture { + readonly pageErrors: string[] + readonly consoleErrors: string[] +} + +function attachErrorCapture(page: Page): ErrorCapture { + const pageErrors: string[] = [] + const consoleErrors: string[] = [] + + page.on('pageerror', (err: Error) => { + pageErrors.push(`${err.name}: ${err.message}`) + }) + + page.on('console', (msg: ConsoleMessage) => { + if (msg.type() !== 'error') return + const text = msg.text() + if (IGNORED_ERROR_PATTERNS.some((re) => re.test(text))) return + consoleErrors.push(text) + }) + + return { pageErrors, consoleErrors } +} + +test.describe('welcome modal flow', () => { + test('Dwarf + Female + named ruler → themed HomePage with Dwarf vocabulary', async ({ page }) => { + const cap = attachErrorCapture(page) + + // 1. Fresh load WITHOUT ?skip=welcome so the modal actually renders. + await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 }) + + // 2. Wait for the modal header to appear. `WelcomeModal` doesn't set + // role="dialog" on its styled Panel, so anchor on the stable game + // title string that only lives inside the modal header. + const modalTitle = page.getByRole('heading', { name: /Magic\s*&\s*Civilization/i }) + await expect(modalTitle).toBeVisible({ timeout: 10_000 }) + await expect(page.getByText('Player Guide — Settings')).toBeVisible() + + // 3. Pick Dwarf. The race OptionButton renders its `opt.label` ("Dwarf" + // per the ruler-names data) alongside the race icon; getByRole + // narrowing keeps us away from any stray text matches elsewhere. + await page.getByRole('button', { name: /^Dwarf$/ }).click() + + // 4. Pick Female. + await page.getByRole('button', { name: /^Female$/ }).click() + + // 5. Type a distinctive ruler name into the name input. The placeholder + // is the stable locator — the input has no aria-label. + const nameInput = page.getByPlaceholder('Leave blank to use the default leader name') + await expect(nameInput).toBeVisible() + await nameInput.fill(RULER_NAME) + await expect(nameInput).toHaveValue(RULER_NAME) + + // 6. Click "Enter the Guide" to confirm. + await page.getByRole('button', { name: 'Enter the Guide' }).click() + + // 7. Wait for HomePage to render post-modal. The Hero's

"Magic + // Civilization" is the stable anchor, and the modal should be gone. + await expect(modalTitle).toBeHidden({ timeout: 10_000 }) + await expect(page.getByRole('heading', { level: 1, name: /^Magic Civilization$/ })).toBeVisible() + + // 8. LoreSection eyebrow reads "Your Story Begins" (per HomePage.tsx). + await expect(page.getByText('Your Story Begins', { exact: true })).toBeVisible() + + // Read all visible body text once for the remaining content assertions. + const bodyText = await page.locator('body').innerText() + + // 9. The chosen ruler name is rendered inside the LoreSection (the + // `{name}` span). + expect( + bodyText, + `HomePage should render ruler name "${RULER_NAME}" inside `, + ).toContain(RULER_NAME) + + // 9b. Concretely assert the name lands inside a (a + // styled ) — the theming claim, not just a body-text match. + await expect( + page.locator('strong', { hasText: RULER_NAME }).first(), + ).toBeVisible() + + // 10. Dwarf vocabulary appears in the body prose ("Dwarves" / "Dwarven"). + expect( + bodyText, + 'HomePage body should use Dwarf vocabulary (Dwarf/Dwarves/Dwarven)', + ).toMatch(/Dwar(f|ves|ven)/i) + + // 11. No Game 2/3 scope leaks (EpisodeGate min={2} must be dark). + for (const pat of FORBIDDEN_PATTERNS) { + expect( + bodyText, + `HomePage leaked Game 2/3 content matching ${pat}`, + ).not.toMatch(pat) + } + + // 12. Zero console errors throughout the walkthrough. + const errors = [ + ...cap.pageErrors.map((e) => `[pageerror] ${e}`), + ...cap.consoleErrors.map((e) => `[console.error] ${e}`), + ] + expect( + errors, + `welcome-modal flow emitted runtime errors:\n${errors.join('\n')}`, + ).toHaveLength(0) + }) +}) diff --git a/src/packages/guide/src/pages/engine/BiomeBrowserPage.tsx b/src/packages/guide/src/pages/engine/BiomeBrowserPage.tsx index f52e46b3..1f07dfa0 100644 --- a/src/packages/guide/src/pages/engine/BiomeBrowserPage.tsx +++ b/src/packages/guide/src/pages/engine/BiomeBrowserPage.tsx @@ -1,15 +1,36 @@ -import { useState, useMemo, type ReactElement } from 'react' +import { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react' import styled from 'styled-components' +import { useSearchParams } from 'react-router-dom' import { FadeIn } from '../../components/ui/FadeIn' -import { PageTitle, Section, SectionHeading, Prose, FilterRow, FilterChip } from '../../components/ui/PagePrimitives' +import { + PageTitle, Section, SectionHeading, Prose, + FilterRow, FilterChip, + DataCard, StatsGrid, StatCell, QualityIndicator, +} from '../../components/ui/PagePrimitives' import { Heading, Text } from '@lilith/ui-typography' -import { ALL_BIOMES, BIOME_CATEGORIES, tierColorMap, tierLabelMap } from '../../data/ecology' +import { useUrlFilter } from '../../hooks/useUrlFilter' +import { ALL_BIOMES, BIOME_MAP, BIOME_CATEGORIES, tierLabelMap } from '../../data/ecology' import type { BiomeDisplay, QualityTierDef } from '../../data/ecology' interface Props { tierDefs: QualityTierDef[] } +/** Category filter value — either the sentinel `all` or a biome category id. */ +const ALL_CATEGORY = 'all' as const +type CategoryValue = typeof ALL_CATEGORY | BiomeDisplay['category'] + +const CATEGORY_VALUES: readonly CategoryValue[] = [ + ALL_CATEGORY, + ...BIOME_CATEGORIES.map((c) => c.id), +] + +function clampTier(q: number): 1 | 2 | 3 | 4 | 5 { + if (q <= 1) return 1 + if (q >= 5) return 5 + return q as 1 | 2 | 3 | 4 | 5 +} + function BiomePieChart({ biomes }: { biomes: BiomeDisplay[] }): ReactElement { const total = biomes.length let currentAngle = 0 @@ -36,16 +57,16 @@ function BiomePieChart({ biomes }: { biomes: BiomeDisplay[] }): ReactElement { return ( - + {slices.map(({ biome, d }) => ( - + {biome.name} ))} - + - {BIOME_CATEGORIES.map(cat => { - const count = biomes.filter(b => b.category === cat.id).length + {BIOME_CATEGORIES.map((cat) => { + const count = biomes.filter((b) => b.category === cat.id).length if (count === 0) return null return ( @@ -59,74 +80,125 @@ function BiomePieChart({ biomes }: { biomes: BiomeDisplay[] }): ReactElement { ) } -function BiomeCard({ biome, colorMap, labelMap }: { biome: BiomeDisplay; colorMap: Record; labelMap: Record }): ReactElement { +interface BiomeCardProps { + biome: BiomeDisplay + labelMap: Record + highlighted: boolean + onOpen: (id: string) => void + onClose: () => void +} + +function BiomeCard({ biome, labelMap, highlighted, onOpen, onClose }: BiomeCardProps): ReactElement { + const ref = useRef(null) + + useEffect(() => { + if (highlighted && ref.current) { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, [highlighted]) + + const [qMin, qMax] = biome.quality_range + const qMaxTier = clampTier(qMax) + const qLabel = qMin === qMax + ? `T${qMin} ${labelMap[qMin] ?? ''}`.trim() + : `T${qMin}–T${qMax}` + return ( - - - - {biome.name} + (highlighted ? onClose() : onOpen(biome.id))} + role="button" + aria-pressed={highlighted} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (highlighted) onClose() + else onOpen(biome.id) + } + }} + > + + + {biome.name} {biome.category} - - - - Quality Range - - {Array.from({ length: biome.quality_range[1] - biome.quality_range[0] + 1 }, (_, i) => { - const q = biome.quality_range[0] + i - return ( - - {q} - - ) - })} - - - - Temperature - {biome.temp_range[0].toFixed(2)} - {biome.temp_range[1].toFixed(2)} - - - Moisture - {biome.moisture_range[0].toFixed(2)} - {biome.moisture_range[1].toFixed(2)} - - - Fauna Capacity - {biome.fauna_capacity} - - - - - - - - Canopy {biome.flora_climax.canopy} - Undergrowth {biome.flora_climax.undergrowth} - Fungi {biome.flora_climax.fungi} - - - + + + + Quality + + + + {qLabel} + + + + + Temperature + {biome.temp_range[0].toFixed(2)} – {biome.temp_range[1].toFixed(2)} + + + Moisture + {biome.moisture_range[0].toFixed(2)} – {biome.moisture_range[1].toFixed(2)} + + + Fauna Capacity + {biome.fauna_capacity} + + + + + + + + + Canopy {biome.flora_climax.canopy} + Undergrowth {biome.flora_climax.undergrowth} + Fungi {biome.flora_climax.fungi} + + ) } export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement { - const [categoryFilter, setCategoryFilter] = useState('all') + const [category, setCategory] = useUrlFilter('category', CATEGORY_VALUES, ALL_CATEGORY) + const [searchParams, setSearchParams] = useSearchParams() + + // `?biome=` drives inline highlight + scroll-to on first render. Pattern + // mirrors `progress-report/ObjectivesTab.tsx` — URL-bound so shared links + // land on the same view. Non-enum so we use raw `useSearchParams`. + const biomeParam = searchParams.get('biome') + const highlightedBiome = biomeParam != null && BIOME_MAP.has(biomeParam) ? biomeParam : null - const colors = useMemo(() => tierColorMap(tierDefs), [tierDefs]) const labels = useMemo(() => tierLabelMap(tierDefs), [tierDefs]) const filtered = useMemo(() => { - if (categoryFilter === 'all') return ALL_BIOMES - return ALL_BIOMES.filter(b => b.category === categoryFilter) - }, [categoryFilter]) + if (category === ALL_CATEGORY) return ALL_BIOMES + return ALL_BIOMES.filter((b) => b.category === category) + }, [category]) + + const openBiome = useCallback((id: string): void => { + const params = new URLSearchParams(searchParams) + params.set('biome', id) + setSearchParams(params, { replace: false }) + }, [searchParams, setSearchParams]) + + const closeBiome = useCallback((): void => { + const params = new URLSearchParams(searchParams) + params.delete('biome') + setSearchParams(params, { replace: false }) + }, [searchParams, setSearchParams]) return ( @@ -149,17 +221,26 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
All Biomes - - setCategoryFilter('all')}> + + setCategory(ALL_CATEGORY)} + > All ({ALL_BIOMES.length}) - {BIOME_CATEGORIES.map(cat => { - const count = ALL_BIOMES.filter(b => b.category === cat.id).length + {BIOME_CATEGORIES.map((cat) => { + const count = ALL_BIOMES.filter((b) => b.category === cat.id).length return ( setCategoryFilter(cat.id)} + type="button" + role="tab" + aria-selected={category === cat.id} + $active={category === cat.id} + onClick={() => setCategory(cat.id)} > {cat.label} ({count}) @@ -167,8 +248,15 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement { })} - {filtered.map(biome => ( - + {filtered.map((biome) => ( + ))}
@@ -176,7 +264,7 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
Color Legend - {ALL_BIOMES.map(biome => ( + {ALL_BIOMES.map((biome) => ( {biome.name} @@ -188,6 +276,8 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement { ) } +// ─── Local styled (page-specific only) ───────────────────────────────────── + const PieContainer = styled.div` display: flex; align-items: center; @@ -196,6 +286,13 @@ const PieContainer = styled.div` flex-wrap: wrap; ` +const PieSvg = styled.svg` + & path { + stroke: ${({ theme }) => theme.colors.background.primary}; + stroke-width: 0.3; + } +` + const PieLegend = styled.div` display: flex; flex-direction: column; @@ -224,29 +321,40 @@ const BiomeGrid = styled.div` margin-top: 1rem; ` -const Card = styled.div` - background: ${({ theme }) => theme.colors.surface}; - border: 1px solid ${({ theme }) => theme.colors.border.default}; - border-radius: 6px; - overflow: hidden; +const BiomeDataCard = styled(DataCard)<{ $highlighted: boolean }>` + cursor: pointer; + transition: border-color 150ms, box-shadow 150ms; + border-color: ${({ $highlighted, theme }) => + $highlighted ? theme.colors.primary.main : theme.colors.border.default}; + box-shadow: ${({ $highlighted, theme }) => + $highlighted + ? `0 0 0 2px color-mix(in srgb, ${theme.colors.primary.main} 35%, transparent)` + : 'none'}; + + &:hover { + border-color: ${({ theme }) => theme.colors.primary.main}; + } + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.colors.primary.main}; + outline-offset: 2px; + } ` -const CardHeader = styled.div` +const BiomeHeader = styled.div` display: flex; align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - border-bottom: 1px solid ${({ theme }) => theme.colors.border.default}; + gap: 0.5rem; ` -const BiomeSwatch = styled.span` +const BiomeColorDot = styled.span` width: 1.25rem; height: 1.25rem; border-radius: 4px; flex-shrink: 0; ` -const CardTitle = styled.span` +const BiomeName = styled.span` font-weight: 600; font-size: 0.9375rem; color: ${({ theme }) => theme.colors.text.primary}; @@ -264,46 +372,12 @@ const CategoryTag = styled.span` border-radius: 3px; ` -const CardBody = styled.div` - padding: 0.75rem 1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -` - -const StatRow = styled.div` - display: flex; - justify-content: space-between; +const QualityLine = styled.span` + display: inline-flex; align-items: center; - font-size: 0.8125rem; -` - -const StatLabel = styled.span` - color: ${({ theme }) => theme.colors.text.muted}; -` - -const StatValue = styled.span` + gap: 0.375rem; font-family: monospace; - font-weight: 600; - color: ${({ theme }) => theme.colors.text.primary}; -` - -const QualityRange = styled.div` - display: flex; - gap: 0.25rem; -` - -const QualityDot = styled.span` - width: 1.5rem; - height: 1.5rem; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.6875rem; - font-weight: 700; - font-family: monospace; - color: #fff; + font-size: 0.75rem; ` const FloraBar = styled.div`