diff --git a/web/src/jobs/promptForm.own.test.ts b/web/src/jobs/promptForm.own.test.ts new file mode 100644 index 0000000..4a95cdb --- /dev/null +++ b/web/src/jobs/promptForm.own.test.ts @@ -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') + }) +}) diff --git a/web/src/library/view.test.ts b/web/src/library/view.test.ts index b9fa016..220374a 100644 --- a/web/src/library/view.test.ts +++ b/web/src/library/view.test.ts @@ -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') + }) +}) diff --git a/web/src/sections/backlog/planner.test.ts b/web/src/sections/backlog/planner.test.ts index 8760257..6616dab 100644 --- a/web/src/sections/backlog/planner.test.ts +++ b/web/src/sections/backlog/planner.test.ts @@ -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, '', '/') diff --git a/web/src/sections/backlog/plannerTable.test.ts b/web/src/sections/backlog/plannerTable.test.ts index 4405ffb..0a82610 100644 --- a/web/src/sections/backlog/plannerTable.test.ts +++ b/web/src/sections/backlog/plannerTable.test.ts @@ -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(' 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() diff --git a/web/src/sections/backlog/plannerTable.ts b/web/src/sections/backlog/plannerTable.ts index 90dc57f..6607851 100644 --- a/web/src/sections/backlog/plannerTable.ts +++ b/web/src/sections/backlog/plannerTable.ts @@ -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) diff --git a/web/src/sections/backlog/summary.test.ts b/web/src/sections/backlog/summary.test.ts index c7ca3c2..d11b4e4 100644 --- a/web/src/sections/backlog/summary.test.ts +++ b/web/src/sections/backlog/summary.test.ts @@ -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') diff --git a/web/src/sections/backlog/textgate.test.ts b/web/src/sections/backlog/textgate.test.ts index a85b8f4..1830352 100644 --- a/web/src/sections/backlog/textgate.test.ts +++ b/web/src/sections/backlog/textgate.test.ts @@ -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, '', '/') diff --git a/web/src/sections/dashboard.since.test.ts b/web/src/sections/dashboard.since.test.ts new file mode 100644 index 0000000..d531b05 --- /dev/null +++ b/web/src/sections/dashboard.since.test.ts @@ -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') + }) +}) diff --git a/web/src/sections/gaming/cards.test.ts b/web/src/sections/gaming/cards.test.ts index b76aeea..c21a876 100644 --- a/web/src/sections/gaming/cards.test.ts +++ b/web/src/sections/gaming/cards.test.ts @@ -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' }, diff --git a/web/src/sections/system/backupsCard.test.ts b/web/src/sections/system/backupsCard.test.ts index 2087791..72b3f38 100644 --- a/web/src/sections/system/backupsCard.test.ts +++ b/web/src/sections/system/backupsCard.test.ts @@ -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() diff --git a/web/src/sections/system/daemonCard.test.ts b/web/src/sections/system/daemonCard.test.ts index 9cd32ae..7612fe4 100644 --- a/web/src/sections/system/daemonCard.test.ts +++ b/web/src/sections/system/daemonCard.test.ts @@ -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…' })) diff --git a/web/src/sections/backlog/painted.testutil.ts b/web/src/test/painted.ts similarity index 100% rename from web/src/sections/backlog/painted.testutil.ts rename to web/src/test/painted.ts diff --git a/web/src/ui/build.test.ts b/web/src/ui/build.test.ts index d6c621c..950f8b3 100644 --- a/web/src/ui/build.test.ts +++ b/web/src/ui/build.test.ts @@ -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/')