Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a80bdea140 | ||
|
|
905626f494 | ||
|
|
b52f6c54ba | ||
|
|
e6a2480818 | ||
|
|
2b33aa80eb | ||
|
|
7a1dd4dccc | ||
|
|
4001772d5e |
+2
-1
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
|
||||
import SignupPage from './pages/auth/signupPage';
|
||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam';
|
||||
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage } from './pages/exam';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import CalendarPage from './pages/user/calendarPage';
|
||||
import SettingsPage from './pages/user/settingsPage';
|
||||
@@ -184,6 +184,7 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/search" element={<SearxngPage />} />
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/bank" element={<ErrorBoundary><QuestionBankPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
|
||||
|
||||
@@ -25,6 +25,7 @@ import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import GradingIcon from '@mui/icons-material/Grading';
|
||||
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
@@ -233,9 +234,14 @@ const ExamDashboardPage: React.FC = () => {
|
||||
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
|
||||
Question bank
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||
@@ -43,6 +44,8 @@ const ExamMarkingPage: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!batchId) return;
|
||||
@@ -68,6 +71,28 @@ const ExamMarkingPage: React.FC = () => {
|
||||
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
||||
[template],
|
||||
);
|
||||
const onScanFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!batchId || !files || files.length === 0) return;
|
||||
setUploading(true); setError(null); setMessage(null);
|
||||
let ok = 0;
|
||||
const failures: string[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
// 'ordered' assigns each scan to the next not-yet-submitted roster slot; unmatched scans
|
||||
// become an unmatched submission the teacher can reconcile.
|
||||
await examRepository.uploadScan(batchId, file, { matchingMethod: 'ordered' });
|
||||
ok += 1;
|
||||
} catch (e) {
|
||||
failures.push(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await load();
|
||||
if (ok) setMessage(`Uploaded ${ok} scan${ok === 1 ? '' : 's'}${failures.length ? `, ${failures.length} failed` : ''}`);
|
||||
if (failures.length) setError(failures.join(' · '));
|
||||
}, [batchId, load]);
|
||||
|
||||
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
||||
|
||||
const saveSelected = async () => {
|
||||
@@ -154,7 +179,29 @@ const ExamMarkingPage: React.FC = () => {
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Marking queue</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
|
||||
<Typography variant="h6">Marking queue</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<UploadFileIcon fontSize="small" />}
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? 'Uploading…' : 'Upload scans'}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => { void onScanFiles(e.target.files); }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
||||
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert, Box, Button, Card, CardActionArea, CardContent, Chip, CircularProgress, Container,
|
||||
Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
||||
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type { BankQuestion, BankResponse } from '../../types/exam.types';
|
||||
|
||||
const QuestionBankPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [bank, setBank] = useState<BankResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [specRef, setSpecRef] = useState<string | null>(null);
|
||||
const [subject, setSubject] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
setBank(await examRepository.getBank({ specRef: specRef ?? undefined, subject: subject ?? undefined }));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [specRef, subject]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const toggle = (id: string) => setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const selectedList = useMemo(() => Array.from(selected), [selected]);
|
||||
const selectedQuestions = useMemo(
|
||||
() => (bank?.questions ?? []).filter((q) => selected.has(q.id)),
|
||||
[bank, selected],
|
||||
);
|
||||
const totalMarks = selectedQuestions.reduce((s, q) => s + (q.max_marks ?? 0), 0);
|
||||
|
||||
// facet lists (sorted) with counts
|
||||
const specFacets = Object.entries(bank?.facets.spec_ref ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||||
const subjectFacets = Object.entries(bank?.facets.subject ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||||
|
||||
const create = async () => {
|
||||
setCreating(true); setError(null);
|
||||
try {
|
||||
const subj = subject ?? selectedQuestions[0]?.paper.subject ?? undefined;
|
||||
const res = await examRepository.createCustomPaper({
|
||||
title: title.trim() || `Custom paper — ${new Date().toISOString().slice(0, 10)}`,
|
||||
subject: subj ?? undefined,
|
||||
question_ids: selectedList,
|
||||
});
|
||||
navigate(`/exam-marker/${res.id}/setup`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setCreating(false);
|
||||
setDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||
Exam Marker
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" fontWeight={700}>Question bank</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Plan by spec point, then assemble a custom paper from questions across your papers.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlaylistAddCheckIcon />}
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
Create paper ({selected.size}{totalMarks ? ` · ${totalMarks}m` : ''})
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{/* Filters */}
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Spec point:</Typography>
|
||||
<Chip size="small" label="all" color={specRef ? 'default' : 'primary'} onClick={() => setSpecRef(null)} variant={specRef ? 'outlined' : 'filled'} />
|
||||
{specFacets.map(([ref, count]) => (
|
||||
<Chip key={ref} size="small" label={`${ref} · ${count}`} color={specRef === ref ? 'primary' : 'default'} variant={specRef === ref ? 'filled' : 'outlined'} onClick={() => setSpecRef(ref)} />
|
||||
))}
|
||||
</Stack>
|
||||
{subjectFacets.length > 1 && (
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Subject:</Typography>
|
||||
<Chip size="small" label="all" color={subject ? 'default' : 'secondary'} onClick={() => setSubject(null)} variant={subject ? 'outlined' : 'filled'} />
|
||||
{subjectFacets.map(([s, count]) => (
|
||||
<Chip key={s} size="small" label={`${s} · ${count}`} color={subject === s ? 'secondary' : 'default'} variant={subject === s ? 'filled' : 'outlined'} onClick={() => setSubject(s)} sx={{ textTransform: 'capitalize' }} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||
) : (bank?.questions.length ?? 0) === 0 ? (
|
||||
<Alert severity="info">
|
||||
No questions match yet. Questions appear here once your templates have parts with spec points
|
||||
(set them in the mark-scheme editor). Auto-map + confirm on a paper to populate it.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{bank?.n} question{bank?.n === 1 ? '' : 's'} · click to add to your paper
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gap: 1.5 }}>
|
||||
{(bank?.questions ?? []).map((q: BankQuestion) => {
|
||||
const on = selected.has(q.id);
|
||||
return (
|
||||
<Card key={q.id} variant="outlined" sx={{ borderColor: on ? 'primary.main' : 'divider', borderWidth: on ? 2 : 1 }}>
|
||||
<CardActionArea onClick={() => toggle(q.id)}>
|
||||
<CardContent sx={{ pb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700}>Q{q.label ?? '?'}</Typography>
|
||||
{on ? <CheckCircleIcon color="primary" fontSize="small" /> : <RadioButtonUncheckedIcon color="disabled" fontSize="small" />}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 0.75 }}>
|
||||
<Chip size="small" label={`${q.max_marks ?? 0} marks`} />
|
||||
{q.answer_type && <Chip size="small" variant="outlined" label={q.answer_type} />}
|
||||
{q.spec_ref && <Chip size="small" color="info" label={q.spec_ref} />}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', textTransform: 'capitalize' }}>
|
||||
{[q.paper.subject, q.paper.title].filter(Boolean).join(' · ') || 'Untitled paper'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => !creating && setDialogOpen(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Create custom paper</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{selected.size} question{selected.size === 1 ? '' : 's'} · {totalMarks} marks. This creates a new
|
||||
template you can open in setup and use for marking.
|
||||
</Typography>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Paper title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={`Custom paper — ${new Date().toISOString().slice(0, 10)}`}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)} disabled={creating}>Cancel</Button>
|
||||
<Button variant="contained" onClick={create} disabled={creating} startIcon={creating ? <CircularProgress size={16} color="inherit" /> : undefined}>
|
||||
{creating ? 'Creating…' : 'Create & open'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionBankPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||
export { default as QuestionBankPage } from './QuestionBankPage';
|
||||
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||
export { default as ExamMarkingPage } from './ExamMarkingPage';
|
||||
|
||||
@@ -13,9 +13,12 @@ import { supabase } from '../../supabaseClient';
|
||||
import type {
|
||||
AutoMapJobStatus,
|
||||
AutoMapResponse,
|
||||
BankResponse,
|
||||
BatchQueueResponse,
|
||||
BatchResultsResponse,
|
||||
CreateBatchPayload,
|
||||
CreateCustomPaperPayload,
|
||||
CreateCustomPaperResult,
|
||||
CreateTemplatePayload,
|
||||
ExamBoundary,
|
||||
ExamQuestion,
|
||||
@@ -28,6 +31,7 @@ import type {
|
||||
Neo4jSyncResult,
|
||||
PatchQuestionPayload,
|
||||
SpecPoint,
|
||||
StudentSubmission,
|
||||
TemplateReplacePayload,
|
||||
UpdateTemplateMetaPayload,
|
||||
} from '../../types/exam.types';
|
||||
@@ -298,6 +302,36 @@ export const examRepository = {
|
||||
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getBank(params: { specRef?: string; subject?: string } = {}): Promise<BankResponse> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<BankResponse>(`${EXAM_BASE}/bank`, {
|
||||
headers,
|
||||
params: { spec_ref: params.specRef, subject: params.subject },
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async createCustomPaper(payload: CreateCustomPaperPayload): Promise<CreateCustomPaperResult> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.post<CreateCustomPaperResult>(`${EXAM_BASE}/custom-papers`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async uploadScan(
|
||||
batchId: string,
|
||||
file: File,
|
||||
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
|
||||
): Promise<StudentSubmission> {
|
||||
const headers = await authHeaders();
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('matching_method', opts.matchingMethod ?? 'ordered');
|
||||
if (opts.studentId) form.append('student_id', opts.studentId);
|
||||
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
|
||||
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default examRepository;
|
||||
|
||||
@@ -338,3 +338,36 @@ export interface MarkUpsertPayload {
|
||||
comment?: string;
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
// ── Mode-3 question bank + custom-paper assembly ──────────────────────────────
|
||||
export interface BankQuestion {
|
||||
id: string;
|
||||
template_id: string;
|
||||
label: string | null;
|
||||
max_marks: number | null;
|
||||
answer_type: string | null;
|
||||
spec_ref: string | null;
|
||||
bounds: Record<string, number> | null;
|
||||
page: number | null;
|
||||
paper: { id: string | null; title: string | null; subject: string | null; exam_code: string | null };
|
||||
}
|
||||
|
||||
export interface BankResponse {
|
||||
questions: BankQuestion[];
|
||||
n: number;
|
||||
facets: { spec_ref: Record<string, number>; subject: Record<string, number> };
|
||||
}
|
||||
|
||||
export interface CreateCustomPaperPayload {
|
||||
title: string;
|
||||
subject?: string;
|
||||
question_ids: string[];
|
||||
}
|
||||
|
||||
export interface CreateCustomPaperResult {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string | null;
|
||||
n_questions: number;
|
||||
n_response_areas: number;
|
||||
}
|
||||
|
||||
@@ -92,4 +92,28 @@ describe('exam setup canvas serialization', () => {
|
||||
expect(payload.boundaries.find((b) => b.id === '44444444-4444-4444-8444-444444444444')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||
})
|
||||
|
||||
it('never drops a response region that has no enclosing part — it falls back to a question', () => {
|
||||
const payload = serializeCanvasShapes(template, [
|
||||
{ id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
||||
{ id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
||||
{ id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 120, label: 'Q1(a)', maxMarks: 3 },
|
||||
{ id: 'resp-far', kind: 'response', x: 100, y: 520, w: 300, h: 90, responseForm: 'lines' }, // outside the part box
|
||||
])
|
||||
const part = payload.questions.find((q) => !q.is_container)
|
||||
expect(payload.response_areas).toHaveLength(1) // previously dropped (no containing part)
|
||||
expect(payload.response_areas[0].question_id).toBe(part?.id) // falls back to the part, not lost
|
||||
})
|
||||
|
||||
it('respects a region persisted question over geometric nearest when no part contains it', () => {
|
||||
const A = '11111111-1111-4111-8111-111111111111'
|
||||
const B = '22222222-2222-4222-8222-222222222222'
|
||||
const R = '33333333-3333-4333-8333-333333333333'
|
||||
const payload = serializeCanvasShapes(template, [
|
||||
{ id: 'pa', kind: 'part', x: 100, y: 120, w: 300, h: 80, label: 'A', questionId: A },
|
||||
{ id: 'pb', kind: 'part', x: 100, y: 500, w: 300, h: 80, label: 'B', questionId: B },
|
||||
{ id: R, kind: 'response', x: 120, y: 260, w: 200, h: 60, questionId: B }, // contained by neither; nearest is A
|
||||
])
|
||||
expect(payload.response_areas.find((r) => r.id === R)?.question_id).toBe(B) // persisted B wins over nearest A
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -147,11 +147,22 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
||||
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
|
||||
})
|
||||
|
||||
// Resolve each region's owner question. Order: current geometric containment (a user who drags a
|
||||
// region into a part re-attaches it) → the PERSISTED attachment if it still points at a saved
|
||||
// question (survives pixel-overflow drift and regions with no enclosing part) → nearest part on the
|
||||
// page → first part → first question of any kind. exam_response_areas.question_id is NOT NULL, so we
|
||||
// never silently drop a region while any question exists (only a template with zero questions can).
|
||||
const questionIds = new Set(questions.map((q) => q.id))
|
||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||
for (const region of regions) {
|
||||
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
|
||||
const nearestPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||
const questionId =
|
||||
(containingPart && partQuestionIds.get(containingPart.id))
|
||||
|| persisted
|
||||
|| (nearestPart && partQuestionIds.get(nearestPart.id))
|
||||
|| questions[0]?.id
|
||||
if (!questionId) continue
|
||||
const kind = region.kind as ExamCanvasRegionKind
|
||||
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) })
|
||||
|
||||
Reference in New Issue
Block a user