diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts index 9e83a87..77506d6 100644 --- a/src/utils/exam-canvas/model.test.ts +++ b/src/utils/exam-canvas/model.test.ts @@ -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 + }) + }) diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts index c18a27b..41904b7 100644 --- a/src/utils/exam-canvas/model.ts +++ b/src/utils/exam-canvas/model.ts @@ -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) })