mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-07-04 11:43:12 +02:00
Adds a React/TypeScript frontend (web/) with a Python stdlib HTTP server backend. The UI mirrors the CLI `stats` command in the browser, with real-time sliders for ProtonDB rating, HLTB confidence thresholds, daily play time, per-game time cap, playtime mode, no-HLTB-data fallback, and a target-date planner. A parity badge confirms the client-side totals reproduce the CLI defaults exactly (786 / 67031.1h / 143017.2h / 238447.9h). Python side: - `_web_dataset.py`: offline projection of HLTB/ProtonDB/snapshot caches into a compact, secret-free JSON payload; 100% branch coverage - `_web_server.py`: zero-dependency stdlib HTTP server serving the built Vite bundle and the /api/dataset endpoint; 100% branch coverage - `main.py`: new `serve` command wiring Frontend (Vitest + RTL, 100% branch coverage enforced): - TypeScript port of ProtonDB compound rating rule with full parity - Pure client-side filtering via estimate.ts (no server round-trips) - SVG completion timeline chart, sortable/searchable game table - Steam dark theme; responsive layout Pre-commit: adds `vitest-coverage` hook at pre-push stage requiring 100% branch coverage on the React codebase. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
import { render, screen, waitFor, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import App from './App'
|
|
import { makeDataset, makeGame, makeState } from './test/factories'
|
|
|
|
describe('App', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('renders the planner after data loads', async () => {
|
|
const ds = makeDataset([makeGame({ app_id: 1, name: 'Alpha' })])
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({ ok: true, json: async () => ds }),
|
|
)
|
|
render(<App />)
|
|
expect(screen.getByText(/Loading your backlog/i)).toBeInTheDocument()
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole('heading', { name: 'Backlog Completion Planner' }),
|
|
).toBeInTheDocument(),
|
|
)
|
|
expect(screen.getByText(/CLI default qualifies/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows an error when the API call fails', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }),
|
|
)
|
|
render(<App />)
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/Could not load data/i)).toBeInTheDocument(),
|
|
)
|
|
})
|
|
|
|
it('handles a non-Error rejection', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('network down'))
|
|
render(<App />)
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/network down/i)).toBeInTheDocument(),
|
|
)
|
|
})
|
|
|
|
it('recomputes scope when basis changes and a game is excluded', async () => {
|
|
const ds = makeDataset(
|
|
[makeGame({ app_id: 1, name: 'Alpha' }), makeGame({ app_id: 2, name: 'Beta' })],
|
|
{
|
|
state: makeState({
|
|
current_game_name: 'Hollow Knight',
|
|
enforcement_started_at: '2026-03-04T00:00:00+00:00',
|
|
pace_games_per_day: 0.9,
|
|
}),
|
|
},
|
|
)
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({ ok: true, json: async () => ds }),
|
|
)
|
|
const user = userEvent.setup()
|
|
render(<App />)
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole('heading', { name: 'Backlog Completion Planner' }),
|
|
).toBeInTheDocument(),
|
|
)
|
|
// The current game appears in the header (covers the conditional branch).
|
|
expect(screen.getByText(/Hollow Knight/)).toBeInTheDocument()
|
|
expect(document.querySelector('.big')?.textContent).toBe('2')
|
|
|
|
// Switching basis promotes the Rush card to active.
|
|
await user.click(screen.getByRole('button', { name: 'Rush' }))
|
|
expect(document.querySelector('.card.active .card-title')?.textContent).toBe('Rush')
|
|
|
|
// Excluding a game drops the in-scope count.
|
|
await user.click(within(screen.getByRole('table')).getAllByRole('checkbox')[0])
|
|
expect(document.querySelector('.big')?.textContent).toBe('1')
|
|
|
|
// Re-including it restores the count (covers the toggle-off branch).
|
|
await user.click(within(screen.getByRole('table')).getAllByRole('checkbox')[0])
|
|
expect(document.querySelector('.big')?.textContent).toBe('2')
|
|
|
|
// Searching narrows the table (covers the search handler).
|
|
await user.type(screen.getByPlaceholderText(/Search games/i), 'Alpha')
|
|
expect(within(screen.getByRole('table')).queryByText('Beta')).toBeNull()
|
|
|
|
// Reset restores the full scope (covers the reset handler).
|
|
await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i }))
|
|
expect(document.querySelector('.big')?.textContent).toBe('2')
|
|
})
|
|
})
|