feat(@projects/@magic-civilization): ✨ add progress report page navigation
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
bedf5e3c41
commit
c110b83db5
9 changed files with 683 additions and 4 deletions
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"generated_at": "2026-04-17T19:05:25Z",
|
||||
"generated_at": "2026-04-17T19:07:01Z",
|
||||
"totals": {
|
||||
"missing": 2,
|
||||
"done": 32,
|
||||
"partial": 10,
|
||||
"stub": 0,
|
||||
"oos": 4,
|
||||
"partial": 10,
|
||||
"missing": 2,
|
||||
"total": 48
|
||||
},
|
||||
"objectives": [
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
EpisodeDwarvesPage, FullGamePage, ExpansionsPage, ToolsPage,
|
||||
CrowdfundPage, TeamPage, EncyclopediaPage,
|
||||
LensesPage, KhazadPrimePlanetPage,
|
||||
DevSpritesPage,
|
||||
DevSpritesPage, ProgressReportPage,
|
||||
} from '@/app/lazy-pages'
|
||||
|
||||
const DEFAULT_PREFS: PlayerPreferences = { race: 'random', gender: 'random', name: '', colorMode: 'dark', dyslexicFont: false, fontSize: 'md' }
|
||||
|
|
@ -72,6 +72,7 @@ export default function App(): ReactElement {
|
|||
{/* About */}
|
||||
<Route path="/about/early-access" element={<EarlyAccessPage />} />
|
||||
<Route path="/about/early-access/progress" element={<EarlyAccessProgressPage />} />
|
||||
<Route path="/progress" element={<ProgressReportPage />} />
|
||||
<Route path="/about/crowdfund" element={<CrowdfundPage />} />
|
||||
<Route path="/about/team" element={<TeamPage />} />
|
||||
<Route path="/crowdfund" element={<Navigate to="/about/crowdfund" replace />} />
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ export const CrowdfundPage = lazy(() => import('@/pages/CrowdfundPage')
|
|||
export const TeamPage = lazy(() => import('@/pages/TeamPage'))
|
||||
export const EncyclopediaPage = lazy(() => import('@/pages/EncyclopediaPage'))
|
||||
|
||||
// Progress report (auto-generated objectives + asset pipeline status)
|
||||
export const ProgressReportPage = lazy(() => import('@/pages/ProgressReportPage'))
|
||||
|
||||
// Observation
|
||||
export const LensesPage = lazy(() => import('@/pages/LensesPage'))
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const NAV: NavGroup[] = [
|
|||
{
|
||||
title: 'About',
|
||||
items: [
|
||||
{ to: '/progress', icon: '📊', label: 'Progress Report' },
|
||||
{ to: '/about/early-access', icon: '🚀', label: 'Early Access' },
|
||||
{ to: '/about/crowdfund', icon: '🎯', label: 'Crowdfund' },
|
||||
{ to: '/about/team', icon: '👥', label: 'Team' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,353 @@
|
|||
import { useMemo, useState, type ReactElement } from 'react'
|
||||
import {
|
||||
FadeIn,
|
||||
PageTitle,
|
||||
PageHeading,
|
||||
PageSubtitle,
|
||||
Section,
|
||||
SectionHeading,
|
||||
Prose,
|
||||
FilterRow,
|
||||
FilterChip,
|
||||
} from '@magic-civ/guide-engine'
|
||||
import objectivesData from '@data/objectives.json'
|
||||
import audioManifest from '@data/audio.json'
|
||||
import { ObjectiveModal } from './progress-report/ObjectiveModal'
|
||||
import {
|
||||
STATUS_ICON,
|
||||
STATUS_LABEL,
|
||||
type ObjectivePriority,
|
||||
type ObjectiveRecord,
|
||||
type ObjectivesExport,
|
||||
type ObjectiveStatus,
|
||||
type StatusFilter,
|
||||
} from './progress-report/types'
|
||||
import {
|
||||
buildAssetGroup,
|
||||
collectExpectedAudioPaths,
|
||||
collectExpectedBuildingSpritePaths,
|
||||
collectExpectedUnitSpritePaths,
|
||||
normalisePresentPaths,
|
||||
} from './progress-report/assets-detection'
|
||||
import {
|
||||
countStatusesForPriority,
|
||||
filterObjectives,
|
||||
groupByPriority,
|
||||
} from './progress-report/filter'
|
||||
import {
|
||||
TotalsGrid,
|
||||
TotalCard,
|
||||
TotalValue,
|
||||
TotalLabel,
|
||||
PriorityBar,
|
||||
PriorityBarHeader,
|
||||
PriorityBarTrack,
|
||||
PriorityBarSegment,
|
||||
StatusBadge,
|
||||
PriorityChip,
|
||||
ObjectivesTable,
|
||||
ObjectiveRow,
|
||||
IdCell,
|
||||
TitleCell,
|
||||
OwnerCell,
|
||||
DeferredDisclosure,
|
||||
AssetGroupBlock,
|
||||
AssetGroupHeader,
|
||||
AssetGroupSummaryText,
|
||||
AssetProgressTrack,
|
||||
AssetProgressFill,
|
||||
AssetList,
|
||||
AssetItem,
|
||||
Meta,
|
||||
} from './progress-report/styled'
|
||||
|
||||
const PRIORITY_LABEL: Record<ObjectivePriority, string> = {
|
||||
p0: 'P0 — Blockers for completely playable',
|
||||
p1: 'P1 — Ship readiness',
|
||||
p2: 'P2 — Polish',
|
||||
}
|
||||
|
||||
// ─── Asset-presence detection via Vite glob ─────────────────────────────────
|
||||
// Glob keys are resolved at build time; values are unused. Presence is purely
|
||||
// "does this filename exist under the publicDir?".
|
||||
const audioGlob = import.meta.glob('/audio/**/*.ogg', { eager: true })
|
||||
const unitSpriteGlob = import.meta.glob('/sprites/units/*.png', { eager: true })
|
||||
const buildingSpriteGlob = import.meta.glob('/sprites/buildings/*.png', { eager: true })
|
||||
|
||||
interface UnitJson { id?: string; sprite?: string; gender?: { male?: { sprite?: string }; female?: { sprite?: string } } }
|
||||
interface BuildingJson { id?: string; sprite?: string }
|
||||
const unitFileGlob = import.meta.glob<UnitJson[]>('@data/units/*.json', { eager: true, import: 'default' })
|
||||
const buildingFileGlob = import.meta.glob<BuildingJson[]>('@data/buildings/*.json', { eager: true, import: 'default' })
|
||||
|
||||
function useAssetGroups(): ReturnType<typeof buildAssetGroups> {
|
||||
return useMemo(() => buildAssetGroups(), [])
|
||||
}
|
||||
|
||||
function buildAssetGroups(): {
|
||||
audio: ReturnType<typeof buildAssetGroup>
|
||||
unitSprites: ReturnType<typeof buildAssetGroup>
|
||||
buildingSprites: ReturnType<typeof buildAssetGroup>
|
||||
} {
|
||||
const expectedAudio = collectExpectedAudioPaths(audioManifest)
|
||||
const audioPresent = normalisePresentPaths(Object.keys(audioGlob))
|
||||
|
||||
const unitFiles = Object.values(unitFileGlob).filter(
|
||||
(v): v is UnitJson[] => Array.isArray(v),
|
||||
)
|
||||
const buildingFiles = Object.values(buildingFileGlob).filter(
|
||||
(v): v is BuildingJson[] => Array.isArray(v),
|
||||
)
|
||||
const expectedUnitSprites = collectExpectedUnitSpritePaths(unitFiles)
|
||||
const expectedBuildingSprites = collectExpectedBuildingSpritePaths(buildingFiles)
|
||||
|
||||
const unitSpritesPresent = normalisePresentPaths(Object.keys(unitSpriteGlob))
|
||||
const buildingSpritesPresent = normalisePresentPaths(Object.keys(buildingSpriteGlob))
|
||||
|
||||
return {
|
||||
audio: buildAssetGroup('Audio', expectedAudio, audioPresent),
|
||||
unitSprites: buildAssetGroup('Unit sprites', expectedUnitSprites, unitSpritesPresent),
|
||||
buildingSprites: buildAssetGroup(
|
||||
'Building sprites',
|
||||
expectedBuildingSprites,
|
||||
buildingSpritesPresent,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProgressReportPage(): ReactElement {
|
||||
const data = objectivesData as ObjectivesExport
|
||||
const [filter, setFilter] = useState<StatusFilter>('all')
|
||||
const [activeObjective, setActiveObjective] = useState<ObjectiveRecord | null>(null)
|
||||
|
||||
const inScope = useMemo(
|
||||
() => data.objectives.filter((o) => o.scope === 'game1'),
|
||||
[data.objectives],
|
||||
)
|
||||
const deferred = useMemo(
|
||||
() => data.objectives.filter((o) => o.scope === 'game2'),
|
||||
[data.objectives],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => filterObjectives(inScope, filter), [inScope, filter])
|
||||
|
||||
const groupedByPriority = useMemo(() => groupByPriority(filtered), [filtered])
|
||||
|
||||
const assetGroups = useAssetGroups()
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<PageTitle>
|
||||
<PageHeading>Progress Report</PageHeading>
|
||||
<PageSubtitle>
|
||||
Live view of the objectives dashboard and the asset pipeline. Generated
|
||||
from <code>.project/objectives/</code> frontmatter on every{' '}
|
||||
<code>./run verify</code> — {data.totals.total} objectives tracked.
|
||||
</PageSubtitle>
|
||||
</PageTitle>
|
||||
|
||||
<Section>
|
||||
<SectionHeading>Overall Status</SectionHeading>
|
||||
<TotalsGrid>
|
||||
{(['done', 'partial', 'stub', 'missing', 'oos'] as ObjectiveStatus[]).map((s) => (
|
||||
<TotalCard key={s}>
|
||||
<TotalValue>
|
||||
<span aria-hidden="true">{STATUS_ICON[s]}</span> {data.totals[s]}
|
||||
</TotalValue>
|
||||
<TotalLabel>{STATUS_LABEL[s]}</TotalLabel>
|
||||
</TotalCard>
|
||||
))}
|
||||
<TotalCard>
|
||||
<TotalValue>{data.totals.total}</TotalValue>
|
||||
<TotalLabel>Total</TotalLabel>
|
||||
</TotalCard>
|
||||
</TotalsGrid>
|
||||
|
||||
{(['p0', 'p1', 'p2'] as ObjectivePriority[]).map((p) =>
|
||||
renderPriorityBar(p, inScope),
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHeading>Objectives</SectionHeading>
|
||||
<FilterRow>
|
||||
<FilterChip type="button" $active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
All ({inScope.length})
|
||||
</FilterChip>
|
||||
<FilterChip type="button" $active={filter === 'p0'} onClick={() => setFilter('p0')}>
|
||||
P0 only
|
||||
</FilterChip>
|
||||
<FilterChip type="button" $active={filter === 'partial'} onClick={() => setFilter('partial')}>
|
||||
Partial only
|
||||
</FilterChip>
|
||||
<FilterChip type="button" $active={filter === 'missing'} onClick={() => setFilter('missing')}>
|
||||
Missing / stub
|
||||
</FilterChip>
|
||||
</FilterRow>
|
||||
|
||||
{(['p0', 'p1', 'p2'] as ObjectivePriority[]).map((p) => {
|
||||
const rows = groupedByPriority[p]
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<div key={p}>
|
||||
<SectionHeading style={{ fontSize: '0.8125rem', marginTop: '1.25rem' }}>
|
||||
{PRIORITY_LABEL[p]} ({rows.length})
|
||||
</SectionHeading>
|
||||
<ObjectivesTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>Title</th>
|
||||
<th>Owner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((o) => (
|
||||
<ObjectiveRow
|
||||
key={o.id}
|
||||
$clickable
|
||||
onClick={() => setActiveObjective(o)}
|
||||
>
|
||||
<IdCell>{o.id.toUpperCase()}</IdCell>
|
||||
<td>
|
||||
<StatusBadge $status={o.status}>
|
||||
<span aria-hidden="true">{STATUS_ICON[o.status]}</span>
|
||||
{STATUS_LABEL[o.status]}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
<TitleCell>{o.title}</TitleCell>
|
||||
<OwnerCell>{o.owner ?? '—'}</OwnerCell>
|
||||
</ObjectiveRow>
|
||||
))}
|
||||
</tbody>
|
||||
</ObjectivesTable>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{deferred.length > 0 && (
|
||||
<DeferredDisclosure>
|
||||
<summary>
|
||||
Deferred to Game 2 — Age of Kzzykt ({deferred.length})
|
||||
</summary>
|
||||
<Prose style={{ marginTop: '0.75rem' }}>
|
||||
These objectives are explicitly future-scope. They are not part of
|
||||
the Age of Dwarves Early Access release.
|
||||
</Prose>
|
||||
<ObjectivesTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Priority</th>
|
||||
<th>Title</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deferred.map((o) => (
|
||||
<ObjectiveRow
|
||||
key={o.id}
|
||||
$clickable
|
||||
onClick={() => setActiveObjective(o)}
|
||||
>
|
||||
<IdCell>{o.id.toUpperCase()}</IdCell>
|
||||
<td>
|
||||
<PriorityChip $priority={o.priority}>
|
||||
{o.priority.toUpperCase()}
|
||||
</PriorityChip>
|
||||
</td>
|
||||
<TitleCell>{o.title}</TitleCell>
|
||||
</ObjectiveRow>
|
||||
))}
|
||||
</tbody>
|
||||
</ObjectivesTable>
|
||||
</DeferredDisclosure>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHeading>Asset Pipeline</SectionHeading>
|
||||
<Prose>
|
||||
Audio and sprite files declared in the game data are checked against
|
||||
what actually ships in the assets tree. Missing files don't block
|
||||
gameplay — audio falls back to silence and sprites fall back to a
|
||||
placeholder — but they do need to land before Early Access.
|
||||
</Prose>
|
||||
{[assetGroups.audio, assetGroups.unitSprites, assetGroups.buildingSprites].map(
|
||||
(g) => (
|
||||
<AssetGroupBlock key={g.label}>
|
||||
<AssetGroupHeader>
|
||||
<span>{g.label}</span>
|
||||
<AssetGroupSummaryText>
|
||||
{g.present}/{g.total} present
|
||||
</AssetGroupSummaryText>
|
||||
</AssetGroupHeader>
|
||||
<AssetProgressTrack>
|
||||
<AssetProgressFill $pct={g.total === 0 ? 0 : (g.present / g.total) * 100} />
|
||||
</AssetProgressTrack>
|
||||
<AssetList>
|
||||
{g.items.map((item) => (
|
||||
<AssetItem
|
||||
key={item.expectedPath}
|
||||
$present={item.present}
|
||||
title={item.expectedPath}
|
||||
>
|
||||
{item.label}
|
||||
</AssetItem>
|
||||
))}
|
||||
</AssetList>
|
||||
</AssetGroupBlock>
|
||||
),
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Meta>Objectives generated {formatDate(data.generated_at)}.</Meta>
|
||||
|
||||
{activeObjective && (
|
||||
<ObjectiveModal
|
||||
objective={activeObjective}
|
||||
onClose={() => setActiveObjective(null)}
|
||||
/>
|
||||
)}
|
||||
</FadeIn>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPriorityBar(priority: ObjectivePriority, objectives: ObjectiveRecord[]): ReactElement | null {
|
||||
const counts = countStatusesForPriority(objectives, priority)
|
||||
if (counts.total === 0) return null
|
||||
const { total } = counts
|
||||
|
||||
return (
|
||||
<PriorityBar key={priority}>
|
||||
<PriorityBarHeader>
|
||||
<span>
|
||||
<PriorityChip $priority={priority}>{priority.toUpperCase()}</PriorityChip>{' '}
|
||||
{PRIORITY_LABEL[priority].replace(/^p[012] — /, '')}
|
||||
</span>
|
||||
<span>
|
||||
{counts.done}/{total} done
|
||||
</span>
|
||||
</PriorityBarHeader>
|
||||
<PriorityBarTrack>
|
||||
{(['done', 'partial', 'stub', 'missing'] as ObjectiveStatus[]).map((s) =>
|
||||
counts[s] > 0 ? (
|
||||
<PriorityBarSegment
|
||||
key={s}
|
||||
$status={s}
|
||||
$pct={(counts[s] / total) * 100}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</PriorityBarTrack>
|
||||
</PriorityBar>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return 'unknown'
|
||||
try {
|
||||
return new Date(iso).toISOString().replace('T', ' ').replace('Z', ' UTC')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import {
|
||||
buildAssetGroup,
|
||||
collectExpectedAudioPaths,
|
||||
collectExpectedBuildingSpritePaths,
|
||||
collectExpectedUnitSpritePaths,
|
||||
normalisePresentPaths,
|
||||
} from '@/pages/progress-report/assets-detection'
|
||||
|
||||
describe('collectExpectedAudioPaths', () => {
|
||||
it('lists every sfx and music track stream, normalised relative to assets/', () => {
|
||||
const manifest = {
|
||||
sfx: {
|
||||
turn_started: { stream: 'audio/sfx/turn_started.ogg' },
|
||||
city_founded: { stream: 'audio/sfx/city_founded.ogg' },
|
||||
},
|
||||
music: {
|
||||
tracks: [
|
||||
{ stream: 'audio/music/overworld_awakening.ogg' },
|
||||
{ stream: 'audio/music/victory.ogg' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const paths = collectExpectedAudioPaths(manifest)
|
||||
expect(paths).toHaveLength(4)
|
||||
expect(paths).toContain('audio/sfx/turn_started.ogg')
|
||||
expect(paths).toContain('audio/music/victory.ogg')
|
||||
})
|
||||
|
||||
it('skips entries without a stream field', () => {
|
||||
const manifest = {
|
||||
sfx: { broken: {}, ok: { stream: 'audio/sfx/ok.ogg' } },
|
||||
music: { tracks: [{}, { stream: 'audio/music/x.ogg' }] },
|
||||
}
|
||||
expect(collectExpectedAudioPaths(manifest)).toEqual([
|
||||
'audio/sfx/ok.ogg',
|
||||
'audio/music/x.ogg',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectExpectedUnitSpritePaths', () => {
|
||||
it('prefers per-gender sprite paths when present', () => {
|
||||
const files = [
|
||||
[
|
||||
{
|
||||
id: 'archer',
|
||||
sprite: 'sprites/units/archer.png',
|
||||
gender: {
|
||||
male: { sprite: 'sprites/units/archer_m.png' },
|
||||
female: { sprite: 'sprites/units/archer_f.png' },
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
const paths = collectExpectedUnitSpritePaths(files)
|
||||
expect(paths).toEqual([
|
||||
'sprites/units/archer_f.png',
|
||||
'sprites/units/archer_m.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to top-level sprite when no gender variants declared', () => {
|
||||
const files = [[{ id: 'dire_wolf', sprite: 'sprites/units/dire_wolf.png' }]]
|
||||
expect(collectExpectedUnitSpritePaths(files)).toEqual([
|
||||
'sprites/units/dire_wolf.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates sprites shared across files and sorts the output', () => {
|
||||
const files = [
|
||||
[{ id: 'a', sprite: 'sprites/units/a.png' }],
|
||||
[{ id: 'b', sprite: 'sprites/units/b.png' }],
|
||||
[{ id: 'a', sprite: 'sprites/units/a.png' }],
|
||||
]
|
||||
expect(collectExpectedUnitSpritePaths(files)).toEqual([
|
||||
'sprites/units/a.png',
|
||||
'sprites/units/b.png',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectExpectedBuildingSpritePaths', () => {
|
||||
it('extracts sprite paths per building entry', () => {
|
||||
const files = [
|
||||
[
|
||||
{ id: 'forge', sprite: 'sprites/buildings/forge.png' },
|
||||
{ id: 'barracks', sprite: 'sprites/buildings/barracks.png' },
|
||||
],
|
||||
[{ id: 'granary', sprite: 'sprites/buildings/granary.png' }],
|
||||
]
|
||||
const paths = collectExpectedBuildingSpritePaths(files)
|
||||
expect(paths).toEqual([
|
||||
'sprites/buildings/barracks.png',
|
||||
'sprites/buildings/forge.png',
|
||||
'sprites/buildings/granary.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores entries without a sprite field', () => {
|
||||
const files = [[{ id: 'stub' }, { id: 'ok', sprite: 'sprites/buildings/ok.png' }]]
|
||||
expect(collectExpectedBuildingSpritePaths(files)).toEqual([
|
||||
'sprites/buildings/ok.png',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalisePresentPaths', () => {
|
||||
it('strips the leading path up to and including /assets/', () => {
|
||||
const keys = [
|
||||
'/opt/repo/public/games/age-of-dwarves/assets/sprites/units/archer_m.png',
|
||||
'/assets/sprites/buildings/granary.png',
|
||||
]
|
||||
expect(normalisePresentPaths(keys)).toEqual(
|
||||
new Set(['sprites/units/archer_m.png', 'sprites/buildings/granary.png']),
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves relative paths alone after stripping leading slashes', () => {
|
||||
expect(normalisePresentPaths(['sprites/units/x.png'])).toEqual(
|
||||
new Set(['sprites/units/x.png']),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAssetGroup', () => {
|
||||
it('marks each expected path present or absent based on the present set', () => {
|
||||
const group = buildAssetGroup(
|
||||
'Unit sprites',
|
||||
['sprites/units/archer_m.png', 'sprites/units/worker_m.png'],
|
||||
new Set(['sprites/units/archer_m.png']),
|
||||
)
|
||||
expect(group.total).toBe(2)
|
||||
expect(group.present).toBe(1)
|
||||
expect(group.items.map((i) => [i.label, i.present])).toEqual([
|
||||
['archer_m.png', true],
|
||||
['worker_m.png', false],
|
||||
])
|
||||
})
|
||||
|
||||
it('reports zero present when nothing matches', () => {
|
||||
const group = buildAssetGroup(
|
||||
'Audio',
|
||||
['audio/sfx/a.ogg', 'audio/sfx/b.ogg'],
|
||||
new Set(),
|
||||
)
|
||||
expect(group.present).toBe(0)
|
||||
expect(group.items.every((i) => !i.present)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
countStatusesForPriority,
|
||||
filterObjectives,
|
||||
groupByPriority,
|
||||
} from '@/pages/progress-report/filter'
|
||||
import type { ObjectiveRecord } from '@/pages/progress-report/types'
|
||||
|
||||
const fixture: ObjectiveRecord[] = [
|
||||
mk('p0-01', 'p0', 'done'),
|
||||
mk('p0-02', 'p0', 'partial'),
|
||||
mk('p0-03', 'p0', 'missing'),
|
||||
mk('p1-01', 'p1', 'done'),
|
||||
mk('p1-02', 'p1', 'stub'),
|
||||
mk('p2-01', 'p2', 'partial'),
|
||||
mk('p2-02', 'p2', 'done'),
|
||||
]
|
||||
|
||||
function mk(
|
||||
id: string,
|
||||
priority: ObjectiveRecord['priority'],
|
||||
status: ObjectiveRecord['status'],
|
||||
): ObjectiveRecord {
|
||||
return {
|
||||
id, priority, status,
|
||||
title: `Title ${id}`,
|
||||
scope: 'game1',
|
||||
owner: null,
|
||||
updated_at: '2026-04-17',
|
||||
summary: '',
|
||||
}
|
||||
}
|
||||
|
||||
describe('filterObjectives', () => {
|
||||
it('returns the full list when filter is "all"', () => {
|
||||
expect(filterObjectives(fixture, 'all')).toHaveLength(fixture.length)
|
||||
})
|
||||
|
||||
it('keeps only P0 rows under the "p0" filter', () => {
|
||||
const out = filterObjectives(fixture, 'p0')
|
||||
expect(out.map((o) => o.id)).toEqual(['p0-01', 'p0-02', 'p0-03'])
|
||||
})
|
||||
|
||||
it('keeps only partial rows under the "partial" filter', () => {
|
||||
const out = filterObjectives(fixture, 'partial')
|
||||
expect(out.every((o) => o.status === 'partial')).toBe(true)
|
||||
expect(out).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps missing AND stub rows under the "missing" filter', () => {
|
||||
const out = filterObjectives(fixture, 'missing')
|
||||
expect(out.map((o) => o.id).sort()).toEqual(['p0-03', 'p1-02'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupByPriority', () => {
|
||||
it('partitions by priority and preserves input order within each group', () => {
|
||||
const groups = groupByPriority(fixture)
|
||||
expect(groups.p0.map((o) => o.id)).toEqual(['p0-01', 'p0-02', 'p0-03'])
|
||||
expect(groups.p1.map((o) => o.id)).toEqual(['p1-01', 'p1-02'])
|
||||
expect(groups.p2.map((o) => o.id)).toEqual(['p2-01', 'p2-02'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countStatusesForPriority', () => {
|
||||
it('counts statuses for a specific priority only', () => {
|
||||
const counts = countStatusesForPriority(fixture, 'p0')
|
||||
expect(counts).toEqual({
|
||||
done: 1, partial: 1, stub: 0, missing: 1, oos: 0, total: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns zeros when no objectives match the priority', () => {
|
||||
const counts = countStatusesForPriority([], 'p1')
|
||||
expect(counts.total).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import objectivesData from '@data/objectives.json'
|
||||
import type { ObjectivesExport, ObjectiveStatus } from '@/pages/progress-report/types'
|
||||
|
||||
const VALID_STATUSES: ReadonlySet<ObjectiveStatus> = new Set<ObjectiveStatus>([
|
||||
'done', 'partial', 'stub', 'missing', 'oos',
|
||||
])
|
||||
|
||||
const data = objectivesData as ObjectivesExport
|
||||
|
||||
describe('objectives.json (generated by tools/objectives-report.py)', () => {
|
||||
it('has a generated_at timestamp in the expected ISO-like shape', () => {
|
||||
expect(typeof data.generated_at).toBe('string')
|
||||
expect(data.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||
})
|
||||
|
||||
it('totals sum matches the objectives array length', () => {
|
||||
const sum = (['done', 'partial', 'stub', 'missing', 'oos'] as ObjectiveStatus[]).reduce(
|
||||
(acc, k) => acc + data.totals[k],
|
||||
0,
|
||||
)
|
||||
expect(sum).toBe(data.totals.total)
|
||||
expect(sum).toBe(data.objectives.length)
|
||||
})
|
||||
|
||||
it('every objective has the required frontmatter fields', () => {
|
||||
for (const o of data.objectives) {
|
||||
expect(typeof o.id).toBe('string')
|
||||
expect(o.id).toMatch(/^p[012]-\d{2}$/)
|
||||
expect(typeof o.title).toBe('string')
|
||||
expect(o.title.length).toBeGreaterThan(0)
|
||||
expect(['p0', 'p1', 'p2']).toContain(o.priority)
|
||||
expect(VALID_STATUSES.has(o.status)).toBe(true)
|
||||
expect(['game1', 'game2']).toContain(o.scope)
|
||||
expect(typeof o.updated_at).toBe('string')
|
||||
expect(typeof o.summary).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('all game2-scoped objectives are out-of-scope', () => {
|
||||
for (const o of data.objectives) {
|
||||
if (o.scope === 'game2') expect(o.status).toBe('oos')
|
||||
}
|
||||
})
|
||||
|
||||
it('at least one objective has a non-empty summary (exported from prose body)', () => {
|
||||
const withSummary = data.objectives.filter((o) => o.summary.length > 0)
|
||||
expect(withSummary.length).toBeGreaterThan(30)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { ObjectivePriority, ObjectiveRecord, ObjectiveStatus, StatusFilter } from './types'
|
||||
|
||||
export function filterObjectives(
|
||||
objectives: ObjectiveRecord[],
|
||||
filter: StatusFilter,
|
||||
): ObjectiveRecord[] {
|
||||
switch (filter) {
|
||||
case 'all': return objectives
|
||||
case 'p0': return objectives.filter((o) => o.priority === 'p0')
|
||||
case 'partial': return objectives.filter((o) => o.status === 'partial')
|
||||
case 'missing':
|
||||
return objectives.filter((o) => o.status === 'missing' || o.status === 'stub')
|
||||
}
|
||||
}
|
||||
|
||||
export function groupByPriority(
|
||||
objectives: ObjectiveRecord[],
|
||||
): Record<ObjectivePriority, ObjectiveRecord[]> {
|
||||
const groups: Record<ObjectivePriority, ObjectiveRecord[]> = { p0: [], p1: [], p2: [] }
|
||||
for (const o of objectives) groups[o.priority].push(o)
|
||||
return groups
|
||||
}
|
||||
|
||||
export interface PriorityStatusCounts {
|
||||
done: number
|
||||
partial: number
|
||||
stub: number
|
||||
missing: number
|
||||
oos: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function countStatusesForPriority(
|
||||
objectives: ObjectiveRecord[],
|
||||
priority: ObjectivePriority,
|
||||
): PriorityStatusCounts {
|
||||
const counts: PriorityStatusCounts = {
|
||||
done: 0, partial: 0, stub: 0, missing: 0, oos: 0, total: 0,
|
||||
}
|
||||
for (const o of objectives) {
|
||||
if (o.priority !== priority) continue
|
||||
const s: ObjectiveStatus = o.status
|
||||
counts[s] += 1
|
||||
counts.total += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue