import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
commandOccurrence,
sessionStateLabel,
SessionsPage,
sortSessionItems,
type SessionListEntry,
} from './SessionsPage'
function page(kind: string, items: unknown[]) {
return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items }
}
function response(value: unknown) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(value) })
}
describe('Sessions page', () => {
it('removes presentation-only fields from a preparation command', () => {
const command = commandOccurrence({
exercise_id: 'ex_one', exercise_name: 'Visible name', equipment_id: null,
recording_mode: 'sets', tracking_mode: 'reps', load_mode: 'external', rest_seconds: 90,
target_sets: 3, target_reps: 8, target_duration_seconds: null, target_weight_kg: 42,
notes: null,
})
expect(command).not.toHaveProperty('exercise_name')
expect(command).not.toHaveProperty('recording_mode')
expect(command).not.toHaveProperty('tracking_mode')
expect(command).toMatchObject({ exercise_id: 'ex_one', target_sets: 3, rest_seconds: 90 })
})
it('keeps proposals distinct and opens a stable deep link', async () => {
window.history.replaceState(null, '', '/seances')
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/preparations?')) return response(page('preparations', []))
if (url.includes('/proposals?')) return response(page('proposals', [{
identity: 'aid_target', title: 'Proposition cible', date: '2026-09-17', state: 'published',
occurrence_count: 6, sort_timestamp: '2026-09-17T12:00:00Z',
}]))
return response({ api_version: 1, kind: 'proposal', identity: 'aid_target', revision_id: 'rev',
title: 'Proposition cible', planned_for: null, session_type: 'training', notes: null,
source_fingerprint: 'abc', state: 'published', occurrences: [] })
}))
render()
await waitFor(() => expect(screen.getByText('Proposition cible')).toBeInTheDocument())
expect(screen.getByText('PROPOSITION IA')).toBeInTheDocument()
const trash = screen.getByRole('button', { name: 'Supprimer la proposition' })
fireEvent.click(trash)
expect(window.location.pathname).toBe('/seances')
fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', {
name: 'Conserver',
}))
await waitFor(() => expect(trash).toHaveFocus())
fireEvent.click(screen.getByText('Proposition cible'))
expect(window.location.pathname).toBe('/seances/proposal/aid_target')
await waitFor(() => expect(screen.getByRole('heading', { name: 'Proposition cible' })).toBeInTheDocument())
})
it('reports an API failure instead of presenting an empty list', async () => {
window.history.replaceState(null, '', '/seances')
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network')))
render()
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles'))
expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument()
})
it('sorts more than one page globally with deterministic ties and invalid dates last', () => {
const values: SessionListEntry[] = Array.from({ length: 41 }, (_, index) => ({
collection: index % 2 === 0 ? 'preparations' : 'proposals',
identity: `id_${String(index).padStart(2, '0')}`,
title: `Item ${index}`,
date: index === 39 ? null : index === 40 ? 'date-invalide' :
`2026-09-${String((index % 20) + 1).padStart(2, '0')}`,
state: index % 2 === 0 ? 'ready' : 'published',
occurrence_count: 1,
sort_timestamp: `2026-09-${String((index % 20) + 1).padStart(2, '0')}T12:00:00Z`,
}))
values.push({
...values[0], identity: 'id_same_b', date: '2026-09-18',
sort_timestamp: '2026-09-18T12:00:00Z',
}, {
...values[0], identity: 'id_same_a', date: '2026-09-18',
sort_timestamp: '2026-09-18T12:00:00Z',
})
const sorted = sortSessionItems(values, 'preparation')
expect(sorted).toHaveLength(43)
expect(sorted.slice(-2).map((item) => item.identity)).toEqual(['id_39', 'id_40'])
expect(sorted.findIndex((item) => item.identity === 'id_same_a')).toBeLessThan(
sorted.findIndex((item) => item.identity === 'id_same_b'),
)
expect(sorted.every((item, index) => index === 0 ||
item.date === null || item.date === 'date-invalide' ||
sorted[index - 1].date === null || sorted[index - 1].date === 'date-invalide' ||
String(sorted[index - 1].date) >= String(item.date))).toBe(true)
})
it('uses contextual French state labels and exposes unknown values', () => {
expect(sessionStateLabel('preparations', 'acknowledged')).toBe('Reçue')
expect(sessionStateLabel('proposals', 'published')).toBe('Publiée')
expect(sessionStateLabel('drafts', 'active')).toBe('En cours')
expect(sessionStateLabel('history', 'completed')).toBe('Terminée')
expect(sessionStateLabel('preparations', 'unexpected')).toBe('État non reconnu (unexpected)')
})
it('sorts history by the real instant across offsets with a stable identity tie-break', () => {
const history: SessionListEntry[] = [
{ collection: 'history', identity: 'se_invalid', title: 'Invalide', date: null,
state: 'completed', occurrence_count: 1, sort_timestamp: 'invalid' },
{ collection: 'history', identity: 'se_b', title: 'Même instant B', date: '2026-09-18',
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T10:00:00+02:00' },
{ collection: 'history', identity: 'se_a', title: 'Même instant A', date: '2026-09-18',
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T08:00:00Z' },
{ collection: 'history', identity: 'se_older', title: 'Ancienne', date: '2026-09-18',
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T07:59:59Z' },
]
expect(sortSessionItems(history, 'history').map((item) => item.identity)).toEqual([
'se_a', 'se_b', 'se_older', 'se_invalid',
])
})
it('cancels confirmation without mutation, then withdraws through the DELETE API', async () => {
window.history.replaceState(null, '', '/seances/preparation/sp_target')
const detail = {
api_version: 1, kind: 'preparation', identity: 'sp_target', revision_id: 'spr_current',
title: 'Préparation ciblée', planned_for: '2026-09-18', session_type: 'training',
notes: null, source_fingerprint: 'a'.repeat(64), source_proposal_id: 'aid_source',
source_proposal_title: 'Proposition source', state: 'acknowledged', occurrences: [],
}
const withdrawn = {
...detail, state: 'withdrawn', withdrawn_at: '2026-09-18T12:00:00Z',
withdrawal_id: 'spw_target', withdrawal_acknowledged_at: null,
}
let detailReads = 0
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url === '/api/v1/sync/status') {
return response({ phase: 'idle', result: 'ok' }).then((value) => ({
...value, headers: new Headers({ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }),
}))
}
if (url.includes('/api/v1/sessions/preparation/sp_target') && init?.method === 'DELETE') {
return response({ preparation_id: 'sp_target', revision_id: 'spr_current',
withdrawal_id: 'spw_target', android_cancellation: 'pending' })
}
if (url.includes('/api/v1/sessions/preparation/sp_target')) {
detailReads += 1
return response(detailReads === 1 ? detail : withdrawn)
}
throw new Error(`unexpected request ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
render()
await screen.findByRole('heading', { name: 'Préparation ciblée' })
const deleteTrigger = screen.getByRole('button', { name: 'Supprimer la préparation' })
fireEvent.click(deleteTrigger)
let dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent('sans effacer un entraînement réel')
const keepButton = within(dialog).getByRole('button', { name: 'Conserver' })
expect(keepButton).toHaveFocus()
fireEvent.click(keepButton)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
await waitFor(() => expect(deleteTrigger).toHaveFocus())
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' }))
dialog = screen.getByRole('alertdialog')
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' }))
dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer la préparation' }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(
'Supprimée sur le PC — annulation Android à synchroniser.',
))
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(screen.queryByRole('button', { name: 'Supprimer la préparation' })).not.toBeInTheDocument()
expect(screen.getByText(/Dérivée de la proposition/)).toHaveTextContent('Proposition source')
})
})