refactor: rewrite App.jsx as orchestrator over new component tree

This commit is contained in:
2026-06-23 17:15:01 -04:00
parent e2b4a39b51
commit 41f43f2ea8
2 changed files with 206 additions and 441 deletions

View File

@@ -1,234 +1,31 @@
import React, { useState, useMemo, useCallback } from 'react'
import {
Box, Container, Typography, TextField, Select, MenuItem, InputLabel,
FormControl, Stack, IconButton, Modal, Paper,
useMediaQuery, useTheme,
} from '@mui/material'
import { DataGrid } from '@mui/x-data-grid'
// ── helpers ──
function sizeShort(size) {
return size.replace(/\s*models?$/, '')
}
function pctLabel(pct) {
if (pct === null || pct === undefined) return '—'
const sign = pct > 0 ? '+' : ''
return `${sign}${pct.toFixed(1)}%`
}
function ptsLabel(pts) {
if (pts === null || pts === undefined) return '—'
return pts > 0 ? `+${pts}` : `${pts}`
}
// Color: red = more expensive (bad for player), green = cheaper (good for player)
function changeColor(val) {
if (val === null || val === undefined) return 'text.secondary'
if (val > 0) return '#f85149' // costlier = red
if (val < 0) return '#3fb950' // cheaper = green
return 'text.secondary'
}
// ── Size dropdown cell ──
function SizeCell({ row, onSelect }) {
const sizes = row.sizes || []
if (sizes.length <= 1) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>
{sizeShort(row.size)}
</Typography>
</Box>
)
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
<Select
size="small"
value={row.size}
onChange={(e) => { e.stopPropagation(); onSelect(row, e.target.value) }}
onClick={(e) => e.stopPropagation()}
variant="outlined"
IconComponent={() => null}
sx={{
minWidth: 'auto',
height: 24,
'& .MuiSelect-select': { py: 0.15, px: 0.5, fontSize: { xs: '0.65rem', sm: '0.75rem' }, fontWeight: 600, paddingRight: '0.5px !important' },
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(59,130,246,0.3)' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' },
}}
renderValue={(v) => sizeShort(v)}
>
{sizes.map((s) => (
<MenuItem key={s.size} value={s.size} sx={{ fontSize: '0.8rem' }}>
{sizeShort(s.size)}
</MenuItem>
))}
</Select>
</Box>
)
}
// ── Line graph modal ──
function GraphModal({ row, open, onClose }) {
const [graphSize, setGraphSize] = useState(null)
React.useEffect(() => {
if (row) setGraphSize(row.size)
}, [row])
if (!row) return null
const sizes = row.sizes || []
const activeSize = graphSize || row.size
const activeSizeData = sizes.find(s => s.size === activeSize) || sizes[0]
const history = activeSizeData?.history || []
const W = 640, H = 360, padL = 70, padR = 24, padT = 24, padB = 48
const chartW = W - padL - padR
const chartH = H - padT - padB
const pts = history.map(h => h.pts)
const minPts = Math.min(...pts, 0)
const maxPts = Math.max(...pts, 1)
const ptsRange = maxPts - minPts || 1
const padY = ptsRange * 0.15
const yMin = Math.max(0, minPts - padY)
const yMax = maxPts + padY
const yRange = yMax - yMin || 1
const n = history.length
const xFor = (i) => n <= 1 ? chartW / 2 + padL : padL + (i / (n - 1)) * chartW
const yFor = (v) => padT + chartH - ((v - yMin) / yRange) * chartH
const linePath = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(h.pts)}`).join(' ')
const areaPath = history.length > 1
? `${linePath} L ${xFor(n - 1)} ${padT + chartH} L ${xFor(0)} ${padT + chartH} Z`
: ''
const yTicks = []
const tickCount = Math.min(4, Math.ceil(yRange / 10))
for (let i = 0; i <= tickCount; i++) {
const v = yMin + (yRange * i / tickCount)
yTicks.push({ v: Math.round(v), y: yFor(v) })
}
const fmtDate = (d) => {
const dt = new Date(d)
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' })
}
return (
<Modal open={open} onClose={onClose} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: { xs: 0, sm: 2 }, width: '100%', height: '100%' }}>
<Paper sx={{
maxWidth: { xs: '100%', sm: 720 },
width: '100%',
height: { xs: '100%', sm: 'auto' },
maxHeight: { xs: '100%', sm: '90vh' },
overflow: 'auto', p: { xs: 1, sm: 3 },
borderRadius: { xs: 0, sm: 2 },
display: 'flex', flexDirection: 'column',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="h6" fontWeight={700} sx={{ fontSize: { xs: '1rem', sm: '1.1rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.72rem', sm: '0.8rem' } }}>{row.faction_name}</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: 'text.secondary', flexShrink: 0 }}></IconButton>
</Box>
{sizes.length > 1 && (
<FormControl size="small" sx={{ mb: 2, minWidth: 140 }}>
<InputLabel>Model count</InputLabel>
<Select
value={activeSize}
label="Model count"
onChange={(e) => setGraphSize(e.target.value)}
renderValue={(v) => sizeShort(v) + ' models'}
>
{sizes.map((s) => (
<MenuItem key={s.size} value={s.size}>{sizeShort(s.size)} models</MenuItem>
))}
</Select>
</FormControl>
)}
{history.length > 0 ? (
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<Box sx={{ width: '100%', maxWidth: { xs: '100%', sm: 680 } }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
{yTicks.map((t, i) => (
<g key={i}>
<line x1={padL} y1={t.y} x2={W - padR} y2={t.y} stroke="#d0d0d0" strokeWidth={0.5} />
<text x={padL - 10} y={t.y + 4} textAnchor="end" fontSize={13} fontWeight={500} fill="wheat">{t.v}</text>
</g>
))}
{areaPath && <path d={areaPath} fill="rgba(59,130,246,0.10)" />}
<path d={linePath} fill="none" stroke="#3b82f6" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
{history.map((h, i) => (
<g key={i}>
<circle cx={xFor(i)} cy={yFor(h.pts)} r={5} fill="#3b82f6" stroke="#fff" strokeWidth={2} />
<text x={xFor(i)} y={yFor(h.pts) - 12} textAnchor="middle" fontSize={14} fontWeight={700} fill="wheat">{h.pts}</text>
<text x={xFor(i)} y={H - padB + 20} textAnchor="middle" fontSize={11} fontWeight={500} fill="wheat">{fmtDate(h.date)}</text>
</g>
))}
</svg>
</Box>
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No historical data for this unit.
</Typography>
)}
{history.length > 1 && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{history[0].version}: {history[0].pts}pts
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{history[history.length - 1].version}: {history[history.length - 1].pts}pts
</Typography>
</Box>
)}
</Paper>
</Modal>
)
}
// ── Main App ──
import React, { useState, useMemo, useCallback, useEffect } from 'react'
import { Box, useMediaQuery, useTheme } from '@mui/material'
import { FilterBar } from './components/FilterBar.jsx'
import { MoversSection } from './components/MoversSection.jsx'
import { UnitTable } from './components/UnitTable.jsx'
import { GraphModal } from './components/GraphModal.jsx'
import { computeMovers } from './utils/movers.js'
import { buildColumns } from './utils/columns.jsx'
import { readFiltersFromUrl, writeFiltersToUrl } from './utils/url.js'
export default function App() {
const [data, setData] = useState(null)
// Read initial state from URL params
const params = new URLSearchParams(window.location.search)
const [query, setQuery] = useState(params.get('q') || '')
const [faction, setFaction] = useState(params.get('faction') || 'adepta-sororitas')
const [dir, setDir] = useState(params.get('dir') || '')
const initial = useMemo(() => readFiltersFromUrl(), [])
const [query, setQuery] = useState(initial.q)
const [faction, setFaction] = useState(initial.faction)
const [dir, setDir] = useState(initial.dir)
const [sizeChoice, setSizeChoice] = useState({})
const [modalRow, setModalRow] = useState(null)
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
React.useEffect(() => {
useEffect(() => {
fetch('./data.json').then(r => r.json()).then(setData).catch(console.error)
}, [])
// Sync filter state to URL whenever it changes
React.useEffect(() => {
const p = new URLSearchParams()
if (query) p.set('q', query)
if (faction) p.set('faction', faction)
if (dir) p.set('dir', dir)
const qs = p.toString()
const newUrl = qs ? `?${qs}` : window.location.pathname
window.history.replaceState(null, '', newUrl)
useEffect(() => {
writeFiltersToUrl({ q: query, faction, dir })
}, [query, faction, dir])
const selectSize = useCallback((row, sizeLabel) => {
@@ -274,234 +71,38 @@ export default function App() {
return view
}, [augmented, query, faction, dir])
// Movers — based on the currently filtered view
const movers = useMemo(() => {
if (!filtered.length) return { drops: [], rises: [] }
const drops = filtered.filter(u => u.change_pct !== null && u.change_pct < 0)
.sort((a, b) => a.change_pct - b.change_pct).slice(0, 5)
const rises = filtered.filter(u => u.change_pct !== null && u.change_pct > 0)
.sort((a, b) => b.change_pct - a.change_pct).slice(0, 5)
return { drops, rises }
}, [filtered])
const movers = useMemo(() => computeMovers(filtered), [filtered])
const showFactionCol = !faction
const showFactionInMovers = !faction // hide faction name in movers when filtered to a faction
// Columns: all flex-based
const columns = useMemo(() => {
const cols = [
{
field: 'name', headerName: 'Unit', flex: 3, minWidth: 80,
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', overflow: 'hidden' }}>
<Typography
variant="body2"
fontWeight={600}
sx={{
fontSize: { xs: '0.68rem', sm: '0.8rem' },
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
'&:hover': { textDecoration: 'underline', color: 'primary.main' },
cursor: 'pointer',
}}
>
{p.row.name}
</Typography>
</Box>
),
},
{
field: 'size', headerName: '#', flex: 0.6, minWidth: 36,
renderCell: (p) => <SizeCell row={p.row} onSelect={selectSize} />,
},
{
field: 'original', headerName: 'Old', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>{p.row.original ?? '—'}</Typography>
</Box>
),
},
{
field: 'new', headerName: 'New', flex: 0.8, minWidth: 36, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: { xs: '0.68rem', sm: '0.8rem' } }}>{p.row.new ?? '—'}</Typography>
</Box>
),
},
{
field: 'change_pct', headerName: 'Δ %', flex: 1, minWidth: 44, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: { xs: '0.68rem', sm: '0.8rem' }, color: changeColor(p.row.change_pct) }}>{pctLabel(p.row.change_pct)}</Typography>
</Box>
),
},
]
// Δ pts column — desktop only
if (!isMobile) {
cols.splice(4, 0, {
field: 'change_pts', headerName: 'Δ pts', flex: 0.8, minWidth: 40, align: 'right', headerAlign: 'right',
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%', height: '100%' }}>
<Typography sx={{ fontFamily: 'monospace', fontSize: { xs: '0.68rem', sm: '0.8rem' }, color: changeColor(p.row.change_pts) }}>{ptsLabel(p.row.change_pts)}</Typography>
</Box>
),
})
}
if (showFactionCol) {
cols.unshift({
field: 'faction_name', headerName: 'Faction', flex: 1.5, minWidth: 80,
renderCell: (p) => (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', height: '100%', overflow: 'hidden' }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.68rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.row.faction_name}</Typography>
</Box>
),
})
}
return cols
}, [selectSize, showFactionCol, isMobile])
const showFactionInMovers = !faction
const columns = useMemo(
() => buildColumns({ isMobile, showFactionCol, onSelectSize: selectSize }),
[isMobile, showFactionCol, selectSize],
)
if (!data) return <Box sx={{ p: 4, color: 'text.secondary' }}>Loading</Box>
return (
<Box data-testid="app-root" sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* Sticky filter bar */}
<Box sx={{
borderBottom: 1, borderColor: 'divider', bgcolor: 'background.paper',
position: 'sticky', top: 0, zIndex: 1100,
}}>
<Container maxWidth="xl" sx={{ py: { xs: 0.75, sm: 1 }, px: { xs: 1, sm: 2 } }}>
<Typography variant="h6" fontWeight={700} sx={{ display: { xs: 'none', sm: 'block' }, mb: 0.5, fontSize: '1.1rem' }}>
Points Comparator
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: 'none', sm: 'block' }, mb: 1, fontSize: '0.75rem' }}>
Codex vs. MFM v4.3 · {data.stats.total_rows.toLocaleString()} units · <span style={{ color: '#3fb950' }}>green = cheaper</span> · <span style={{ color: '#f85149' }}>red = costlier</span>
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 0.5, sm: 1 }} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
size="small"
label="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Unit name…"
sx={{ flex: 2, minWidth: { xs: '100%', sm: 180 } }}
/>
<Stack direction="row" spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
<InputLabel>Faction</InputLabel>
<Select value={faction} label="Faction" onChange={(e) => setFaction(e.target.value)}>
<MenuItem value="">All</MenuItem>
{data.factions.map(slug => (
<MenuItem key={slug} value={slug}>{data.faction_names[slug] || slug}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
<InputLabel>{isMobile ? 'Δ' : 'Change'}</InputLabel>
<Select value={dir} label={isMobile ? 'Δ' : 'Change'} onChange={(e) => setDir(e.target.value)}>
<MenuItem value="">All</MenuItem>
<MenuItem value="up"> Costlier</MenuItem>
<MenuItem value="down"> Cheaper</MenuItem>
<MenuItem value="no-change"> No change</MenuItem>
<MenuItem value="new-only">+ New only</MenuItem>
<MenuItem value="old-only"> Removed</MenuItem>
</Select>
</FormControl>
</Stack>
</Stack>
</Container>
</Box>
{/* Movers — based on current filtered view, click opens modal */}
<Container maxWidth="xl" sx={{ py: { xs: 0.5, sm: 2 }, px: { xs: 1, sm: 2 } }}>
<Box sx={{ display: 'flex', gap: { xs: 0.5, sm: 2 }, flexDirection: 'row' }}>
{/* Drops = cheaper = green (good for player) */}
<Paper sx={{ flex: 1, p: { xs: 0.5, sm: 2 }, borderRadius: { xs: 1, sm: 2 }, borderLeft: 3, borderColor: 'success.main', minWidth: 0 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.25, fontSize: { xs: '0.65rem', sm: '0.8rem' }, color: 'success.main' }}> Cheaper</Typography>
<Stack spacing={0}>
{movers.drops.map((u, i) => (
<Box key={i} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', py: 0.15, px: 0.25, borderRadius: 0.5, '&:hover': { bgcolor: 'rgba(63,185,80,0.08)' } }}
onClick={() => setModalRow(u)}>
<Box sx={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
<Typography variant="body2" fontWeight={600} sx={{ fontSize: { xs: '0.6rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', '&:hover': { color: 'primary.main' } }}>{u.name}</Typography>
{showFactionInMovers && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.55rem', sm: '0.7rem' }, display: { xs: 'none', sm: 'block' } }}>{u.faction_name}</Typography>
)}
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace', color: 'success.main', fontWeight: 700, fontSize: { xs: '0.55rem', sm: '0.8rem' }, whiteSpace: 'nowrap', ml: 0.5 }}>
{u.original}{u.new} ({pctLabel(u.change_pct)})
</Typography>
</Box>
))}
</Stack>
</Paper>
{/* Rises = costlier = red (bad for player) */}
<Paper sx={{ flex: 1, p: { xs: 0.5, sm: 2 }, borderRadius: { xs: 1, sm: 2 }, borderLeft: 3, borderColor: 'error.main', minWidth: 0 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.25, fontSize: { xs: '0.65rem', sm: '0.8rem' }, color: 'error.main' }}> Costlier</Typography>
<Stack spacing={0}>
{movers.rises.map((u, i) => (
<Box key={i} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', py: 0.15, px: 0.25, borderRadius: 0.5, '&:hover': { bgcolor: 'rgba(248,81,73,0.08)' } }}
onClick={() => setModalRow(u)}>
<Box sx={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
<Typography variant="body2" fontWeight={600} sx={{ fontSize: { xs: '0.6rem', sm: '0.8rem' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', '&:hover': { color: 'primary.main' } }}>{u.name}</Typography>
{showFactionInMovers && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.55rem', sm: '0.7rem' }, display: { xs: 'none', sm: 'block' } }}>{u.faction_name}</Typography>
)}
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace', color: 'error.main', fontWeight: 700, fontSize: { xs: '0.55rem', sm: '0.8rem' }, whiteSpace: 'nowrap', ml: 0.5 }}>
{u.original}{u.new} ({pctLabel(u.change_pct)})
</Typography>
</Box>
))}
</Stack>
</Paper>
</Box>
</Container>
{/* Data Grid */}
<Container maxWidth="xl" sx={{ pb: 4, px: { xs: 1, sm: 2 } }}>
<Paper sx={{ borderRadius: 2, overflow: 'hidden' }}>
{/* Count label — inside the table card, anchored */}
<Box sx={{ px: { xs: 1, sm: 2 }, py: 1, borderBottom: 1, borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.65rem', sm: '0.7rem' } }}>
<b style={{ color: 'text.primary' }}>{filtered.length.toLocaleString()}</b> units
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: { xs: '0.6rem', sm: '0.65rem' }, display: { xs: 'none', sm: 'block' } }}>
Click a unit for points history
</Typography>
</Box>
<DataGrid
rows={filtered}
columns={columns}
getRowId={(row) => `${row.faction}|${row.name}`}
density="compact"
autoHeight
hideFooter
disableColumnMenu
onCellClick={(p) => {
if (p.field === 'size') return
setModalRow(p.row)
}}
sx={{
border: 'none',
width: '100%',
'& .MuiDataGrid-columnHeaders': { bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' },
'& .MuiDataGrid-columnHeader': { fontSize: { xs: '0.6rem', sm: '0.75rem' }, textTransform: 'uppercase', fontWeight: 600 },
'& .MuiDataGrid-columnHeaderTitle': { fontSize: { xs: '0.6rem', sm: '0.75rem' } },
'& .MuiDataGrid-columnSeparator': { display: 'none' },
'& .MuiDataGrid-iconButtonContainer': { display: 'none' },
'& .MuiDataGrid-row': { cursor: 'pointer', '&:hover': { bgcolor: 'rgba(59,130,246,0.04)' } },
'& .MuiDataGrid-cell': { borderColor: 'divider', py: { xs: 0.5, sm: 0.5 }, display: 'flex', alignItems: 'center', overflow: 'hidden' },
'& .MuiDataGrid-virtualScroller': { overflowX: 'hidden' },
}}
/>
</Paper>
</Container>
{/* Graph Modal */}
<FilterBar
query={query} setQuery={setQuery}
faction={faction} setFaction={setFaction}
dir={dir} setDir={setDir}
factions={data.factions}
factionNames={data.faction_names}
isMobile={isMobile}
totalRows={data.stats.total_rows}
/>
<MoversSection
movers={movers}
onSelectUnit={setModalRow}
showFaction={showFactionInMovers}
/>
<UnitTable
rows={filtered}
columns={columns}
filteredCount={filtered.length}
onCellClick={setModalRow}
/>
<GraphModal row={modalRow} open={!!modalRow} onClose={() => setModalRow(null)} />
</Box>
)

View File

@@ -0,0 +1,164 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import fixture from './fixtures/data.json'
import { renderWithTheme } from './setup.js'
import App from '../App.jsx'
function mockFetch(data) {
globalThis.fetch = vi.fn(() =>
Promise.resolve({ json: () => Promise.resolve(data) }),
)
}
describe('App (end-to-end UI)', () => {
beforeEach(() => {
// Reset URL to bare pathname so each test starts fresh
window.history.replaceState(null, '', '/')
mockFetch(fixture)
})
it('shows a loading state then renders rows', async () => {
renderWithTheme(<App />)
expect(screen.getByText(/Loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument()
})
// Palatine appears in both the movers panel and the data grid
expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0)
})
it('filters rows by search query (case-insensitive)', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
await user.type(screen.getByLabelText('Search'), 'pala')
await waitFor(() => {
// Palatine is still visible (in both movers + grid)
expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0)
// Other units are filtered out
expect(screen.queryByText('Intercessor Squad')).not.toBeInTheDocument()
})
})
it('filters rows by faction and hides the Faction column', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
await user.click(screen.getByLabelText('Faction'))
await user.click(screen.getByRole('option', { name: 'Space Marines' }))
await waitFor(() => {
// Intercessor Squad appears in movers (drops, -5%) AND grid
expect(screen.getAllByText('Intercessor Squad').length).toBeGreaterThan(0)
expect(screen.queryByText('Palatine')).not.toBeInTheDocument()
// The "Faction" column header should NOT be visible when filtered to one faction
// (note: the FilterBar's "Faction" label is still present, so we scope to column headers)
expect(screen.queryByRole('columnheader', { name: 'Faction' })).not.toBeInTheDocument()
})
})
it('filters rows by change direction (down = cheaper only)', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
await user.click(screen.getByLabelText('Change'))
await user.click(screen.getByRole('option', { name: /Cheaper/ }))
await waitFor(() => {
expect(screen.getAllByText('Palatine').length).toBeGreaterThan(0)
// Redemptor Dreadnought is costlier, should be filtered out
expect(screen.queryByText('Redemptor Dreadnought')).not.toBeInTheDocument()
})
})
it('writes filters to the URL and rehydrates from them on load', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
await user.click(screen.getByLabelText('Faction'))
await user.click(screen.getByRole('option', { name: 'Space Marines' }))
await waitFor(() => {
expect(window.location.search).toContain('faction=space-marines')
})
})
it('rehydrates filters from URL params on load', async () => {
window.history.replaceState(null, '', '/?faction=necrons')
renderWithTheme(<App />)
await waitFor(() => {
// Only Necrons units visible
expect(screen.getByText('New Unit X')).toBeInTheDocument()
expect(screen.queryByText('Palatine')).not.toBeInTheDocument()
})
})
it('updates the active size of a multi-size unit when the # cell is changed', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
// Arco-flagellants appears in both movers (rises, +11.11%) and grid
await waitFor(() => screen.getAllByText('Arco-flagellants')[0])
// Arco-flagellants has 3 models (default) and 10 models
// The size cell combobox is the only combobox on the page that is NOT a FilterBar dropdown
// (FilterBar's comboboxes are labelled by filterbar-faction-label / filterbar-dir-label)
const sizeSelect = screen.getAllByRole('combobox').find(
c => c.getAttribute('aria-labelledby') !== 'filterbar-faction-label'
&& c.getAttribute('aria-labelledby') !== 'filterbar-dir-label',
)
await user.click(sizeSelect)
// Choose "10" (the larger size)
await user.click(screen.getByRole('option', { name: '10' }))
// The row's "New" value should update from 50 to 140
await waitFor(() => {
// The "10" option click should have changed the displayed value
// The Old and New for the 10-models size are both 140
const cells = screen.getAllByText('140')
expect(cells.length).toBeGreaterThan(0)
})
})
it('opens the GraphModal when a non-size cell is clicked', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getAllByText('Palatine')[0])
// Palatine's Old=50, New=40. "50" also appears in Arco-flagellants' New column,
// so we scope the click to Palatine's DataGrid row (data-id uses `${faction}|${name}`).
const palatineRow = document.querySelector('[data-id="adepta-sororitas|Palatine"]')
expect(palatineRow).not.toBeNull()
// Click the Old cell for Palatine (50)
await user.click(within(palatineRow).getByText('50'))
// Modal should now be visible with Palatine's name
await waitFor(() => {
// Both the grid cell and the modal header show "Palatine" — getAllByText
const matches = screen.getAllByText('Palatine')
expect(matches.length).toBeGreaterThan(1)
})
// The "No historical data" message should NOT be present (Palatine has history)
expect(screen.queryByText(/no historical data/i)).not.toBeInTheDocument()
})
it('shows "No historical data" for units with empty history', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getByText('New Unit X'))
// Click on the "New Unit X" row (no history)
await user.click(screen.getByText('New Unit X'))
await waitFor(() => {
expect(screen.getByText(/no historical data/i)).toBeInTheDocument()
})
})
it('movers cards reflect the filtered set and clicking a row opens the modal', async () => {
const user = userEvent.setup()
renderWithTheme(<App />)
await waitFor(() => screen.getByText('↓ Cheaper'))
// The cheapest unit is Palatine at -20%
// Click on the mover row containing Palatine
const cheaperPanel = screen.getByText('↓ Cheaper').parentElement
const palatineInPanel = within(cheaperPanel).getByText('Palatine')
await user.click(palatineInPanel)
// The modal opens (Palatine's name appears more than once)
await waitFor(() => {
const matches = screen.getAllByText('Palatine')
expect(matches.length).toBeGreaterThan(1)
})
})
})