web: tests close coverage gaps after rework

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 12:08:55 +01:00
parent 5b31b19ea7
commit 1d6d3469eb
13 changed files with 173 additions and 10 deletions

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import type { PromptEvent } from './hub'
import { promptForm } from './promptForm'
const choice = (options: { value: string; label: string }[]): PromptEvent => ({
seq: 1, ts: 't', type: 'prompt', prompt_id: 'q1', kind: 'choice', message: 'Which one?', options,
})
describe('choice prompt own-game escape', () => {
it('paints the escape as a glyph and speaks its label; game names stay plain text', () => {
const form = promptForm('j', choice([{ value: 'a', label: 'Alpha' }, { value: 'own', label: 'Pick my own game' }]))
document.body.append(form)
const own = form.querySelector('[data-value=own] .choice-label')
expect(own?.querySelector('[aria-hidden="true"]')?.textContent).toBe('🗂️')
expect(own?.querySelector('.sr-only')?.textContent).toBe('Pick my own game')
expect(form.querySelector('[data-value=a] .choice-label')?.textContent).toBe('Alpha')
})
})

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { makeLibGame } from '../test/fixtures'
import { completion, filterGames, isCompleted, metaLine, NO_FILTERS, sortGames } from './view'
import { completion, filterGames, isCompleted, metaGlyphs, metaLine, NO_FILTERS, reasonGlyph, reasonOrMeta, sortGames } from './view'
const g = makeLibGame
@ -68,3 +68,41 @@ describe('metaLine', () => {
expect(metaLine(g({ achievements_total: 0, hltb_hours: null, playtime_minutes: 0 }))).toBe('No achievements')
})
})
describe('metaGlyphs', () => {
it('paints trophy, clock and play with their values', () => {
expect(metaGlyphs(g({ achievements_total: 8, achievements_unlocked: 3, hltb_hours: 12, playtime_minutes: 192 })))
.toBe('🏆 37% · ⏱️ 12 h · ▶️ 3.2 h')
})
it('paints a dash for no achievements and drops unknown HLTB and zero playtime', () => {
expect(metaGlyphs(g({ achievements_total: 0, hltb_hours: null, playtime_minutes: 0 }))).toBe('🏆 —')
})
})
describe('reasonGlyph', () => {
it.each([
['Already 100% complete', '🏆✅'],
['No achievements', '🏆🚫'],
['Skipped by you', '⏭️'],
['Skipped until 2026-09-01', '⏭️ 2026-09-01'],
['Something else', '⛔'],
['Blocked until 2026-10-02', '⛔ 2026-10-02'],
])('%s paints %s', (reason, painted) => {
expect(reasonGlyph(reason)).toBe(painted)
})
})
describe('reasonOrMeta', () => {
it('paints the meta glyphs and speaks the meta line when pickable', () => {
const game = g({ achievements_total: 8, achievements_unlocked: 3, hltb_hours: 12, playtime_minutes: 192 })
const el = reasonOrMeta(null, game, 'sub')
expect(el.className).toBe('sub')
expect(el.querySelector('[aria-hidden="true"]')?.textContent).toBe(metaGlyphs(game))
expect(el.querySelector('.sr-only')?.textContent).toBe(metaLine(game))
})
it('paints the reason glyph and speaks the reason when not pickable', () => {
const el = reasonOrMeta('No achievements', g(), 'sub')
expect(el.querySelector('[aria-hidden="true"]')?.textContent).toBe('🏆🚫')
expect(el.querySelector('.sr-only')?.textContent).toBe('No achievements')
})
})

View File

