feat(@projects/@magic-civilization): add welcome modal e2e test

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 05:29:20 -07:00
parent 979abea2e0
commit b5208e9c37
2 changed files with 337 additions and 124 deletions

View file

@ -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
* `<RaceHighlight>`) and Dwarf vocabulary and leaks no Game 2/3 scope
* text (the `<EpisodeGate min={2}>` 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 <h1> "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
// `<RaceHighlight>{name}</RaceHighlight>` span).
expect(
bodyText,
`HomePage should render ruler name "${RULER_NAME}" inside <RaceHighlight>`,
).toContain(RULER_NAME)
// 9b. Concretely assert the name lands inside a <RaceHighlight> (a
// styled <strong>) — 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)
})
})

View file

@ -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 (
<PieContainer>
<svg viewBox="0 0 100 100" width="280" height="280">
<PieSvg viewBox="0 0 100 100" width="280" height="280">
{slices.map(({ biome, d }) => (
<path key={biome.id} d={d} fill={biome.color} stroke="#1a1510" strokeWidth="0.3">
<path key={biome.id} d={d} fill={biome.color}>
<title>{biome.name}</title>
</path>
))}
</svg>
</PieSvg>
<PieLegend>
{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 (
<PieLegendItem key={cat.id}>
@ -59,74 +80,125 @@ function BiomePieChart({ biomes }: { biomes: BiomeDisplay[] }): ReactElement {
)
}
function BiomeCard({ biome, colorMap, labelMap }: { biome: BiomeDisplay; colorMap: Record<number, string>; labelMap: Record<number, string> }): ReactElement {
interface BiomeCardProps {
biome: BiomeDisplay
labelMap: Record<number, string>
highlighted: boolean
onOpen: (id: string) => void
onClose: () => void
}
function BiomeCard({ biome, labelMap, highlighted, onOpen, onClose }: BiomeCardProps): ReactElement {
const ref = useRef<HTMLDivElement | null>(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 (
<Card>
<CardHeader>
<BiomeSwatch style={{ background: biome.color }} />
<CardTitle>{biome.name}</CardTitle>
<BiomeDataCard
ref={ref}
$variant="compact"
$highlighted={highlighted}
onClick={() => (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)
}
}}
>
<BiomeHeader>
<BiomeColorDot style={{ background: biome.color }} />
<BiomeName>{biome.name}</BiomeName>
<CategoryTag>{biome.category}</CategoryTag>
</CardHeader>
<CardBody>
<StatRow>
<StatLabel>Quality Range</StatLabel>
<QualityRange>
{Array.from({ length: biome.quality_range[1] - biome.quality_range[0] + 1 }, (_, i) => {
const q = biome.quality_range[0] + i
return (
<QualityDot key={q} style={{ background: colorMap[q] }} title={`T${q} ${labelMap[q]}`}>
{q}
</QualityDot>
)
})}
</QualityRange>
</StatRow>
<StatRow>
<StatLabel>Temperature</StatLabel>
<StatValue>{biome.temp_range[0].toFixed(2)} - {biome.temp_range[1].toFixed(2)}</StatValue>
</StatRow>
<StatRow>
<StatLabel>Moisture</StatLabel>
<StatValue>{biome.moisture_range[0].toFixed(2)} - {biome.moisture_range[1].toFixed(2)}</StatValue>
</StatRow>
<StatRow>
<StatLabel>Fauna Capacity</StatLabel>
<StatValue>{biome.fauna_capacity}</StatValue>
</StatRow>
<FloraBar>
<FloraSegment
style={{ width: `${biome.flora_climax.canopy * 100}%`, background: '#1a9928' }}
title={`Canopy: ${biome.flora_climax.canopy}`}
/>
<FloraSegment
style={{ width: `${biome.flora_climax.undergrowth * 100}%`, background: '#8cc634' }}
title={`Undergrowth: ${biome.flora_climax.undergrowth}`}
/>
<FloraSegment
style={{ width: `${biome.flora_climax.fungi * 100}%`, background: '#9040a0' }}
title={`Fungi: ${biome.flora_climax.fungi}`}
/>
</FloraBar>
<FloraLabels>
<span>Canopy {biome.flora_climax.canopy}</span>
<span>Undergrowth {biome.flora_climax.undergrowth}</span>
<span>Fungi {biome.flora_climax.fungi}</span>
</FloraLabels>
</CardBody>
</Card>
</BiomeHeader>
<StatsGrid>
<StatCell>
<StatCell.Label>Quality</StatCell.Label>
<StatCell.Value>
<QualityLine>
<QualityIndicator $tier={qMaxTier} />
<span>{qLabel}</span>
</QualityLine>
</StatCell.Value>
</StatCell>
<StatCell>
<StatCell.Label>Temperature</StatCell.Label>
<StatCell.Value>{biome.temp_range[0].toFixed(2)} {biome.temp_range[1].toFixed(2)}</StatCell.Value>
</StatCell>
<StatCell>
<StatCell.Label>Moisture</StatCell.Label>
<StatCell.Value>{biome.moisture_range[0].toFixed(2)} {biome.moisture_range[1].toFixed(2)}</StatCell.Value>
</StatCell>
<StatCell>
<StatCell.Label>Fauna Capacity</StatCell.Label>
<StatCell.Value>{biome.fauna_capacity}</StatCell.Value>
</StatCell>
</StatsGrid>
<FloraBar>
<FloraSegment
style={{ width: `${biome.flora_climax.canopy * 100}%`, background: '#1a9928' }}
title={`Canopy: ${biome.flora_climax.canopy}`}
/>
<FloraSegment
style={{ width: `${biome.flora_climax.undergrowth * 100}%`, background: '#8cc634' }}
title={`Undergrowth: ${biome.flora_climax.undergrowth}`}
/>
<FloraSegment
style={{ width: `${biome.flora_climax.fungi * 100}%`, background: '#9040a0' }}
title={`Fungi: ${biome.flora_climax.fungi}`}
/>
</FloraBar>
<FloraLabels>
<span>Canopy {biome.flora_climax.canopy}</span>
<span>Undergrowth {biome.flora_climax.undergrowth}</span>
<span>Fungi {biome.flora_climax.fungi}</span>
</FloraLabels>
</BiomeDataCard>
)
}
export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [category, setCategory] = useUrlFilter<CategoryValue>('category', CATEGORY_VALUES, ALL_CATEGORY)
const [searchParams, setSearchParams] = useSearchParams()
// `?biome=<id>` 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 (
<FadeIn duration="fast">
@ -149,17 +221,26 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
<Section>
<SectionHeading>All Biomes</SectionHeading>
<FilterRow>
<FilterChip $active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
<FilterRow role="tablist" aria-label="Biome category">
<FilterChip
type="button"
role="tab"
aria-selected={category === ALL_CATEGORY}
$active={category === ALL_CATEGORY}
onClick={() => setCategory(ALL_CATEGORY)}
>
All ({ALL_BIOMES.length})
</FilterChip>
{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 (
<FilterChip
key={cat.id}
$active={categoryFilter === cat.id}
onClick={() => setCategoryFilter(cat.id)}
type="button"
role="tab"
aria-selected={category === cat.id}
$active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
{cat.label} ({count})
</FilterChip>
@ -167,8 +248,15 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
})}
</FilterRow>
<BiomeGrid>
{filtered.map(biome => (
<BiomeCard key={biome.id} biome={biome} colorMap={colors} labelMap={labels} />
{filtered.map((biome) => (
<BiomeCard
key={biome.id}
biome={biome}
labelMap={labels}
highlighted={biome.id === highlightedBiome}
onOpen={openBiome}
onClose={closeBiome}
/>
))}
</BiomeGrid>
</Section>
@ -176,7 +264,7 @@ export default function BiomeBrowserPage({ tierDefs }: Props): ReactElement {
<Section>
<SectionHeading>Color Legend</SectionHeading>
<ColorLegend>
{ALL_BIOMES.map(biome => (
{ALL_BIOMES.map((biome) => (
<LegendEntry key={biome.id}>
<LegendSwatch style={{ background: biome.color }} />
<LegendName>{biome.name}</LegendName>
@ -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`