trainlog/web/src/routes/ProgramsTab.test.tsx

261 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ProgramsTab } from './ProgramsTab'
function jsonResponse(value: unknown, headers?: HeadersInit) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(headers),
json: () => Promise.resolve(value),
})
}
function programPage() {
return {
api_version: 1,
offset: 0,
more: false,
next_offset: 0,
items: [],
}
}
function preview(imported: boolean) {
return {
api_version: 1,
program_id: 'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
title: 'Cycle test',
start_date: '2026-09-21',
end_date: null,
payload_sha256: 'a'.repeat(64),
session_count: 2,
unknown_exercise_count: 0,
warnings: [],
imported,
}
}
describe('Programs tab', () => {
it('deletes a program through a separate guarded action and restores focus on cancel', async () => {
const item = {
program_id: 'pg_test',
title: 'Cycle à retirer',
state: 'archived',
start_date: null,
end_date: null,
session_count: 4,
provenance: 'trainlog-program',
updated_at: '2026-09-18T12:00:00Z',
imported_at: '2026-09-17T12:00:00Z',
revision_id: 'pgr_revision',
preparation_count: 2,
usage: 'used',
}
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.startsWith('/api/v1/sessions/programs?')) {
return jsonResponse({ ...programPage(), items: [item] })
}
if (url === '/api/v1/sync/status') {
return jsonResponse(
{ phase: 'idle', result: 'ok' },
{ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
)
}
if (url === '/api/v1/sessions/program/pg_test/delete') {
expect(init?.method).toBe('POST')
expect((init?.headers as Record<string, string>)['If-Match']).toBe('"pgr_revision"')
expect((init?.headers as Record<string, string>)['X-Trainlog-Request-ID'])
.toMatch(/^pd_[0-9a-f]{32}$/)
return jsonResponse({ api_version: 1, program_id: 'pg_test' })
}
throw new Error(`unexpected request ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
render(<ProgramsTab detailProgramId={null} onOpen={vi.fn()} onBack={vi.fn()} />)
const trash = await screen.findByRole('button', {
name: 'Supprimer le programme Cycle à retirer',
})
fireEvent.click(trash)
let dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent(/toutes les préparations.*conserv/)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(document.activeElement).toBe(trash)
fireEvent.click(trash)
dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer le programme' }))
await waitFor(() => expect(screen.queryByText('Cycle à retirer')).not.toBeInTheDocument())
expect(fetchMock.mock.calls.filter(([url]) =>
String(url) === '/api/v1/sessions/program/pg_test/delete')).toHaveLength(1)
})
it('shows a server preview before the only importing mutation', async () => {
const onOpen = vi.fn()
const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {
const url = String(input)
if (url.startsWith('/api/v1/sessions/programs?')) {
return jsonResponse(programPage())
}
if (url === '/api/v1/sync/status') {
return jsonResponse(
{ phase: 'idle', result: 'ok' },
{ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
)
}
if (url === '/api/v1/sessions/programs/validate') {
return jsonResponse(preview(false))
}
if (url === '/api/v1/sessions/programs/import') {
return jsonResponse(preview(true))
}
throw new Error(`unexpected request ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
render(<ProgramsTab detailProgramId={null} onOpen={onOpen} onBack={vi.fn()} />)
await waitFor(() => expect(screen.getByText('Aucun programme.')).toBeInTheDocument())
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['{"format":"trainlog-program"}'], 'cycle.json', {
type: 'application/json',
})
fireEvent.change(input, { target: { files: [file] } })
const previewPanel = await screen.findByRole('region', { name: 'Aperçu avant import' })
expect(previewPanel).toHaveTextContent('Cycle test')
expect(previewPanel).toHaveTextContent('2 séances')
expect(previewPanel).toHaveTextContent('Aucune donnée n’a encore été enregistrée.')
expect(fetchMock.mock.calls.filter(([url]) =>
String(url) === '/api/v1/sessions/programs/import')).toHaveLength(0)
fireEvent.click(within(previewPanel).getByRole('button', { name: 'Importer' }))
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(
'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
))
expect(fetchMock.mock.calls.filter(([url]) =>
String(url) === '/api/v1/sessions/programs/import')).toHaveLength(1)
})
it('creates one preparation from a program session and navigates to it', async () => {
window.history.replaceState(null, '', '/seances/program/pg_test')
const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {
const url = String(input)
if (url === '/api/v1/sessions/program/pg_test') {
return jsonResponse({
api_version: 1,
program_id: 'pg_test',
title: 'Cycle test',
note: null,
state: 'active',
start_date: null,
end_date: null,
created_at: '2026-09-18T12:00:00Z',
updated_at: '2026-09-18T12:00:00Z',
revision_id: 'revision',
source_format: 'trainlog-program',
source_version: 1,
source_payload_sha256: 'a'.repeat(64),
sessions: [{
program_session_id: 'pgs_test',
position: 0,
title: 'Séance A',
session_type: 'training',
planned_for: null,
note: null,
execution_state: 'todo',
execution_session_id: null,
occurrences: [{
entry_id: 'pge_test',
position: 0,
exercise_id: 'ex_test',
equipment_id: null,
recording_mode: 'sets',
tracking_mode: 'reps',
data_fields: 0,
load_mode: 'none',
rest_seconds: 90,
target_sets: 3,
target_reps: 8,
target_duration_seconds: null,
target_weight_kg: null,
notes: null,
}],
}],
})
}
if (url === '/api/v1/sync/status') {
return jsonResponse(
{ phase: 'idle', result: 'ok' },
{ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
)
}
if (url.endsWith('/sessions/pgs_test/prepare')) {
return jsonResponse({ preparation_id: 'sp_created' })
}
throw new Error(`unexpected request ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
render(<ProgramsTab detailProgramId="pg_test" onOpen={vi.fn()} onBack={vi.fn()} />)
fireEvent.click(await screen.findByRole('button', { name: 'Créer une préparation' }))
await waitFor(() => expect(window.location.pathname).toBe('/seances/preparation/sp_created'))
expect(fetchMock.mock.calls.filter(([, options]) =>
(options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
})
it('counts a completed program session and no longer offers to prepare it', async () => {
const sessions = Array.from({ length: 24 }, (_, index) => ({
program_session_id: `pgs_${index}`,
position: index,
title: index === 0 ? 'S1 A — Ceinture abdominale + jambes' : `Séance ${index + 1}`,
session_type: 'training',
planned_for: null,
note: null,
execution_state: index === 0 ? 'completed' : 'todo',
execution_session_id: index === 0 ? 'se_completed' : null,
occurrences: [],
}))
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url === '/api/v1/sessions/program/pg_test') {
return jsonResponse({
api_version: 1,
program_id: 'pg_test',
title: 'Programme 24 séances',
note: null,
state: 'active',
start_date: null,
end_date: null,
created_at: '2026-09-18T12:00:00Z',
updated_at: '2026-09-21T12:00:00Z',
revision_id: 'revision',
source_format: 'trainlog-program',
source_version: 1,
source_payload_sha256: 'a'.repeat(64),
sessions,
})
}
throw new Error(`unexpected request ${url}`)
}))
render(<ProgramsTab detailProgramId="pg_test" onOpen={vi.fn()} onBack={vi.fn()} />)
expect(await screen.findByText('1/24')).toBeInTheDocument()
expect(screen.getByText('4% effectué')).toBeInTheDocument()
const completed = screen
.getByText(/S1 A — Ceinture abdominale \+ jambes/)
.closest('li')
expect(completed).not.toBeNull()
expect(within(completed as HTMLElement).getByText('Effectuée')).toBeInTheDocument()
expect(
within(completed as HTMLElement).queryByRole('button', {
name: 'Créer une préparation',
}),
).toBeNull()
expect(screen.getAllByRole('button', { name: 'Créer une préparation' })).toHaveLength(23)
})
})