@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { q } from '../../api/queries'
import { makeDataset, makeGame, makeState } from '../../test/factories'
import { big, GAMES, hash, header, names, panel, show, table } from '../../test/plannerHelpers'
import { painted } from './painted.testutil'
import { painted } from '../../test/painted'
afterEach(() => {
history.replaceState(null, '', '/')

View File

@ -2,6 +2,7 @@ import { screen, within } from '@testing-library/dom'
import { afterEach, describe, expect, it } from 'vitest'
import { makeGame } from '../../test/factories'
import { GAMES, hash, header, names, show, table } from '../../test/plannerHelpers'
import { ANY_GLYPH } from './glyphs'
import { hltbUrl } from './plannerTable'
afterEach(() => {
@ -115,6 +116,16 @@ describe('<game-table> table', () => {
expect(screen.getByText('Fallback estimate', { exact: false })).toBeVisible()
})
it('paints the keep options as glyphs and speaks their values', async () => {
await show()
const options = [...screen.getByRole('combobox', { name: 'Filter Keep' }).querySelectorAll('option')]
expect(options.map((o) => [o.value, o.textContent, o.getAttribute('aria-label')])).toEqual([
['', ANY_GLYPH, 'All'],
['kept', '📌', 'kept'],
['excluded', '🚫', 'excluded'],
])
})
it('caps the table and notes the overflow', async () => {
await show(Array.from({ length: 301 }, (_, i) => makeGame({ app_id: i + 1, name: `Game ${i}` })))
expect(screen.getByText('300/301')).toBeVisible()

View File

@ -38,11 +38,9 @@ export function hltbUrl(game: WebGame): string {
// Textgate: select options paint glyphs; the value (kept/excluded, a
// tierLabel string) is what the hash stores and what the option speaks.
const optionLabel = (c: Column, v: string) => {
if (c.key === 'keep') return KEEP_GLYPH[v] ?? v
if (c.key === 'proton') return tierGlyphs(v)
return v
}
// Only the keep and proton columns have a select filter; keep's options are
// always kept/excluded, so every one has a glyph.
const optionLabel = (c: Column, v: string) => (c.key === 'keep' ? KEEP_GLYPH[v] : tierGlyphs(v))
const option = (c: Column, v: string) =>
h('option', { value: v, 'aria-label': v || 'All' }, v ? optionLabel(c, v) : ANY_GLYPH)

View File

@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { applyFilters } from '../../estimate'
import { makeDataset, makeFilters, makeGame, makePaceVsHltb, makeState } from '../../test/factories'
import type { Filters, PaceVsHLTB } from '../../types'
import { painted } from './painted.testutil'
import { painted } from '../../test/painted'
import { summaryCards } from './summary'
const NOW = new Date('2026-01-01T00:00:00Z')

View File

@ -8,7 +8,7 @@ import { flush, mount, stubApi } from '../../test/harness'
import { GAMES, panel, show } from '../../test/plannerHelpers'
import { visibleWords } from '../../test/visibleWords'
import { ANY_GLYPH, KEEP_GLYPH, styleGlyph, tierGlyphs } from './glyphs'
import { painted } from './painted.testutil'
import { painted } from '../../test/painted'
afterEach(() => {
history.replaceState(null, '', '/')

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { since } from './dashboard'
describe('since', () => {
it('paints a clock and the short span, speaking the full phrase', () => {
const el = since(new Date(Date.now() - 5 * 60_000).toISOString())
expect(el.querySelector('[aria-hidden="true"]')?.textContent).toBe('🕓 5 min')
expect(el.querySelector('.sr-only')?.textContent).toBe('5 min ago')
})
it('paints "<1 min" while speaking "just now"', () => {
const el = since(new Date().toISOString())
expect(el.querySelector('[aria-hidden="true"]')?.textContent).toBe('🕓 <1 min')
expect(el.querySelector('.sr-only')?.textContent).toBe('just now')
})
})

View File

@ -1,7 +1,8 @@
import { screen } from '@testing-library/dom'
import { describe, expect, it } from 'vitest'
import { makeBudgetRules, makeBudgetSession, makeBudgetToday } from '../../test/factories'
import { historyCard, liveCard, rulesCard, todayCard, todayUnavailable } from './cards'
import { OTHER_KEY, UNATTRIBUTED_KEY } from '../../budgetSeries'
import { historyCard, liveCard, rulesCard, seriesLabel, todayCard, todayUnavailable } from './cards'
const show = (el: HTMLElement) => {
document.body.replaceChildren(el)
@ -108,6 +109,58 @@ describe('liveCard', () => {
})
})
describe('liveCard reading age', () => {
const painted = () => document.querySelector('.hint:last-child [aria-hidden="true"]')?.textContent
const spoken = () => document.querySelector('.hint:last-child .sr-only')?.textContent
it('paints a clock and "<10s" for a fresh reading', () => {
show(liveCard(makeBudgetSession()))
expect(painted()).toBe('🕓 <10s')
expect(spoken()).toBe('as of just now')
})
it('paints the age without "ago" for an older reading', () => {
show(liveCard(makeBudgetSession({ observed_at: new Date(Date.now() - 125_000).toISOString() })))
expect(painted()).toMatch(/^🕓 2m/)
expect(spoken()).toMatch(/^as of 2m.* ago$/)
})
it('paints "?" when the reading time is unknown', () => {
show(liveCard(makeBudgetSession({ observed_at: '' })))
expect(painted()).toBe('🕓 ?')
expect(spoken()).toBe('as of unknown')
})
})
describe('seriesLabel', () => {
const paintedOf = (el: HTMLElement) => el.querySelector('[aria-hidden="true"]')?.textContent
const spokenOf = (el: HTMLElement) => el.querySelector('.sr-only')?.textContent
it('paints a plus for the grouped remainder and speaks its label', () => {
const el = seriesLabel(OTHER_KEY, 'Other games')
expect(paintedOf(el)).toBe('➕')
expect(spokenOf(el)).toBe('Other games')
})
it('paints a question mark for unattributed time', () => {
const el = seriesLabel(UNATTRIBUTED_KEY, 'Unattributed')
expect(paintedOf(el)).toBe('❔')
expect(spokenOf(el)).toBe('Unattributed')
})
it('paints a rocket and the bare name for a launcher, speaking the full label', () => {
const el = seriesLabel('launcher:steam', 'Steam (launcher)')
expect(paintedOf(el)).toBe('🚀 Steam')
expect(spokenOf(el)).toBe('Steam (launcher)')
})
it('paints a game name as plain text', () => {
const el = seriesLabel('app:1', 'Titan Quest')
expect(el.textContent).toBe('Titan Quest')
expect(el.querySelector('.sr-only')).toBeNull()
})
})
describe('historyCard', () => {
const legend = [
{ key: 'app:475150', label: 'Titan Quest' },

View File

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { q } from '../../api/queries'
import { makeJob, makeSpec } from '../../test/fixtures'
import { apiError, bodiesOf, flush, installEventSource, mount, stubApi, type Handler } from '../../test/harness'
import { dateTime } from '../../ui/format'
import { restorePhrase } from './backupsCard'
const BACKUP = { id: '20261010-100000', created_at: '2026-10-10T10:00:00Z', reason: 'before reset', size_bytes: 2048 }
@ -44,6 +45,18 @@ describe('backups-card', () => {
expect(await screen.findByRole('button', { name: 'Reset all state…' })).toBeInTheDocument()
})
it('paints a generic archive glyph for an unknown reason, in the list and the dialog', async () => {
show({ 'GET /api/backups': [{ ...BACKUP, reason: 'manual' }] })
expect(await screen.findByText(BACKUP.id)).toBeInTheDocument()
const row = document.querySelector('[data-reason="manual"]')
expect(row?.querySelector('[aria-hidden="true"]')?.textContent).toBe('🗄️')
expect(row?.querySelector('.sr-only')?.textContent).toBe('manual')
await userEvent.click(screen.getByRole('button', { name: 'Restore…' }))
const dialog = await screen.findByRole('dialog', { name: `Restore backup ${BACKUP.id}` })
const painted = [...(dialog.querySelector('p.row')?.querySelectorAll(':scope > [aria-hidden="true"]') ?? [])].map((e) => e.textContent)
expect(painted).toEqual([`🗄️ ${BACKUP.id} · ${dateTime(BACKUP.created_at)}`, '🗄️'])
})
it('says when there are none', async () => {
show({ 'GET /api/backups': [] })
expect(await screen.findByText('No backups yet.')).toBeInTheDocument()

View File

@ -50,6 +50,12 @@ describe('daemon-card', () => {
expect(screen.queryByText('Daemon unreachable')).toBeNull()
})
it('paints a just-started daemon as 0 min and speaks "just now"', async () => {
show(daemon({ started_at: new Date().toISOString() }))
const up = await screen.findByLabelText('just now')
expect(up.querySelector('[aria-hidden="true"]')?.textContent).toBe('0 min')
})
it('restarts through the runner when allowed', async () => {
show(daemon())
await userEvent.click(await screen.findByRole('button', { name: 'Restart daemon…' }))

View File

@ -81,6 +81,16 @@ describe('small builders', () => {
expect(el.querySelector('.pill-icon')).not.toBeNull()
})
it('pill paints a bullet for an unknown tone and speaks its text', () => {
const el = pill('mystery', 'odd one')
expect(el.querySelector('.pill-icon')?.textContent).toBe('•')
expect(el.querySelector('.sr-only')?.textContent).toBe('odd one')
})
it('pill paints the icon it is given over the tone glyph', () => {
expect(pill('danger', 'x', '🔥').querySelector('.pill-icon')?.textContent).toBe('🔥')
})
it('link is an anchor', () => {
const a = link('/library/', 'Open')
expect(a.getAttribute('href')).toBe('/library/')