diff --git a/frontend/src/components/SessionRail.jsx b/frontend/src/components/SessionRail.jsx index 51ef2ca..406cfe3 100644 --- a/frontend/src/components/SessionRail.jsx +++ b/frontend/src/components/SessionRail.jsx @@ -59,8 +59,14 @@ export default function SessionRail({ sessions, open, onToggle, loading }) { opens its own overview, which says so and offers to start it — clicking a name used to drop you straight into a 240-question exam. */} - + {/* The attempt to read about is the live one if there is + one, otherwise the last finished. `last_attempt_id` + counts only finished attempts, so a session left + part-way used to be addressed by quiz — and the page + then had no attempt id to delete or review with. */} + {row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'} {row.title} diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx index 6d86068..8d85fdf 100644 --- a/frontend/src/pages/AnalysisSessionPage.jsx +++ b/frontend/src/pages/AnalysisSessionPage.jsx @@ -84,7 +84,10 @@ export default function AnalysisSessionPage() { const deleteSession = async () => { setDeleting(true) try { - await api.delete(`/attempts/${attemptId}`) + // The attempt the page is showing, not the one named in the URL: reached + // by quiz (a session whose only attempt is still in progress) there is no + // attemptId in the path, and this deleted "/attempts/undefined". + await api.delete(`/attempts/${data?.attempt_id ?? attemptId}`) navigate('/sessions', { replace: true }) } catch { setDeleting(false) @@ -134,7 +137,7 @@ export default function AnalysisSessionPage() { Start this session ) : ( <> - Review answers + Review answers {!data.completed_at && ( Resume )} diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx index 0a9a921..5fbb197 100644 --- a/frontend/src/pages/AnalysisSessionPage.test.jsx +++ b/frontend/src/pages/AnalysisSessionPage.test.jsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import AnalysisSessionPage from './AnalysisSessionPage' import api from '../api/client' @@ -106,6 +107,31 @@ describe('a session that has been sat', () => { }) }) +describe('a session whose only attempt is still in progress', () => { + beforeEach(() => { + vi.clearAllMocks() + // Reached by quiz, because last_attempt_id counts finished attempts only — + // but the analysis it answers with does name the live attempt. + mock({ ...SAT, attempt_id: 77, completed_at: null, answered: 0, score: 0, percent: 0 }) + }) + + it('deletes the attempt the page is showing, not the one named in the URL', async () => { + mountQuiz() + await screen.findByRole('button', { name: 'Delete session' }) + await userEvent.click(screen.getByRole('button', { name: 'Delete session' })) + api.delete.mockResolvedValue({}) + await userEvent.click(screen.getByRole('button', { name: 'Delete for good' })) + // It used to send /attempts/undefined, and nothing happened. + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/attempts/77')) + }) + + it('reviews that attempt too', async () => { + mountQuiz() + expect(await screen.findByRole('link', { name: 'Review answers' })) + .toHaveAttribute('href', '/results/77') + }) +}) + describe('a session left part way', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index c4329e0..7c06f3f 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -1,7 +1,10 @@ /* Custom test builder — AMBOSS-style facet rows that open a picker panel, instead of a cramped sidebar of nested scrolling checkbox lists. */ -.custom-test { max-width: 1120px; margin: 0 auto; padding-bottom: 100px; } +/* No bottom padding: the save bar below is sticky but still in flow, and it + is the last thing on the page — so padding under it is dead space between + the bar and the footer, not room for it to sit over. */ +.custom-test { max-width: 1120px; margin: 0 auto; } .custom-test-top { display: grid; grid-template-columns: 1fr auto; align-items: end; diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css index 3096901..485c29f 100644 --- a/frontend/src/pages/QuestionEditPage.css +++ b/frontend/src/pages/QuestionEditPage.css @@ -1,7 +1,10 @@ /* Full-page question editor — room to read the stem and pick categories, instead of a cramped modal. Mobile-first: the aside drops below the form. */ -.qe-page { max-width: 1180px; margin: 0 auto; padding-bottom: 90px; } +/* No bottom padding: the save bar below is sticky but still in flow, and it + is the last thing on the page — so padding under it is dead space between + the bar and the footer, not room for it to sit over. */ +.qe-page { max-width: 1180px; margin: 0 auto; } /* The way out stays reachable. This page is long enough that scrolling to the stem loses the back link entirely, and the save bar is pinned at the bottom @@ -120,7 +123,6 @@ .qe-aside { position: static; } } @media (max-width: 640px) { - .qe-page { padding-bottom: 110px; } .qe-top-actions { width: 100%; } .qe-top-actions .btn { flex: 1; } .qe-save { flex: 1; margin-left: 0; }