diff --git a/smoke-s4-9c.js b/smoke-s4-9c.js new file mode 100644 index 0000000..e7a5145 --- /dev/null +++ b/smoke-s4-9c.js @@ -0,0 +1,60 @@ +const { chromium } = require('playwright'); +const fs = require('fs'); + +const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000'; +const EMAIL = process.env.VITE_TEST_TEACHER_EMAIL || 'physics@greenfieldacademy.test'; +const PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD || process.env.SEED_TEACHER_PASSWORD; +const TEMPLATE_ID = process.env.TEMPLATE_ID || '31d92cf3-9bbd-4a7e-b2dc-b37f8b69bc34'; +const OUT = process.env.OUT || '/out'; + +async function run(mode) { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ colorScheme: mode, viewport: { width: 1440, height: 980 } }); + const page = await context.newPage(); + const messages = []; + page.on('console', msg => messages.push(`${msg.type()}: ${msg.text()}`)); + page.on('pageerror', err => messages.push(`pageerror: ${err.message}`)); + + await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); + if (await page.getByLabel('Email').count()) { + if (!PASSWORD) throw new Error('missing password env'); + await page.getByLabel('Email').fill(EMAIL); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Login' }).click(); + await page.waitForURL(url => /\/dashboard|\/exam-marker|\/node\//.test(url.pathname), { timeout: 20000 }); + } + + await page.goto(`${BASE}/exam-marker/${TEMPLATE_ID}/setup`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('[data-testid="exam-template-setup-canvas"]', { timeout: 20000 }); + await page.waitForTimeout(3500); + + const text = await page.locator('body').innerText(); + const checks = { + hasBoundaryTool: text.includes('Boundary'), + hasPartTool: text.includes('Part'), + hasResponseTool: text.includes('Response'), + hasContextTool: text.includes('Context'), + hasQuestionNumberTool: text.includes('Q Number'), + hasMarkAreaTool: text.includes('Mark Area'), + hasReferenceTool: text.includes('Reference'), + hasFurnitureTool: text.includes('Furniture'), + hasHintPanel: text.includes('Setup guide') && text.includes('Boundary pairing preview'), + hasMultiPageCopy: text.includes('later page') || text.includes('multi-page'), + hasPdfStatus: text.includes('PDF backdrop:'), + crashOverlay: text.includes('Template setup canvas crashed'), + }; + await page.screenshot({ path: `${OUT}/s4-9c-${mode}.png`, fullPage: true }); + await browser.close(); + return { mode, url: `${BASE}/exam-marker/${TEMPLATE_ID}/setup`, checks, consoleMessages: messages }; +} + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const results = []; + for (const mode of ['light', 'dark']) results.push(await run(mode)); + fs.writeFileSync(`${OUT}/s4-9c-smoke.json`, JSON.stringify(results, null, 2)); + const failures = results.flatMap(r => Object.entries(r.checks).filter(([k,v]) => k === 'crashOverlay' ? v : !v).map(([k]) => `${r.mode}:${k}`)); + const consoleErrors = results.flatMap(r => r.consoleMessages.filter(m => /^(error|pageerror):/i.test(m) && !/Failed to load resource/i.test(m))); + console.log(JSON.stringify({ results, failures, consoleErrors }, null, 2)); + if (failures.length || consoleErrors.length) process.exit(1); +})().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index d92e680..8e9bd39 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -5,6 +5,14 @@ import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, S import ArrowBackIcon from '@mui/icons-material/ArrowBack' import SaveIcon from '@mui/icons-material/Save' import MouseIcon from '@mui/icons-material/Mouse' +import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule' +import CropSquareIcon from '@mui/icons-material/CropSquare' +import EditNoteIcon from '@mui/icons-material/EditNote' +import VisibilityIcon from '@mui/icons-material/Visibility' +import TagIcon from '@mui/icons-material/Tag' +import GradingIcon from '@mui/icons-material/Grading' +import AttachFileIcon from '@mui/icons-material/AttachFile' +import HideSourceIcon from '@mui/icons-material/HideSource' import '@tldraw/tldraw/tldraw.css' import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw' import axios from 'axios' @@ -13,20 +21,20 @@ import { ErrorBoundary } from '../../../components/ErrorBoundary' import { logger } from '../../../debugConfig' import { examRepository } from '../../../services/exam/examRepository' import type { ExamTemplateDetail } from '../../../types/exam.types' -import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' +import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, pageForY, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes' import { loadPdfPageImages, PdfPageImage } from './pdfLoader' const TOOLS = [ - { id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'inherit' as const }, - { id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const }, - { id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const }, - { id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const }, - { id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const }, - { id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const }, - { id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const }, - { id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const }, - { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' as const }, + { id: 'select', label: 'Select', tip: 'Move, resize, delete, or inspect attachment pills.', color: 'inherit' as const, icon: }, + { id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Place a top line, then a bottom line; the bottom can be on a later PDF page for multi-page questions.', color: 'error' as const, icon: }, + { id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const, icon: }, + { id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const, icon: }, + { id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const, icon: }, + { id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number for OCR and template checking.', color: 'success' as const, icon: }, + { id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2] or Total for Question X.', color: 'success' as const, icon: }, + { id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box formula sheets, data sheets, appendices, or other student resources.', color: 'info' as const, icon: }, + { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins, page numbers, blank extra space, or decoration to ignore.', color: 'inherit' as const, icon: }, ] const PAGE_START_X = 260 @@ -121,6 +129,27 @@ function syncPdfPages(editor: Editor, pages: PdfPageImage[]) { try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ } } +type BoundaryPairSummary = { index: number; label: string; startPage: number; endPage: number; multiPage: boolean } + +function boundaryPairSummaries(editor: Editor | null, pages: CanvasPageGeometry[]): BoundaryPairSummary[] { + if (!editor) return [] + const boundaries = editor.getCurrentPageShapes() + .map(modelFromTLShape) + .filter((shape): shape is ExamCanvasShapeModel => shape?.kind === 'boundary') + .sort((a, b) => (pageForY(a.y + a.h / 2, pages) - pageForY(b.y + b.h / 2, pages)) || (a.y - b.y)) + const summaries: BoundaryPairSummary[] = [] + for (let i = 0; i < boundaries.length; i += 2) { + const top = boundaries[i] + const bottom = boundaries[i + 1] + if (!top || !bottom) break + const startPage = pageForY(top.y + top.h / 2, pages) + const endPage = pageForY(bottom.y + bottom.h / 2, pages) + const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${summaries.length + 1}` + summaries.push({ index: summaries.length + 1, label, startPage, endPage, multiPage: startPage !== endPage }) + } + return summaries +} + function seedGuide(editor: Editor) { const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) if (current.length) return @@ -147,6 +176,11 @@ const ExamTemplateSetupInner: React.FC = () => { const [activeTool, setActiveTool] = useState('select') const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading') const [pdfError, setPdfError] = useState(null) + const [boundaryPairs, setBoundaryPairs] = useState([]) + + const refreshBoundaryPairs = useCallback(() => { + setBoundaryPairs(boundaryPairSummaries(editorRef.current, pageGeometriesRef.current)) + }, []) const load = useCallback(async () => { if (!templateId) return @@ -173,6 +207,9 @@ const ExamTemplateSetupInner: React.FC = () => { if (editor) { syncPdfPages(editor, pages) loadShapes(editor, shapesFromTemplate(detail, geometries)) + setBoundaryPairs(boundaryPairSummaries(editor, geometries)) + } else { + setBoundaryPairs([]) } setDirty(false) } catch (e) { @@ -182,7 +219,7 @@ const ExamTemplateSetupInner: React.FC = () => { } finally { setLoading(false) } - }, [templateId]) + }, [templateId, refreshBoundaryPairs]) useEffect(() => { void load() }, [load]) @@ -197,6 +234,7 @@ const ExamTemplateSetupInner: React.FC = () => { const saved = await examRepository.replaceTemplate(templateId, payload) setTemplate(saved) loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current)) + refreshBoundaryPairs() setDirty(false) } catch (e) { const msg = apiMessage(e) @@ -205,7 +243,7 @@ const ExamTemplateSetupInner: React.FC = () => { } finally { setSaving(false) } - }, [template, templateId]) + }, [template, templateId, refreshBoundaryPairs]) const toolButtons = useMemo(() => TOOLS.map((tool) => ( @@ -213,7 +251,7 @@ const ExamTemplateSetupInner: React.FC = () => { size="small" variant={activeTool === tool.id ? 'contained' : 'outlined'} color={tool.color} - startIcon={tool.id === 'select' ? : undefined} + startIcon={tool.icon} onClick={() => { const editor = editorRef.current if (!editor) return @@ -227,9 +265,93 @@ const ExamTemplateSetupInner: React.FC = () => { )), [activeTool]) + const canvasCss = { + '& .tlui-layout': { display: 'none' }, + '& .exam-canvas-shape': { + width: '100%', + height: '100%', + boxSizing: 'border-box', + position: 'relative', + border: '2px var(--exam-shape-border-style) var(--exam-shape-stroke)', + borderRadius: 'var(--exam-shape-radius)', + background: 'var(--exam-shape-fill)', + color: 'var(--exam-shape-stroke)', + fontFamily: 'Inter, system-ui, sans-serif', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 0.75, + padding: 1, + boxShadow: '0 10px 22px rgba(15,23,42,0.10)', + overflow: 'hidden', + }, + '& .exam-canvas-shape--boundary': { + alignItems: 'center', + justifyContent: 'center', + height: '100%', + minHeight: 8, + padding: '0 64px', + background: 'transparent', + boxShadow: 'none', + }, + '& .exam-canvas-shape__main-label': { + fontSize: 12, + fontWeight: 900, + textTransform: 'uppercase', + letterSpacing: 0.6, + background: 'rgba(255,255,255,0.90)', + border: '1px solid rgba(15,23,42,0.12)', + borderRadius: 999, + padding: '2px 8px', + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + lineHeight: 1.2, + }, + '& .exam-canvas-shape__icon': { minWidth: 14, textAlign: 'center' }, + '& .exam-canvas-shape__badge': { + fontSize: 10, + fontWeight: 800, + letterSpacing: 0.7, + textTransform: 'uppercase', + background: 'rgba(255,255,255,0.68)', + borderRadius: 999, + padding: '2px 7px', + color: 'inherit', + }, + '& .exam-canvas-shape__endcap': { + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + fontSize: 11, + fontWeight: 900, + borderRadius: 999, + padding: '1px 7px', + background: 'var(--exam-shape-stroke)', + color: '#fff', + boxShadow: '0 2px 8px rgba(0,0,0,0.16)', + }, + '& .exam-canvas-shape__endcap--left': { left: 6 }, + '& .exam-canvas-shape__endcap--right': { right: 6 }, + ...(theme.palette.mode === 'dark' ? { + '& .exam-canvas-shape': { + borderColor: 'var(--exam-shape-stroke-dark)', + background: 'var(--exam-shape-fill-dark)', + color: 'var(--exam-shape-stroke-dark)', + boxShadow: '0 10px 22px rgba(0,0,0,0.28)', + }, + '& .exam-canvas-shape--boundary': { background: 'transparent', boxShadow: 'none' }, + '& .exam-canvas-shape__main-label, & .exam-canvas-shape__badge': { + background: 'rgba(15,23,42,0.84)', + borderColor: 'rgba(255,255,255,0.16)', + }, + '& .exam-canvas-shape__endcap': { background: 'var(--exam-shape-stroke-dark)', color: '#0f172a' }, + } : {}), + } + return ( t.zIndex.drawer + 20, bgcolor: 'background.default' }}> - + { onMount={(editor) => { editorRef.current = editor editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) - editor.store.listen(() => setDirty(true), { scope: 'document' }) + editor.store.listen(() => { setDirty(true); refreshBoundaryPairs() }, { scope: 'document' }) if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor) + refreshBoundaryPairs() }} /> @@ -260,11 +383,27 @@ const ExamTemplateSetupInner: React.FC = () => { {toolButtons} - + Setup guide - 1) Draw top and bottom Boundary lines for each main question. 2) Draw Part boxes inside the pair. 3) Draw Response/Context/metadata regions inside a Part. Save derives parent links by spatial containment and reloads from the API. + Start with boundaries: place the top line, then the bottom line. If a question continues, scroll to the later page and place the bottom boundary there. Next draw Part boxes, then Response/Context/metadata regions; Save links regions by spatial containment. + + + + + + + t.palette.mode === 'dark' ? 'rgba(248,113,113,0.12)' : 'rgba(239,68,68,0.06)', border: '1px dashed', borderColor: 'error.main' }}> + Boundary pairing preview + {boundaryPairs.length ? boundaryPairs.slice(0, 4).map((pair) => ( + + {pair.label}: p{pair.startPage} top → p{pair.endPage} bottom{pair.multiPage ? ' · multi-page span' : ''} + + )) : ( + Draw two Boundary lines to preview the saved question span. + )} + PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'} diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index 50da7be..d12eeba 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -36,34 +36,42 @@ export type ExamCanvasTLShape = TLBaseBoxShape & { } } -const palette: Record = { - boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' }, - part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' }, - response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' }, - context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' }, - question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' }, - mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' }, - reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' }, - furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' }, +const palette: Record = { + boundary: { stroke: '#dc2626', strokeDark: '#f87171', fill: 'rgba(239,68,68,0.06)', fillDark: 'rgba(248,113,113,0.12)', dash: '12 7', label: 'Boundary', icon: '↕', badge: 'TOP / BOTTOM' }, + part: { stroke: '#d97706', strokeDark: '#fbbf24', fill: 'rgba(245,158,11,0.18)', fillDark: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', badge: 'MARKABLE BOX' }, + response: { stroke: '#2563eb', strokeDark: '#60a5fa', fill: 'rgba(37,99,235,0.17)', fillDark: 'rgba(96,165,250,0.32)', label: 'Response', icon: '✎', badge: 'STUDENT WRITES' }, + context: { stroke: '#7c3aed', strokeDark: '#c4b5fd', fill: 'rgba(124,58,237,0.14)', fillDark: 'rgba(167,139,250,0.28)', dash: '7 5', label: 'Context', icon: '◌', badge: 'STIMULUS' }, + question_number: { stroke: '#0f766e', strokeDark: '#5eead4', fill: 'rgba(15,118,110,0.15)', fillDark: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', badge: 'OCR LABEL' }, + mark_area: { stroke: '#16a34a', strokeDark: '#86efac', fill: 'rgba(22,163,74,0.15)', fillDark: 'rgba(74,222,128,0.24)', label: 'Mark Area', icon: '✓', badge: 'PRINTED MARKS' }, + reference: { stroke: '#0891b2', strokeDark: '#67e8f9', fill: 'rgba(8,145,178,0.14)', fillDark: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '📎', badge: 'RESOURCE' }, + furniture: { stroke: '#64748b', strokeDark: '#cbd5e1', fill: 'rgba(100,116,139,0.12)', fillDark: 'rgba(148,163,184,0.22)', dash: '3 5', label: 'Furniture', icon: '×', badge: 'IGNORE' }, } function renderShape(shape: ExamCanvasTLShape) { const kind = shape.props.kind const p = palette[kind] ?? palette.response const isBoundary = kind === 'boundary' + const label = shape.props.label || p.label return ( -
- - {shape.props.label || p.label} +
+ + + {label} - {!isBoundary && shape.props.questionId && Attached} + {!isBoundary && {shape.props.questionId ? 'Attached' : p.badge}} + {isBoundary && {label.toLowerCase().includes('end') ? 'B' : 'T'}} + {isBoundary && {label.toLowerCase().includes('end') ? 'Bottom' : 'Top'}}
)